{
+ expect(
+ render(
+
,
+ ),
+ ).toContain('data-text="Checking the trend…"');
+ expect(
+ render(
+
,
+ ),
+ ).toContain('data-text="Checking: Lab results, last 12 months…"');
+ });
+
+ it("reads in German", () => {
+ const html = render(
+
,
+ "de",
+ );
+ expect(html).toContain('data-text="Denkt nach…"');
+ });
+});
+
+describe("the labels", () => {
it("names the latest running entry, else the latest one", () => {
expect(currentActivity([THINKING_DONE, FETCH_BP, DIGEST_RUNNING])?.id).toBe(
"a4",
@@ -242,59 +370,134 @@ describe("the running line", () => {
).toBe("Fetching blood pressure, last 90 days…");
});
- it("walks thinking → fetching → summarising, one quiet line", () => {
- const frames = [
- [THINKING_RUNNING],
- [
- { ...THINKING_RUNNING, status: "done" as const },
- { ...FETCH_BP, status: "running" as const },
- ],
- [THINKING_DONE, FETCH_BP, DIGEST_RUNNING],
- ];
- const lines = frames.map((activity) =>
- render(
-
,
- ),
+ it("ends a running line in exactly one ellipsis", () => {
+ expect(inProgressLabel("Writing the answer")).toBe("Writing the answer…");
+ expect(inProgressLabel("Writing the answer…")).toBe("Writing the answer…");
+ expect(inProgressLabel("Writing the answer...")).toBe(
+ "Writing the answer…",
);
- expect(lines[0]).toContain(">Thinking…<");
- expect(lines[1]).toContain(">Fetching blood pressure, last 90 days…<");
- expect(lines[2]).toContain(">Summarising 214 readings…<");
- for (const html of lines) {
- expect(html.match(/data-slot="coach-turn-steps-toggle"/g)).toHaveLength(
- 1,
- );
- expect(html).toContain('data-state="running"');
- expect(html).toContain('aria-label="Show what the Coach looked at"');
- // The seconds so far follow the text directly, in the line's own
- // muted tone, with the summary's spacing ("12 s"), never pushed to
- // the far edge of the column.
- expect(html).toMatch(
- /data-slot="coach-turn-steps-active"[^>]*>[^<]*<\/span>
]*data-slot="coach-turn-steps-seconds"[^>]*>1[23]\u00a0s,
- );
- const seconds = html.match(
- /]*data-slot="coach-turn-steps-seconds"[^>]*>/,
- )?.[0];
- expect(seconds).toContain("tabular-nums");
- expect(seconds).not.toMatch(/\bml-auto\b|text-foreground/);
+ });
+
+ it("picks an icon by the kind of work", () => {
+ expect(stepIcon("thinking", undefined)).toBe(Lightbulb);
+ expect(stepIcon("fetch", "get_metric_series")).toBe(Database);
+ expect(stepIcon("fetch", "get_metric_table")).toBe(Table2);
+ expect(stepIcon("fetch", "get_correlations")).toBe(ChartScatter);
+ expect(stepIcon(null, "get_sleep")).toBe(Database);
+ // The brain belongs to the row, never to a step.
+ for (const phase of [
+ "thinking",
+ "memory",
+ "fetch",
+ "digest",
+ "checkpoint",
+ "remember",
+ "plan",
+ "asking",
+ "stop",
+ "answer",
+ ] as const) {
+ expect(stepIcon(phase, undefined)).not.toBe(Brain);
}
});
+});
- it("says Thinking… before the first frame arrives", () => {
- const html = render();
- expect(html).toContain(">Thinking…<");
- expect(html).toContain('aria-expanded="false"');
+describe("after the answer", () => {
+ it("settles into the thought process and its step count, without shimmer", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-state="done"');
+ expect(html).toMatch(
+ /data-slot="coach-turn-steps-done"[^>]*>Thought process · 5 steps,
+ );
+ 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(
+ ,
+ "de",
+ );
+ expect(de).toContain("Denkprozess · 1 Schritt<");
+ const pl = render(
+ ,
+ "pl",
+ );
+ expect(pl).toContain("Tok myślenia · 3 kroki<");
+ });
+
+ it("an older message counts its steps, or the areas it drew on", () => {
+ expect(
+ render(
+ ,
+ ),
+ ).toContain("Thought process · 2 steps<");
+ expect(
+ render(
+ ,
+ ),
+ ).toContain("Thought process · 1 step<");
+ });
+
+ it("says an answer is needed while a question waits, without shimmer", () => {
const html = render(
- ,
+ ,
+ );
+ expect(html).toContain('data-state="awaiting"');
+ expect(html).toMatch(
+ /data-slot="coach-turn-steps-done"[^>]*>Answer needed,
+ );
+ expect(html).not.toContain("text-shimmer");
+ expect(
+ render(
+ ,
+ "de",
+ ),
+ ).toContain(">Antwort nötig<");
+ });
+
+ it("renders nothing for a settled message with nothing to show", () => {
+ expect(
+ render(),
+ ).not.toContain("coach-turn-steps");
+ });
+
+ it("carries a polite status region that starts empty", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toMatch(
+ /<\/span>/,
);
- expect(html).toContain("Checking: Lab results, last 12 months");
});
});
@@ -320,6 +523,7 @@ describe("nothing opens by itself", () => {
activity={[THINKING_DONE, ASKING]}
steps={[]}
active={false}
+ awaitingAnswer
/>,
],
[
@@ -370,109 +574,78 @@ describe("nothing opens by itself", () => {
for (const [name, node] of states) {
it(`is closed when ${name}`, () => {
const html = render(node);
- expect(html).toContain('aria-expanded="false"');
expect(html).not.toContain('aria-expanded="true"');
+ expect(html).not.toContain('rotate-180"');
expect(html).not.toContain('data-slot="coach-turn-steps-panel"');
expect(html).not.toContain('data-slot="coach-turn-step-detail"');
expect(html).not.toContain("The second half of the month runs higher.");
});
}
+
+ it("a row opened while the turn ran closes when it ends, and nothing opens it", () => {
+ // Opened during the run, the turn ends: closed.
+ expect(openAfter({ open: true, wasActive: true, active: false })).toBe(
+ false,
+ );
+ // Closed stays closed through every move.
+ for (const [wasActive, active] of [
+ [true, false],
+ [false, true],
+ [true, true],
+ [false, false],
+ ] as const) {
+ expect(openAfter({ open: false, wasActive, active })).toBe(false);
+ }
+ // A tap on a settled row is the person's, and stays.
+ expect(openAfter({ open: true, wasActive: false, active: false })).toBe(
+ true,
+ );
+ });
});
-describe("after the answer", () => {
- const { t } = translators("en");
-
- it("collapses to one calm summary of lookups and seconds", () => {
+describe("tokens and motion", () => {
+ it("uses theme tokens only, and every motion is guarded or turned off by reduced motion", () => {
const html = render(
,
);
- expect(html).toContain('data-state="done"');
- expect(html).toContain(
- 'data-slot="coach-turn-steps-done" class="min-w-0 truncate">Thought it through, 2 lookups, 12 s<',
+ expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
+ expect(html).not.toMatch(/text-muted-foreground\/\d/);
+ const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
+ m[1].split(/\s+/),
);
- // No spinner, no running seconds, no stable-name override once settled.
- expect(html).not.toContain("coach-turn-steps-spinner");
- expect(html).not.toContain("coach-turn-steps-seconds");
- expect(html).not.toContain('aria-label="Show what the Coach looked at"');
+ for (const cls of classes) {
+ if (cls.startsWith("transition") || cls.startsWith("animate-")) {
+ throw new Error(`unguarded motion: ${cls}`);
+ }
+ }
+ // A 44 px tap target on phones.
+ expect(html).toContain("min-h-11");
});
- it("counts fetches as lookups and runs a round's fetches side by side", () => {
- expect(countLookups(SETTLED)).toBe(2);
- // 4 s thinking + max(0.9, 1.4) s fetching + 0.6 s digest + 6 s answer.
- expect(activitySeconds(SETTLED)).toBe(12);
- expect(activitySeconds([THINKING_RUNNING])).toBeNull();
- });
-
- it("uses the live turn's clock when the trail has no durations", () => {
- expect(
- activitySummary({
- activity: [{ ...ANSWER_DONE, durationMs: undefined }],
- startedAt: 1_000,
- endedAt: 19_000,
- t,
- locale: "en",
- }),
- ).toBe("Thought it through, 18 s");
- });
-
- it("reads in German", () => {
- const html = render(
- ,
- "de",
+ it("the shimmer and the dots stop under reduced motion, in the stylesheet", () => {
+ const css = readFileSync(
+ join(__dirname, "../../../../app/globals.css"),
+ "utf8",
);
- expect(html).toContain("Nachgedacht, 2 Abfragen, 12 s");
- });
-
- it("an older message still folds into its sources", () => {
- const html = render(
- ,
+ const shimmer =
+ css.match(/@utility text-shimmer \{[\s\S]*?\n\}/)?.[0] ?? "";
+ expect(shimmer).toContain("background-clip: text");
+ expect(shimmer).toContain("background-size: 250% 100%");
+ expect(shimmer).toContain("animation: text-shimmer 2s linear infinite");
+ expect(shimmer).toMatch(
+ /@media \(prefers-reduced-motion: reduce\) \{\s*animation: none;\s*background-image: none;\s*color: var\(--muted-foreground\);/,
);
- expect(html).toContain("Looked at 2 sources");
- expect(
- countSources([BP, { ...BP, id: "s9", period: "previous" }, SLEEP]),
- ).toBe(2);
- });
-
- it("an answer saved before steps folds into its areas", () => {
- expect(
- render(
- ,
- ),
- ).toContain("Looked at 2 areas");
- expect(
- render(
- ,
- "de",
- ),
- ).toContain("1 Bereich angesehen");
- });
-
- it("renders nothing for a settled message with nothing to show", () => {
- expect(
- render(),
- ).not.toContain("coach-turn-steps");
- });
-
- it("carries a polite status region that starts empty", () => {
- const html = render(
- ,
+ expect(css).toMatch(
+ /@keyframes text-shimmer \{\s*from \{\s*background-position: 100% 0;\s*\}\s*to \{\s*background-position: 0% 0;/,
);
- expect(html).toMatch(
- /<\/span>/,
+ const dot = css.match(/@utility waiting-dot \{[\s\S]*?\n\}/)?.[0] ?? "";
+ expect(dot).toContain("animation: waiting-dot 1.4s ease-in-out infinite");
+ expect(dot).toMatch(
+ /@media \(prefers-reduced-motion: reduce\) \{\s*animation: none;\s*opacity: 1;/,
);
});
});
@@ -485,72 +658,6 @@ describe("currentStep", () => {
});
});
-describe("tokens and motion", () => {
- it("uses theme tokens only, and every motion stops under reduced motion", () => {
- const html =
- render(
- ,
- ) +
- render(
- ,
- );
- expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
- expect(html).not.toMatch(/text-muted-foreground\/\d/);
- const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
- m[1].split(/\s+/),
- );
- for (const cls of classes) {
- if (cls.startsWith("transition")) {
- throw new Error(`unguarded transition: ${cls}`);
- }
- }
- expect(html).toContain("animate-spin motion-reduce:animate-none");
- // A 44 px tap target on phones.
- expect(html).toContain("min-h-11");
- });
-});
-
-describe("", () => {
- it("one row per step: domain · window · count or reason, with its status", () => {
- const html = render(
- ,
- );
- expect(html).toContain('aria-label="What the Coach looked at"');
- expect(html.match(/data-slot="coach-turn-step"/g)).toHaveLength(3);
- expect(html).toContain('data-status="done"');
- expect(html).toContain('data-status="empty"');
- expect(html).toContain('data-status="running"');
- let text = html.replace(
- /, <\/span>/g,
- "",
- );
- for (let prev = ""; prev !== text;) {
- prev = text;
- text = text.replace(/<[^>]*>/g, "");
- }
- expect(text).toContain("Blood pressure · last 90 days · 142 readings");
- expect(text).toContain("Sleep · last 30 days · no readings");
- expect(text).toContain("Lab results · last 12 months");
- });
-});
-
describe("legacyAreaLabels", () => {
const { t } = translators("en");
@@ -563,3 +670,22 @@ describe("legacyAreaLabels", () => {
).toEqual(["Blood pressure", "Sleep"]);
});
});
+
+describe("the visible row text", () => {
+ it("is the status alone", () => {
+ expect(
+ textOf(
+ rowHtml(
+ render(
+ ,
+ ),
+ ),
+ ),
+ ).toBe("Thought process · 5 steps");
+ expect(
+ textOf(
+ rowHtml(render()),
+ ),
+ ).toBe("Thinking...");
+ });
+});
diff --git a/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
index bbb4bcaf6..976bb041e 100644
--- a/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
+++ b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
@@ -56,12 +56,9 @@ function streaming(
followUps: [],
clarification: null,
activity: [],
- interimRefs: [],
memoryNote: null,
planProposal: null,
stop: null,
- startedAt: null,
- endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx
index 435d324ad..f2a7ef634 100644
--- a/src/components/insights/coach-panel/chat-bubble.tsx
+++ b/src/components/insights/coach-panel/chat-bubble.tsx
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
+import { DEMO_READ_ONLY_CODE } from "@/lib/demo-refusal";
import {
parseChartTokens,
tokenToMetric,
@@ -20,7 +21,6 @@ import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
import { CoachTurnActivity } from "./turn-activity";
-import { CoachInterimResults } from "./interim-results";
import {
CoachResults,
countResultsInSection,
@@ -100,6 +100,10 @@ export function errorCodeToI18nKey(code: string): string {
return "insights.coach.errorNoProvider";
case "module.disabled":
return "insights.coach.errorCoachOff";
+ // A demo deploy refuses every write, the chat POST included. That is
+ // the demo's nature, not an outage, and the line says so.
+ case DEMO_READ_ONLY_CODE:
+ return "insights.coach.errorDemo";
default:
// The operator switched the Coach (or all AI) off on this server.
if (code.startsWith("assistant.disabled.")) {
@@ -337,11 +341,6 @@ interface ChatBubbleProps {
* its metadata on `metricSource.activity`; the bubble falls back to that.
*/
activity?: CoachActivity[];
- /** v1.41 — the refs of the tables that arrived while the turn ran. */
- interimRefs?: string[];
- /** v1.41 — when the live turn started and ended on this device. */
- startedAt?: number | null;
- endedAt?: number | null;
/** v1.41 — the live `memoryNote` frame, with the fact's words. */
memoryNote?: CoachMemoryNoteFrame | null;
}
@@ -416,9 +415,6 @@ export function areChatBubblePropsEqual(
prev.onFollowUp === next.onFollowUp &&
prev.replies === next.replies &&
prev.activity === next.activity &&
- prev.interimRefs === next.interimRefs &&
- prev.startedAt === next.startedAt &&
- prev.endedAt === next.endedAt &&
prev.memoryNote === next.memoryNote &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
@@ -449,9 +445,6 @@ function ChatBubbleImpl({
onFollowUp,
replies,
activity,
- interimRefs,
- startedAt,
- endedAt,
memoryNote,
}: ChatBubbleProps) {
const { t } = useTranslations();
@@ -645,18 +638,22 @@ function ChatBubbleImpl({
data-slot="coach-answer-column"
className="flex w-full max-w-full min-w-0 flex-col items-start gap-2 sm:max-w-[calc(80%-2.625rem)]"
>
- {/* v1.41 — what the Coach is doing, one quiet line at the top of the
- answer: live from the activity frames while the turn runs, one
- calm summary once it is done. Nothing opens by itself; a tap on
- the line opens the trail, which also says how the answer was
- worked out and holds the tables the answer read. A message saved
- before v1.41 falls back to its steps, or the areas it drew on. */}
+ {/* v1.41 — what the Coach is doing, one quiet row at the top of the
+ answer and the only thing in the column until the answer starts:
+ live from the activity frames while the turn runs, "Thought
+ process · N steps" once it is done, "Answer needed" while a
+ question waits. Nothing opens by itself; a tap on the row opens
+ the steps, which also say how the answer was worked out and hold
+ the tables the answer read. A message saved before v1.41 falls
+ back to its steps, or the areas it drew on. */}
reply.kind === "clarification")
+ }
areas={metricSource?.metrics}
method={inProgress || errorCode ? null : method}
conversationId={conversationId}
@@ -673,11 +670,6 @@ function ChatBubbleImpl({
) : null
}
/>
- {/* v1.41 — the tables read so far, one line each, while the turn
- still runs. They give way to the answer's own charts. */}
- {inProgress && results && interimRefs && interimRefs.length > 0 ? (
-
- ) : null}
{/* The prose bubble, once there is prose or an error. While the turn
runs without prose the status line above says what is happening;
the bubble arrives with the first token. */}
diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx
index b1ad56296..2565c9bb0 100644
--- a/src/components/insights/coach-panel/coach-results.tsx
+++ b/src/components/insights/coach-panel/coach-results.tsx
@@ -124,7 +124,7 @@ function displayedByRef(
/**
* The live tables with `displayed` taken from the provenance where it names
- * them: an interim table is sent before the answer decides.
+ * them: a table read while the turn ran is sent before the answer decides.
*/
export function liveWithProvenance<
T extends Pick,
@@ -265,7 +265,7 @@ export function CoachResults({
type ResultView = "chart" | "table";
-export function CoachResultView({
+function CoachResultView({
result,
chartFirst,
}: {
diff --git a/src/components/insights/coach-panel/interim-results.tsx b/src/components/insights/coach-panel/interim-results.tsx
deleted file mode 100644
index 4e8f6c4ea..000000000
--- a/src/components/insights/coach-panel/interim-results.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-"use client";
-
-/**
- * v1.41 — the tables a turn has read so far, while it still runs.
- *
- * Under the status line, one quiet line per table as soon as the call that
- * read it settles: its title, window and how many readings it holds, with a
- * small sparkline in the first chart colour and no axes. At most three. A
- * tap opens the full chart (or table) right there; nothing opens by itself.
- * Once the answer settles the previews go: the tables the answer points at
- * become its charts, the rest sit under "Data used" in the closed trail.
- */
-import { useId, useState } from "react";
-import { ChevronRight } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-import { useTranslations } from "@/lib/i18n/context";
-import type { Locale } from "@/lib/i18n/config";
-import {
- coachWindowLabelKey,
- interimLabelKey,
-} from "@/lib/ai/coach/dialog-keys";
-import type { CoachResultTable } from "@/lib/ai/coach/types";
-
-import { CoachResultView } from "./coach-results";
-
-/** At most this many previews show, whatever the turn has read. */
-export const MAX_INTERIM_PREVIEWS = 3;
-
-type Translate = (
- key: string,
- params?: Record,
-) => string;
-
-/** The interim tables to preview, in the order they arrived. */
-export function interimPreviews(
- results: readonly CoachResultTable[],
- interimRefs: readonly string[],
-): CoachResultTable[] {
- const refs = new Set(interimRefs);
- return results
- .filter((result) => refs.has(result.ref))
- .slice(0, MAX_INTERIM_PREVIEWS);
-}
-
-/**
- * How many readings a table holds: the sum of its count column when it has
- * one (readings per day or week), else its rows.
- */
-export function interimReadingCount(result: CoachResultTable): number {
- const countIndex = result.columns.findIndex((c) => c.kind === "count");
- if (countIndex === -1) return result.rowCount;
- let sum = 0;
- for (const row of result.rows) {
- const cell = row[countIndex];
- if (typeof cell === "number") sum += cell;
- }
- return sum > 0 ? sum : result.rowCount;
-}
-
-/** "Blood pressure by day, last 30 days, 214 readings". */
-export function interimLabel(
- result: CoachResultTable,
- t: Translate,
- locale: Locale,
-): string {
- const count = interimReadingCount(result);
- return t(interimLabelKey(count, locale), {
- title: result.title,
- window: t(coachWindowLabelKey(result.source.window)),
- count,
- });
-}
-
-/** The values the sparkline draws: the first charted series, in row order. */
-export function sparklineValues(result: CoachResultTable): number[] {
- const spec = result.chart;
- const key =
- spec?.kind === "line" || spec?.kind === "bar"
- ? spec.series[0]
- : spec?.kind === "compare"
- ? spec.a
- : result.columns.find((c) => c.kind === "number")?.key;
- const index = result.columns.findIndex((c) => c.key === key);
- if (index === -1) return [];
- return result.rows
- .map((row) => row[index])
- .filter((cell): cell is number => typeof cell === "number");
-}
-
-/** An SVG polyline path through the values, inside `width × height`. */
-export function sparklinePoints(
- values: readonly number[],
- width: number,
- height: number,
-): string {
- if (values.length === 0) return "";
- const min = Math.min(...values);
- const max = Math.max(...values);
- const span = max - min || 1;
- const step = values.length > 1 ? width / (values.length - 1) : 0;
- return values
- .map((value, i) => {
- const x = values.length > 1 ? i * step : width / 2;
- const y = height - 1 - ((value - min) / span) * (height - 2);
- return `${x.toFixed(1)},${y.toFixed(1)}`;
- })
- .join(" ");
-}
-
-function Sparkline({ values }: { values: number[] }) {
- if (values.length < 2) return null;
- return (
-
- );
-}
-
-function InterimPreview({ result }: { result: CoachResultTable }) {
- const { t, locale } = useTranslations();
- const detailId = useId();
- const [open, setOpen] = useState(false);
- return (
-
-
- {open ? (
-
-
-
- ) : null}
-
- );
-}
-
-export function CoachInterimResults({
- results,
- interimRefs,
-}: {
- results: readonly CoachResultTable[];
- interimRefs: readonly string[];
-}) {
- const previews = interimPreviews(results, interimRefs);
- if (previews.length === 0) return null;
- return (
-
- {previews.map((result) => (
-
- ))}
-
- );
-}
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx
index cab74b884..a7f3e82d6 100644
--- a/src/components/insights/coach-panel/message-thread.tsx
+++ b/src/components/insights/coach-panel/message-thread.tsx
@@ -716,15 +716,13 @@ export function MessageThread({
? { results: streaming.results }
: {})}
// v1.41 — and its live trail (with the titles and texts
- // the persisted copy only has behind `…/trail`), its clock
- // and the words of the fact it kept.
+ // the persisted copy only has behind `…/trail`) and the
+ // words of the fact it kept.
{...(m.id === streaming?.messageId
? {
...(streaming.activity.length > 0
? { activity: streaming.activity }
: {}),
- startedAt: streaming.startedAt,
- endedAt: streaming.endedAt,
memoryNote: streaming.memoryNote,
}
: {})}
@@ -782,9 +780,6 @@ export function MessageThread({
steps={streaming.steps}
results={streaming.results}
activity={streaming.activity}
- interimRefs={streaming.interimRefs}
- startedAt={streaming.startedAt}
- endedAt={streaming.endedAt}
memoryNote={streaming.memoryNote}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
diff --git a/src/components/insights/coach-panel/turn-activity.tsx b/src/components/insights/coach-panel/turn-activity.tsx
index a0acfcdcd..34ac42142 100644
--- a/src/components/insights/coach-panel/turn-activity.tsx
+++ b/src/components/insights/coach-panel/turn-activity.tsx
@@ -1,50 +1,63 @@
"use client";
/**
- * v1.41 — the live trail of a Coach turn, one quiet line at the top of the
- * answer.
+ * v1.41.1 — the thinking row of a Coach turn: one quiet line at the top of
+ * the answer, and the steps behind it.
*
- * While the turn runs the line says what the Coach is doing right now, from
- * the `activity` frames: "Thinking…", "Fetching blood pressure, last 90
- * days…", "Summarising 214 readings…", "Writing the answer…", with the
- * seconds so far right after the text. A reasoning title replaces the
- * thinking label when one arrives. Once the turn ends the same line settles
- * into one calm summary: "Thought it through, 3 lookups, 12 s".
+ * The row is a brain, the status text and a chevron, in the answer's own
+ * text size and the muted tone. While the turn runs the text says what the
+ * Coach is doing right now ("Fetching blood pressure, last 90 days…", or
+ * "Thinking…" before the first frame), with a shimmer passing through it and
+ * the trailing dots fading in turn; there is no clock. When the answer is
+ * done the row settles into "Thought process · N steps", and a turn that
+ * ends on a question to the person says "Answer needed" instead. The chevron
+ * only shows when there is something to open.
*
- * Nothing opens by itself. The trail starts closed in every state of a turn
- * (running, asking, stopped, failed, settled, reloaded) and only a tap on the
- * line opens it; it then stays open until the person closes it, and keeps
- * growing while the turn runs. Open, it lists one entry per phase with its
- * status, the reasoning text of a round behind its own disclosure (closed
- * too), the method line and the tables the answer used without pointing at
- * them.
+ * Nothing opens by itself. The row starts closed in every state, only a tap
+ * opens it, and a row opened while the turn ran closes again when it ends:
+ * the answer is what the person came for. Open, it lists the steps one under
+ * the other, each with a small icon on a thin line that runs to the next
+ * one, a reasoning round with its summary underneath; then the method line
+ * and the tables the answer used without pointing at them.
*
* Labels are catalog text the server rendered; titles and texts are screened
* model text the owner alone receives. A persisted message carries only the
* metadata, so its titles and texts are read from `…/trail` the first time
- * the person opens the trail. A message saved before v1.41 has no activity
- * and falls back to the step list (`metricSource.steps`), and one saved
+ * the person opens the row. A message saved before v1.41 has no activity
+ * and falls back to its step list (`metricSource.steps`), and one saved
* before steps existed to the areas it drew on.
*
- * Screen readers hear the line through a polite status region, throttled to
+ * Screen readers hear the status through a polite live region, throttled to
* one announcement per `ANNOUNCE_THROTTLE_MS` (the latest wins), and the
- * summary once when the turn ends. A reloaded conversation announces nothing.
+ * settled line once when the turn ends. A reloaded conversation announces
+ * nothing.
*/
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import {
- Check,
- ChevronRight,
- Loader2,
- Minus,
- TriangleAlert,
+ BookOpen,
+ Bookmark,
+ Brain,
+ ChartLine,
+ ChartScatter,
+ ChevronDown,
+ CircleStop,
+ Database,
+ FileSearch,
+ Lightbulb,
+ ListTodo,
+ MessageCircleQuestion,
+ PenLine,
+ Sigma,
+ Table2,
+ type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
-import type { Locale } from "@/lib/i18n/config";
+import { WaitingText } from "@/components/ui/waiting-text";
import {
COACH_STEP_REASON_KEYS,
COACH_STEP_UI_KEYS,
@@ -55,7 +68,7 @@ import {
} from "@/lib/ai/coach/dialog-keys";
import {
COACH_ACTIVITY_KEYS,
- activitySummaryKey,
+ activityThoughtProcessKey,
} from "@/lib/ai/coach/activity/contract";
import type {
CoachActivity,
@@ -65,13 +78,14 @@ import type {
CoachTrail,
} from "@/lib/ai/coach/types";
+import { COACH_FOCUS_RING } from "./focus-ring";
import { CoachMethodLine } from "./method-line";
/** The shortest gap between two screen-reader announcements. */
export const ANNOUNCE_THROTTLE_MS = 1500;
-/** "12s": the seconds on the line and beside a finished round. */
-const ELAPSED_KEY = "insights.coach.thinkingElapsed";
+/** A trailing "…" or "...", which a finished entry no longer carries. */
+const TRAILING_ELLIPSIS = /(?:…|\.\.\.)\s*$/u;
type Translate = (
key: string,
@@ -141,15 +155,6 @@ export function describeStep(
return { title, meta };
}
-/**
- * How many sources the turn looked at: one per domain (a step without one,
- * the snapshot, counts by its tool). A second read of the same domain, for
- * the period before or a retry, is not a new source.
- */
-export function countSources(steps: readonly CoachStep[]): number {
- return new Set(steps.map((step) => step.domain ?? step.tool)).size;
-}
-
/**
* The step a legacy running line names: the latest one still running, else
* the latest one overall (between two rounds nothing is running).
@@ -197,8 +202,9 @@ export function currentActivity(
}
/**
- * What an entry is called on the line: a screened reasoning title for a
- * thinking round once one has arrived, else the server's catalog label.
+ * What an entry is called: a screened reasoning title for a thinking round
+ * once one has arrived, else the server's catalog label. A finished entry
+ * no longer trails off: "Writing the answer", not "Writing the answer…".
*/
export function activityLineLabel(entry: CoachActivity): string {
if (
@@ -207,89 +213,76 @@ export function activityLineLabel(entry: CoachActivity): string {
) {
return entry.title;
}
- // A finished entry no longer trails off: "Writing the answer", not
- // "Writing the answer…".
return entry.status === "running"
? entry.label
- : entry.label.replace(/(?:…|\.\.\.)\s*$/u, "");
-}
-
-/** The lookups a turn made: one per fetch. */
-export function countLookups(activity: readonly CoachActivity[]): number {
- return activity.filter((entry) => entry.phase === "fetch").length;
+ : entry.label.replace(TRAILING_ELLIPSIS, "");
}
/**
- * The seconds a turn took, from its trail: the phases of a round run one
- * after the other, except its fetches, which run side by side (the longest
- * counts). Null when the trail carries no durations.
+ * Whether the row stays open as the turn moves on: a row opened while the
+ * turn ran closes when it ends. Nothing ever opens it but a tap.
*/
-export function activitySeconds(
- activity: readonly CoachActivity[],
-): number | null {
- const fetchByRound = new Map();
- let sequential = 0;
- let any = false;
- for (const entry of activity) {
- if (entry.durationMs === undefined) continue;
- any = true;
- if (entry.phase === "fetch") {
- fetchByRound.set(
- entry.round,
- Math.max(fetchByRound.get(entry.round) ?? 0, entry.durationMs),
- );
- } else {
- sequential += entry.durationMs;
- }
+export function openAfter(args: {
+ open: boolean;
+ wasActive: boolean;
+ active: boolean;
+}): boolean {
+ return args.wasActive && !args.active ? false : args.open;
+}
+
+/** The running row's text: whatever it names, still going ("…"). */
+export function inProgressLabel(label: string): string {
+ return `${label.replace(TRAILING_ELLIPSIS, "")}…`;
+}
+
+/**
+ * The icon of a step: what kind of work it was. Reads of the record are a
+ * database, a table read for the answer a table, connections a scatter, an
+ * earlier chart reopened a chart, the record summary a search.
+ */
+export function stepIcon(
+ phase: CoachActivity["phase"] | null,
+ tool: CoachStep["tool"] | undefined,
+): LucideIcon {
+ switch (phase) {
+ case "thinking":
+ case "checkpoint":
+ return Lightbulb;
+ case "memory":
+ return BookOpen;
+ case "digest":
+ return Sigma;
+ case "remember":
+ return Bookmark;
+ case "plan":
+ return ListTodo;
+ case "asking":
+ return MessageCircleQuestion;
+ case "stop":
+ return CircleStop;
+ case "answer":
+ return PenLine;
+ default:
+ break;
+ }
+ switch (tool) {
+ case "snapshot":
+ return FileSearch;
+ case "show_result":
+ return ChartLine;
+ case "get_correlations":
+ return ChartScatter;
+ case "get_metric_table":
+ return Table2;
+ default:
+ return Database;
}
- if (!any) return null;
- let total = sequential;
- for (const ms of fetchByRound.values()) total += ms;
- return Math.max(1, Math.round(total / 1000));
-}
-
-/** Whole seconds between two instants, at least one. */
-export function elapsedSeconds(from: number, to: number): number {
- return Math.max(1, Math.round((to - from) / 1000));
-}
-
-/**
- * The settled line: "Thought it through, N lookups, S s". The seconds come
- * from the trail, else from the clock of the turn that just streamed. Null
- * when neither is known (a message saved before v1.41).
- */
-export function activitySummary(args: {
- activity: readonly CoachActivity[];
- startedAt?: number | null;
- endedAt?: number | null;
- t: Translate;
- locale: Locale;
-}): string | null {
- const { activity, startedAt, endedAt, t, locale } = args;
- if (activity.length === 0) return null;
- const seconds =
- activitySeconds(activity) ??
- (startedAt && endedAt ? elapsedSeconds(startedAt, endedAt) : null);
- if (seconds === null) return null;
- const lookups = countLookups(activity);
- return t(activitySummaryKey(lookups, locale), { lookups, seconds });
-}
-
-/** A tick per second while `running`; the current time otherwise. */
-function useNow(running: boolean): number {
- const [now, setNow] = useState(() => Date.now());
- useEffect(() => {
- if (!running) return;
- const id = setInterval(() => setNow(Date.now()), 1000);
- return () => clearInterval(id);
- }, [running]);
- return now;
}
/**
* The status region's text: the running line as it changes, throttled, and
- * the summary once when the turn ends. Nothing is announced for a trail that
- * was already settled when it mounted.
+ * the settled line once when the turn ends. Nothing is announced for a row
+ * that was already settled when it mounted.
*/
function useLineAnnouncement(text: string, active: boolean): string {
const [mountedActive] = useState(active);
@@ -299,7 +292,7 @@ function useLineAnnouncement(text: string, active: boolean): string {
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
- // A reloaded, settled trail stays quiet.
+ // A reloaded, settled row stays quiet.
if (!mountedActive) return;
if (!text || text === said.current) return;
said.current = text;
@@ -355,101 +348,115 @@ function useTrailText(args: {
return query.data ?? null;
}
-// ── Rendering ──────────────────────────────────────────────────────────────
+// ── The open list ──────────────────────────────────────────────────────────
-type EntryStatus = CoachActivity["status"];
+/** How a step reads: the one going on now, a finished one, a failed one. */
+type StepTone = "active" | "done" | "failed";
-function StatusIcon({
- status,
- active,
-}: {
- status: EntryStatus;
- active: boolean;
-}) {
- const className = "mt-0.5 size-3 shrink-0";
- if (status === "running" && active) {
- return (
-
- );
- }
- if (status === "done" || status === "running") {
- return (
-
- );
- }
- if (status === "failed") {
- return (
-
- );
- }
- return (
-
- );
+interface TrailItem {
+ key: string;
+ icon: LucideIcon;
+ label: string;
+ /** Muted parts under the label, joined by a middle dot. */
+ meta?: string[];
+ /** A reasoning summary under the label. */
+ text?: string;
+ /** What the memory step recalled, one per line. */
+ recalled?: string[];
+ tone: StepTone;
+ phase?: CoachActivity["phase"];
+ status?: CoachActivity["status"];
}
-/** The rows of a legacy step list: one per step, in the order they started. */
-export function CoachTurnStepList({
- id,
- steps,
- active,
-}: {
- id?: string;
+function toneOf(status: CoachActivity["status"], active: boolean): StepTone {
+ if (status === "failed") return "failed";
+ return status === "running" && active ? "active" : "done";
+}
+
+/** The activity entries of a turn, as the rows of the open list. */
+function activityItems(args: {
+ activity: CoachActivity[];
steps: CoachStep[];
+ trail: CoachTrail | null;
active: boolean;
-}) {
- const { t, tCount } = useTranslations();
- return (
-
- {steps.map((step) => {
- const { title, meta } = describeStep(step, t, tCount);
- return (
- -
-
-
- {title}
-
-
-
- );
- })}
-
+ t: Translate;
+ tCount: TranslateCount;
+}): TrailItem[] {
+ const { activity, steps, trail, active, t, tCount } = args;
+ const stepsById = new Map(steps.map((step) => [step.id, step]));
+ const textById = new Map(
+ (trail?.entries ?? []).map((entry) => [entry.id, entry]),
);
+ return activity.map((entry) => {
+ const stored = textById.get(entry.id);
+ const merged: CoachActivity = {
+ ...entry,
+ title: entry.title ?? stored?.title,
+ text: entry.text ?? stored?.text,
+ };
+ const step = entry.stepRef ? stepsById.get(entry.stepRef) : undefined;
+ const base = {
+ key: entry.id,
+ icon: stepIcon(entry.phase, step?.tool),
+ tone: toneOf(entry.status, active),
+ phase: entry.phase,
+ status: entry.status,
+ };
+ if (entry.phase === "fetch" && step) {
+ const { title, meta } = describeStep(step, t, tCount);
+ return { ...base, label: title, meta };
+ }
+ return {
+ ...base,
+ label: activityLineLabel(merged),
+ text: merged.text,
+ recalled:
+ entry.phase === "memory" && trail?.recalled?.length
+ ? trail.recalled
+ : undefined,
+ };
+ });
}
+/** A legacy message's steps, as the rows of the open list. */
+function stepItems(
+ steps: CoachStep[],
+ active: boolean,
+ t: Translate,
+ tCount: TranslateCount,
+): TrailItem[] {
+ return steps.map((step) => {
+ const { title, meta } = describeStep(step, t, tCount);
+ return {
+ key: step.id,
+ icon: stepIcon(null, step.tool),
+ label: title,
+ meta,
+ tone: toneOf(step.status, active),
+ status: step.status,
+ };
+ });
+}
+
+const TONE_CLASS: Record = {
+ active: "text-foreground",
+ done: "text-muted-foreground",
+ failed: "text-destructive",
+};
+
function StepMeta({ parts }: { parts: string[] }) {
return (
<>
{parts.map((part, i) => (
- ·
- {/* The comma is for a screen reader only. `select-none` keeps it
- out of a copied row. */}
- ,
+ {i > 0 ? (
+ <>
+ ·
+ {/* The comma is for a screen reader only. `select-none` keeps
+ it out of a copied row. */}
+ ,
+ >
+ ) : null}
{part}
))}
@@ -458,194 +465,90 @@ function StepMeta({ parts }: { parts: string[] }) {
}
/**
- * 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.
+ * The steps, one under the other: a small icon in the left column on a thin
+ * line that runs down to the next step, the label beside it and anything it
+ * has to say underneath.
*/
-function ActivityEntry({
- entry,
- step,
- active,
- detail,
+function TrailList({
+ items,
+ slot,
+ itemSlot,
+ label,
}: {
- entry: CoachActivity;
- step: CoachStep | undefined;
- active: boolean;
- detail: { text?: string; recalled?: string[] } | null;
+ items: TrailItem[];
+ slot: string;
+ itemSlot: string;
+ label: string;
}) {
- 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 (
-
-
-
- {hasDetail ? (
-
+
-
- ) : (
-
- {title}
-
-
- )}
- {hasDetail && open ? (
-
- {detail?.text ? (
-
{detail.text}
- ) : null}
- {detail?.recalled?.length ? (
-
- {detail.recalled.map((fact) => (
- - {fact}
- ))}
-
- ) : null}
-
- ) : null}
-
-
+ >
+
- {activity.length > 0 ? (
-
- {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 (
-
- );
- })}
-
- ) : steps.length > 0 ? (
-
- ) : areaLabels.length > 0 ? (
-
- {areaLabels.map((label) => (
- -
-
- {label}
-
- ))}
-
- ) : null}
- {!active &&
}
- {!active && dataUsed}
-
- );
-}
+// ── The row ────────────────────────────────────────────────────────────────
export interface CoachTurnActivityProps {
/** The live trail, or `metricSource.activity` of a persisted message. */
@@ -654,19 +557,21 @@ export interface CoachTurnActivityProps {
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;
+ /**
+ * True when the settled turn asked the person something and waits for
+ * the choice: the row says so instead of summing up.
+ */
+ awaitingAnswer?: boolean;
/**
* 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. */
+ /** 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 trail. Pass it only when there is something to show.
+ * open list. Pass it only when there is something to show.
*/
dataUsed?: ReactNode;
/** The persisted message, for reading its stored titles and texts. */
@@ -678,8 +583,7 @@ export function CoachTurnActivity({
activity,
steps,
active,
- startedAt,
- endedAt,
+ awaitingAnswer = false,
areas,
method,
dataUsed,
@@ -688,14 +592,19 @@ export function CoachTurnActivity({
}: 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).
+ // Closed in every state; only the person's tap opens it.
const [open, setOpen] = useState(false);
- const now = useNow(active);
+ // A row opened while the turn ran closes when it ends: the answer is
+ // what the person came for. Adjusted while rendering, so the settled
+ // row never paints open for a frame.
+ const [wasActive, setWasActive] = useState(active);
+ if (wasActive !== active) {
+ setWasActive(active);
+ setOpen(openAfter({ open, wasActive, active }));
+ }
// Only a persisted message whose live entries carry no text asks the
- // server for it, and only once the trail is open.
+ // server for it, and only once the row is open.
const needsStoredText =
!active && activity.length > 0 && !activity.some((a) => a.title || a.text);
const trail = useTrailText({
@@ -708,130 +617,152 @@ export function CoachTurnActivity({
activity.length === 0 && steps.length === 0
? legacyAreaLabels(areas, t)
: [];
+ const stepCount = activity.length || steps.length || areaLabels.length;
const hasMethod = !active && !!method?.text;
const hasDataUsed = !active && dataUsed != null && dataUsed !== false;
+ const canOpen = stepCount > 0 || hasMethod || hasDataUsed;
let line: string;
if (active) {
const entry = currentActivity(activity);
const step = entry ? null : currentStep(steps);
line = entry
- ? activityLineLabel(entry)
+ ? inProgressLabel(activityLineLabel(entry))
: step
- ? stepLabel(step, t)
+ ? inProgressLabel(stepLabel(step, t))
: t(COACH_ACTIVITY_KEYS.thinking);
+ } else if (awaitingAnswer) {
+ line = t(COACH_ACTIVITY_KEYS.awaitingAnswer);
} 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));
+ line = t(activityThoughtProcessKey(stepCount, locale), {
+ count: stepCount,
+ });
}
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;
- }
+ // A settled message with nothing to show has no row at all.
+ if (!active && !canOpen) return null;
- const seconds =
- active && startedAt
- ? elapsedSeconds(startedAt, Math.max(now, startedAt))
- : null;
+ const state = active ? "running" : awaitingAnswer ? "awaiting" : "done";
+ const isOpen = open && canOpen;
+
+ const rowContent = (
+ <>
+
-
- {open && (
-
+ {canOpen ? (
+
+ ) : (
+
+ {rowContent}
+
)}
+ {isOpen ? (
+
+ {activity.length > 0 ? (
+
+ ) : steps.length > 0 ? (
+
+ ) : areaLabels.length > 0 ? (
+ ({
+ key: label,
+ icon: Database,
+ label,
+ tone: "done",
+ }))}
+ slot="coach-turn-areas"
+ itemSlot="coach-turn-area"
+ label={t(COACH_STEP_UI_KEYS.listLabel)}
+ />
+ ) : null}
+ {hasMethod ? : null}
+ {hasDataUsed ? dataUsed : null}
+
+ ) : null}
({
...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,
diff --git a/src/components/ui/waiting-text.tsx b/src/components/ui/waiting-text.tsx
new file mode 100644
index 000000000..45fed57dd
--- /dev/null
+++ b/src/components/ui/waiting-text.tsx
@@ -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 (
+ <>
+ {words}
+ {match ? (
+
+ {DOT_DELAYS.map((delay) => (
+
+ .
+
+ ))}
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/lib/ai/coach/__tests__/contract-keys.test.ts b/src/lib/ai/coach/__tests__/contract-keys.test.ts
index 81747a5de..96f9d67b2 100644
--- a/src/lib/ai/coach/__tests__/contract-keys.test.ts
+++ b/src/lib/ai/coach/__tests__/contract-keys.test.ts
@@ -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 {
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) => {
diff --git a/src/lib/ai/coach/__tests__/dialog-keys.test.ts b/src/lib/ai/coach/__tests__/dialog-keys.test.ts
index c445d0bd8..d9d6ceef3 100644
--- a/src/lib/ai/coach/__tests__/dialog-keys.test.ts
+++ b/src/lib/ai/coach/__tests__/dialog-keys.test.ts
@@ -47,7 +47,6 @@ import {
methodAveragesKey,
methodReadingsKey,
methodTotalsKey,
- stepHeaderDoneKey,
stepMetricsKey,
stepReadingsKey,
stepRowsKey,
@@ -112,7 +111,6 @@ function catalogKeys(): Set {
}
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));
diff --git a/src/lib/ai/coach/activity/__tests__/contract.test.ts b/src/lib/ai/coach/activity/__tests__/contract.test.ts
index 12e69aab9..22b06165b 100644
--- a/src/lib/ai/coach/activity/__tests__/contract.test.ts
+++ b/src/lib/ai/coach/activity/__tests__/contract.test.ts
@@ -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",
);
});
});
diff --git a/src/lib/ai/coach/activity/contract.ts b/src/lib/ai/coach/activity/contract.ts
index c7eb983f1..cca931799 100644
--- a/src/lib/ai/coach/activity/contract.ts
+++ b/src/lib/ai/coach/activity/contract.ts
@@ -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);
}
diff --git a/src/lib/ai/coach/dialog-keys.ts b/src/lib/ai/coach/dialog-keys.ts
index 49e74793f..5ed803f7d 100644
--- a/src/lib/ai/coach/dialog-keys.ts
+++ b/src/lib/ai/coach/dialog-keys.ts
@@ -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",
diff --git a/src/lib/demo-refusal.ts b/src/lib/demo-refusal.ts
new file mode 100644
index 000000000..0615488d1
--- /dev/null
+++ b/src/lib/demo-refusal.ts
@@ -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;
diff --git a/src/lib/http/proxy-bypass-routes.ts b/src/lib/http/proxy-bypass-routes.ts
index d5aca39c9..e9781400a 100644
--- a/src/lib/http/proxy-bypass-routes.ts
+++ b/src/lib/http/proxy-bypass-routes.ts
@@ -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;
}
diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts
index 4aeb1fb60..3eb2f1974 100644
--- a/src/lib/openapi/error-codes.ts
+++ b/src/lib/openapi/error-codes.ts
@@ -118,6 +118,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> =
],
"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",
diff --git a/src/proxy.ts b/src/proxy.ts
index d2bd8b033..20ba082d5 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -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 }),
);
}
}