+ );
+}
diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx
index f54000029..e35376530 100644
--- a/src/components/insights/coach-panel/chat-bubble.tsx
+++ b/src/components/insights/coach-panel/chat-bubble.tsx
@@ -19,12 +19,22 @@ import { ProseBlocks } from "@/components/insights/prose-blocks";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
-import { CoachTurnSteps } from "./turn-steps";
-import { CoachResults, countResultsInSection } from "./coach-results";
+import { CoachTurnActivity } from "./turn-activity";
+import { CoachInterimResults } from "./interim-results";
+import {
+ CoachResults,
+ countResultsInSection,
+ liveWithProvenance,
+} from "./coach-results";
import { CoachFollowUpChips } from "./follow-up-chips";
+import { SuggestedReplies, type SuggestedReply } from "./suggested-replies";
+import { CoachMemoryNote } from "./memory-note";
+import { CoachAssumptionLine } from "./assumption-line";
import { AssistantMessageActions, UserMessageActions } from "./message-actions";
import type {
+ CoachActivity,
CoachFollowUp,
+ CoachMemoryNote as CoachMemoryNoteFrame,
CoachProvenanceMetric,
} from "@/lib/ai/coach/types";
@@ -316,6 +326,30 @@ interface ChatBubbleProps {
/** True while another turn is in flight; the chips step aside. */
followUpsDisabled?: boolean;
onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
+ /**
+ * v1.41 — the reply pills the latest answer offers instead of its chips:
+ * a clarifying question's choices, or the two answers to a fact or plan
+ * proposal. Built by the thread; one array per offer.
+ */
+ replies?: ReplyOffer | null;
+ /**
+ * v1.41 — the live trail from the streaming hook. Persisted messages carry
+ * its metadata on `metricSource.activity`; the bubble falls back to that.
+ */
+ activity?: CoachActivity[];
+ /** v1.41 — the refs of the tables that arrived while the turn ran. */
+ interimRefs?: string[];
+ /** v1.41 — when the live turn started and ended on this device. */
+ startedAt?: number | null;
+ endedAt?: number | null;
+ /** v1.41 — the live `memoryNote` frame, with the fact's words. */
+ memoryNote?: CoachMemoryNoteFrame | null;
+}
+
+/** v1.41 — the reply pills one assistant message offers. */
+export interface ReplyOffer {
+ messageId: string;
+ replies: SuggestedReply[];
}
/** The chips one assistant message offers. */
@@ -380,6 +414,12 @@ export function areChatBubblePropsEqual(
sameFollowUpOffer(prev.followUps, next.followUps) &&
prev.followUpsDisabled === next.followUpsDisabled &&
prev.onFollowUp === next.onFollowUp &&
+ prev.replies === next.replies &&
+ prev.activity === next.activity &&
+ prev.interimRefs === next.interimRefs &&
+ prev.startedAt === next.startedAt &&
+ prev.endedAt === next.endedAt &&
+ prev.memoryNote === next.memoryNote &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
);
@@ -407,6 +447,12 @@ function ChatBubbleImpl({
followUps,
followUpsDisabled,
onFollowUp,
+ replies,
+ activity,
+ interimRefs,
+ startedAt,
+ endedAt,
+ memoryNote,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
@@ -543,7 +589,9 @@ function ChatBubbleImpl({
// grounded token is present (provider-agnostic — reads the inline token).
// v1.39.4 — the result tables this answer references and the ones it
// only used; the metadata is enough to place and count them.
- const resultMetas = results?.length ? results : (metricSource?.results ?? []);
+ const resultMetas = results?.length
+ ? liveWithProvenance(results, metricSource?.results ?? [])
+ : (metricSource?.results ?? []);
const shownDomains = new Set(
resultMetas
.filter((meta) => meta.displayed)
@@ -558,6 +606,7 @@ function ChatBubbleImpl({
? selectCoachChartTokens(content, metricSource?.metrics, shownDomains)
: [];
const settled = !inProgress && !errorCode && providerType !== "refusal";
+ const memoryMeta = memoryNote ?? metricSource?.memoryNote ?? null;
// v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure
// from this reply (each rewritten to the `[…]` elision mark). Shown only on a
@@ -596,15 +645,22 @@ function ChatBubbleImpl({
data-slot="coach-answer-column"
className="flex w-full max-w-full min-w-0 flex-col items-start gap-2 sm:max-w-[calc(80%-2.625rem)]"
>
- {/* What the Coach read on this turn, live from the step frames and
- restored from the provenance on reload; an older message lists
- the areas it drew on. Opened, it also says how the answer was
- worked out and holds the tables the answer read. */}
- 0 ? (
- {/* v1.19.1 (C3) — the live turn shows the classic typing animation
- (three pulsing dots) while it is still thinking with no prose
- yet, restoring the writing/typing indicator the maintainer
- prefers over the "Denke nach / Nachgedacht" reasoning disclosure.
- The dots render inside the prose bubble below; the disclosure is
- retired. Persisted bubbles never stream, so history stays settled. */}
- {/* The prose bubble. While the live turn is still thinking with no
- prose the bubble carries the typing animation; once tokens land
- it swaps to the streamed prose without a layout jump. */}
- {(content || safeError || inProgress) && (
+ {/* v1.41 — the tables read so far, one line each, while the turn
+ still runs. They give way to the answer's own charts. */}
+ {inProgress && results && interimRefs && interimRefs.length > 0 ? (
+
+ ) : null}
+ {/* The prose bubble, once there is prose or an error. While the turn
+ runs without prose the status line above says what is happening;
+ the bubble arrives with the first token. */}
+ {(content || safeError) && (
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the
@@ -644,8 +700,6 @@ function ChatBubbleImpl({
keeps the bubble and the error stays a caption. */}
{content ? (
- ) : inProgress ? (
-
) : safeError ? (
{safeError}
) : (
@@ -705,6 +759,23 @@ function ChatBubbleImpl({
section="displayed"
/>
)}
+ {/* v1.41 — what the answer assumed instead of asking, and what the
+ Coach kept from this turn: one quiet line each. */}
+ {settled && (metricSource?.assumptions?.length ?? 0) > 0 ? (
+
+ ) : null}
+ {settled && memoryMeta ? (
+
+ ) : null}
{/* v1.18.6 — a "no provider configured anywhere" turn is a
setup gap, not a transient failure: surface a direct link to
Settings → AI so the Coach guides the user into BYOK / local
@@ -753,10 +824,20 @@ function ChatBubbleImpl({
model={usage?.model ?? model}
/>
)}
- {/* The follow-up chips of the latest answer, in its own column. */}
- {followUps && onFollowUp ? (
+ {/* The replies the latest answer offers, in its own column: a
+ question's choices or a proposal's two answers when it has them,
+ else its follow-up chips. One pattern for all of them. */}
+ {replies && replies.replies.length > 0 ? (
+
+ ) : followUps && onFollowUp ? (
chip.kind !== "change_assumption",
+ )}
messageId={followUps.messageId}
disabled={!!followUpsDisabled}
onSelect={onFollowUp}
diff --git a/src/components/insights/coach-panel/clarification-card.tsx b/src/components/insights/coach-panel/clarification-card.tsx
deleted file mode 100644
index 2bc5a374c..000000000
--- a/src/components/insights/coach-panel/clarification-card.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-"use client";
-
-/**
- * v1.39.4 — the choices for a clarifying question, above the composer.
- * The question itself is the assistant reply in the thread, so the card
- * does not repeat it: a short heading, the choices, and a meta line saying a
- * typed answer works too. A tap on a choice sends its label with
- * `clarification: { messageId, choiceId }`; a typed answer goes out with
- * `clarification: { messageId }`.
- *
- * Announced politely when it appears, through the live region the
- * conversation keeps mounted beside it (a region that arrives with its
- * content is never heard), and never takes focus on its own: the person may
- * already be typing. A tapped choice hands focus to the composer.
- */
-import { MessageCircleQuestionMark } from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-import { Card } from "@/components/ui/card";
-import { useTranslations } from "@/lib/i18n/context";
-import { COACH_CLARIFY_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
-import type {
- CoachClarification,
- CoachClarificationChoice,
-} from "@/lib/ai/coach/types";
-
-import { focusCoachComposer } from "./composer-focus";
-
-export interface CoachClarificationCardProps {
- clarification: CoachClarification;
- /** The assistant message that asked. */
- messageId: string;
- /** True while a turn is in flight. */
- disabled: boolean;
- onChoose: (choice: CoachClarificationChoice) => void;
-}
-
-export function CoachClarificationCard({
- clarification,
- messageId,
- disabled,
- onChoose,
-}: CoachClarificationCardProps) {
- const { t } = useTranslations();
- // The catalog string in the reader's locale when the bundle has it, the
- // server's rendering when it does not.
- const choiceLabel = (choice: CoachClarificationChoice) => {
- const local = t(choice.labelKey);
- return local === choice.labelKey ? choice.label : local;
- };
- const { choices } = clarification;
- const hint =
- choices.length === 0
- ? t(COACH_CLARIFY_UI_KEYS.contextHint)
- : clarification.freeText
- ? t(COACH_CLARIFY_UI_KEYS.freeTextHint)
- : null;
-
- return (
-
-
-
-
- {t(COACH_CLARIFY_UI_KEYS.cardLabel)}
-
-
- {choices.length > 0 ? (
-
- {choices.map((choice) => (
-
- ))}
-
- ) : null}
- {hint ? (
-
{hint}
- ) : null}
-
- );
-}
diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx
index 131f88d11..a81c1e6ff 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -14,14 +14,9 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
-import { COACH_CLARIFY_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import { randomId } from "@/lib/random-id";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
-import type {
- CoachClarificationChoice,
- CoachFollowUp,
- CoachScope,
-} from "@/lib/ai/coach/types";
+import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
@@ -51,9 +46,9 @@ import { HistoryRail } from "./history-rail";
import {
MessageThread,
latestClarification,
+ type CoachReplyIntent,
type InterleavedThreadItem,
} from "./message-thread";
-import { CoachClarificationCard } from "./clarification-card";
import { CoachSettingsOverlay } from "./coach-settings-overlay";
import { CoachTopBarTrail } from "./coach-top-bar-trail";
import { ConversationsPanel } from "./conversations-panel";
@@ -662,18 +657,50 @@ export function CoachConversation({
});
}
- // v1.39.4 — a choice on the clarification card.
- function handleClarificationChoice(choice: CoachClarificationChoice) {
- if (send.isStreaming || !openClarification) return;
- if (currentConversationId === null) return;
- void send.send({
+ // v1.41 — a reply pill under the latest answer: a clarifying question's
+ // choice, or an answer to a fact or plan proposal. Its label goes out as
+ // the person's own message; the request says which offer it answers, and
+ // the server resolves the rest from what it stored on that message.
+ function handleReply(intent: CoachReplyIntent) {
+ if (send.isStreaming || fenced || currentConversationId === null) return;
+ const sent = send.send({
conversationId: currentConversationId,
- message: choice.label,
- clarification: {
- messageId: openClarification.messageId,
- choiceId: choice.id,
- },
+ message: intent.label,
+ ...(intent.kind === "clarification"
+ ? {
+ clarification: {
+ messageId: intent.messageId,
+ choiceId: intent.choice.id,
+ },
+ }
+ : intent.kind === "memory"
+ ? {
+ memoryDecision: {
+ messageId: intent.messageId,
+ proposalId: intent.proposalId,
+ accept: intent.accept,
+ },
+ }
+ : {
+ planDecision: {
+ messageId: intent.messageId,
+ planId: intent.planId,
+ accept: intent.accept,
+ },
+ }),
});
+ // A decision changes what the Coach remembers or which plans run; the
+ // lists elsewhere read it fresh once the turn is through.
+ if (intent.kind !== "clarification") {
+ void sent.then(() =>
+ queryClient.invalidateQueries({
+ queryKey:
+ intent.kind === "memory"
+ ? queryKeys.coachFacts()
+ : queryKeys.coachPlansAll(),
+ }),
+ );
+ }
}
async function handleSubmit(value: string) {
@@ -908,25 +935,6 @@ export function CoachConversation({
{t("insights.coach.attach.indexingHint")}
) : null}
- {/* v1.39.4 — mounted empty before any question, so the question is
- announced when it arrives (a live region that mounts with its
- content is not heard). Visually hidden, so it takes no room in the
- stack; the card below carries the choices. */}
-
}
composer={composerStack}
diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx
index 49fc18139..a6813f540 100644
--- a/src/components/insights/coach-panel/coach-input.tsx
+++ b/src/components/insights/coach-panel/coach-input.tsx
@@ -372,7 +372,7 @@ export function CoachInput({
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("insights.coach.composerPlaceholder")}
// Read-only rather than disabled while a reply streams: a disabled
- // field drops focus, and a follow-up chip or a clarification choice
+ // field drops focus, and a tapped reply pill
// hands focus here right as the turn starts.
readOnly={disabled}
aria-disabled={disabled || undefined}
diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx
index b0a855961..b1ad56296 100644
--- a/src/components/insights/coach-panel/coach-results.tsx
+++ b/src/components/insights/coach-panel/coach-results.tsx
@@ -115,6 +115,32 @@ function useReusedLine(result: CoachResultMeta): string | null {
: t(COACH_RESULT_UI_KEYS.reusedFromUndated);
}
+/** `displayed` per ref, from a message's result metadata. */
+function displayedByRef(
+ metas: ReadonlyArray>,
+): Map {
+ return new Map(metas.map((meta) => [meta.ref, meta.displayed]));
+}
+
+/**
+ * The live tables with `displayed` taken from the provenance where it names
+ * them: an interim table is sent before the answer decides.
+ */
+export function liveWithProvenance<
+ T extends Pick,
+>(
+ live: readonly T[],
+ metas: ReadonlyArray>,
+): T[] {
+ const displayed = displayedByRef(metas);
+ return live.map((table) => {
+ const flag = displayed.get(table.ref);
+ return flag === undefined || flag === table.displayed
+ ? table
+ : { ...table, displayed: flag };
+ });
+}
+
/** How many tables sit in a section, from the metadata alone. */
export function countResultsInSection(
metas: ReadonlyArray>,
@@ -147,8 +173,11 @@ function sectionItems(args: {
const inSection = (displayed: boolean) =>
args.section === "displayed" ? displayed : !displayed;
if (args.live && args.live.length > 0) {
+ // v1.41 — a table that arrived while the turn still ran learns whether
+ // the answer points at it from the provenance, which comes later.
+ const displayed = displayedByRef(args.metas);
return args.live
- .filter((table) => inSection(table.displayed))
+ .filter((table) => inSection(displayed.get(table.ref) ?? table.displayed))
.map((table) => ({ ref: table.ref, table }));
}
if (!args.fetched) return [];
@@ -174,7 +203,7 @@ export function CoachResults({
const { t } = useTranslations();
const hasLive = (live?.length ?? 0) > 0;
const expected = hasLive
- ? countResultsInSection(live ?? [], section)
+ ? countResultsInSection(liveWithProvenance(live ?? [], metas), section)
: countResultsInSection(metas, section);
const { ref, results, isError, refetch } = useCoachMessageResults({
conversationId,
@@ -236,7 +265,7 @@ export function CoachResults({
type ResultView = "chart" | "table";
-function CoachResultView({
+export function CoachResultView({
result,
chartFirst,
}: {
diff --git a/src/components/insights/coach-panel/composer-focus.ts b/src/components/insights/coach-panel/composer-focus.ts
index afcecf39d..afb0e0211 100644
--- a/src/components/insights/coach-panel/composer-focus.ts
+++ b/src/components/insights/coach-panel/composer-focus.ts
@@ -1,9 +1,9 @@
/**
* v1.39.4 — the Coach composer's id, and handing focus to it.
*
- * A follow-up chip or a clarification choice sends a message and then goes
- * away (chips hide while the turn runs, choices disable), which would drop
- * keyboard and screen-reader focus to the page. Focus moves to the composer
+ * A reply pill (a follow-up, a clarifying choice, a memory or plan
+ * decision) sends a message and then goes away (pills hide while the turn
+ * runs), which would drop keyboard and screen-reader focus to the page. Focus moves to the composer
* instead: that is where the person continues. The composer stays focusable
* while a reply streams (it is read-only then, not disabled).
*/
diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx
index 64e5561c6..d525c13b2 100644
--- a/src/components/insights/coach-panel/follow-up-chips.tsx
+++ b/src/components/insights/coach-panel/follow-up-chips.tsx
@@ -2,24 +2,21 @@
/**
* v1.39.4 — up to three follow-up chips under the latest assistant reply.
- * They sit inside the answer's own column, on the bubble's left edge and at
- * the answer's text size, so they read as part of the reply rather than as
- * thread chrome. A tap sends the chip's label as the message with
- * `followUp: { messageId, id }`; the server resolves what the chip asks for.
+ * v1.41 — a thin adapter onto `SuggestedReplies`, the one pattern every
+ * tappable reply in the thread uses (follow-ups, clarifying choices, memory
+ * and plan decisions).
*
+ * A tap sends the chip's label as the message with
+ * `followUp: { messageId, id }`; the server resolves what the chip asks for.
* The label is the server's rendering in the request locale. It is not
* re-rendered from `labelKey` here: a related-metric chip names its metric
- * in the label, and the key alone does not carry it. Hidden while a turn is
- * in flight, so a chip can never be tapped against a reply that is about to
- * stop being the latest. The tapped chip goes away with the rest, so focus
- * moves to the composer.
+ * in the label, and the key alone does not carry it.
*/
-import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import type { CoachFollowUp } from "@/lib/ai/coach/types";
-import { focusCoachComposer } from "./composer-focus";
+import { SuggestedReplies } from "./suggested-replies";
/** At most this many chips render, whatever the payload carries. */
const MAX_CHIPS = 3;
@@ -40,36 +37,21 @@ export function CoachFollowUpChips({
onSelect,
}: CoachFollowUpChipsProps) {
const { t } = useTranslations();
- const chips = followUps.slice(0, MAX_CHIPS);
- if (disabled || chips.length === 0) return null;
- return (
-
- {chips.map((chip) => (
-
- ))}
-
- );
+ return SuggestedReplies({
+ slot: "coach-follow-up-chips",
+ label: t(COACH_FOLLOW_UP_UI_KEYS.groupLabel),
+ messageId,
+ disabled,
+ max: MAX_CHIPS,
+ replies: followUps.map((chip) => ({
+ id: chip.id,
+ label: chip.label,
+ kind: "followUp",
+ data: {
+ "data-follow-up-id": chip.id,
+ "data-follow-up-kind": chip.kind,
+ },
+ onSelect: () => onSelect(chip, messageId),
+ })),
+ });
}
diff --git a/src/components/insights/coach-panel/interim-results.tsx b/src/components/insights/coach-panel/interim-results.tsx
new file mode 100644
index 000000000..49064737f
--- /dev/null
+++ b/src/components/insights/coach-panel/interim-results.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+/**
+ * v1.41 — the tables a turn has read so far, while it still runs.
+ *
+ * Under the status line, one quiet line per table as soon as the call that
+ * read it settles: its title, window and how many readings it holds, with a
+ * small sparkline in the first chart colour and no axes. At most three. A
+ * tap opens the full chart (or table) right there; nothing opens by itself.
+ * Once the answer settles the previews go: the tables the answer points at
+ * become its charts, the rest sit under "Data used" in the closed trail.
+ */
+import { useId, useState } from "react";
+import { ChevronRight } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+import type { Locale } from "@/lib/i18n/config";
+import {
+ coachWindowLabelKey,
+ interimLabelKey,
+} from "@/lib/ai/coach/dialog-keys";
+import type { CoachResultTable } from "@/lib/ai/coach/types";
+
+import { CoachResultView } from "./coach-results";
+
+/** At most this many previews show, whatever the turn has read. */
+export const MAX_INTERIM_PREVIEWS = 3;
+
+type Translate = (
+ key: string,
+ params?: Record,
+) => string;
+
+/** 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 (
+
+ );
+}
diff --git a/src/components/insights/coach-panel/memory-note.tsx b/src/components/insights/coach-panel/memory-note.tsx
new file mode 100644
index 000000000..3338492de
--- /dev/null
+++ b/src/components/insights/coach-panel/memory-note.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+/**
+ * v1.41 — the one quiet line under an answer that says the Coach kept
+ * something: "Remembered: you want to reach 75 kg by December. Undo".
+ *
+ * Meta text at the meta size, with "Undo" as a text button of the same size.
+ * Undo forgets the fact (a soft delete) and the line goes. A fact the Coach
+ * only proposes (a health fact, which is never kept without a tap) has no
+ * line: the answer ends with the question and the reply pills carry
+ * "Yes, remember it" / "No".
+ *
+ * The live turn has the fact's words from the `memoryNote` frame. A
+ * persisted message carries only the fact's id, so its words come from the
+ * memory list; a fact that has since been forgotten or edited away leaves
+ * no line behind.
+ */
+import { useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+import { COACH_MEMORY_KEYS } from "@/lib/ai/coach/memory/shared";
+import type { CoachMemoryNoteMeta } from "@/lib/ai/coach/types";
+
+import { useCoachFacts, useForgetCoachFact } from "./use-coach-facts";
+
+export interface CoachMemoryNoteLineProps {
+ fact: string;
+ pending: boolean;
+ onUndo: () => void;
+}
+
+/** The line itself, without data: what the tests render. */
+export function CoachMemoryNoteLine({
+ fact,
+ pending,
+ onUndo,
+}: CoachMemoryNoteLineProps) {
+ const { t } = useTranslations();
+ return (
+
+ );
+}
+
+export function CoachMemoryNote({
+ note,
+ liveFact,
+}: {
+ /** `metricSource.memoryNote`, or the live frame's metadata. */
+ note: CoachMemoryNoteMeta;
+ /** The fact's words from the live frame, when the turn just streamed. */
+ liveFact?: string | null;
+}) {
+ const { t } = useTranslations();
+ const [undone, setUndone] = useState(false);
+ const saved = !note.proposal && !!note.factId;
+ // The list is read only for a persisted note without its words.
+ const facts = useCoachFacts({ enabled: saved && !liveFact && !undone });
+ const forget = useForgetCoachFact({
+ onSuccess: () => setUndone(true),
+ onError: () => toast.error(t("settings.ai.coachMemory.forgotError")),
+ });
+ if (!saved || undone || !note.factId) return null;
+ const fact =
+ liveFact ?? facts.data?.find((f) => f.id === note.factId)?.text ?? null;
+ if (!fact) return null;
+ return (
+ {
+ if (note.factId && !forget.isPending) forget.mutate(note.factId);
+ }}
+ />
+ );
+}
diff --git a/src/components/insights/coach-panel/message-actions.tsx b/src/components/insights/coach-panel/message-actions.tsx
index 5748241af..eba8cb999 100644
--- a/src/components/insights/coach-panel/message-actions.tsx
+++ b/src/components/insights/coach-panel/message-actions.tsx
@@ -41,7 +41,10 @@ import type { Formatters } from "@/lib/format-locale";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
-import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
+import {
+ COACH_MEMORY_KEYS,
+ REMEMBER_FACT_MAX_CHARS,
+} from "@/lib/ai/coach/memory/shared";
import {
COACH_ICON_BUTTON,
@@ -271,37 +274,38 @@ function MessageTime({ iso }: { iso: string }) {
}
/**
- * Stores the user's message in the self-context (Settings → AI) on one tap,
- * so it rides every future system prompt. The server picks the field from
- * the text itself. The icon confirms in place and a toast says where it
- * went; the button never unmounts, so keyboard focus stays on it.
+ * Keeps the user's message in the Coach's memory on one tap
+ * (`POST /api/insights/coach/facts` with the message id; the server reads the
+ * text and files it). It then shows in Settings → Coach with "from you", and
+ * reaches every later turn. v1.41 — it used to write the self-context; the
+ * Coach's memory is now the one place for what the Coach knows. The icon
+ * confirms in place and a toast says so; the button never unmounts, so
+ * keyboard focus stays on it.
*/
-function RememberMessageButton({ content }: { content: string }) {
+function RememberMessageButton({ messageId }: { messageId: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
const remember = useMutation({
mutationFn: async () =>
- apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
- answer: content,
+ apiPost<{ created: boolean }>("/api/insights/coach/facts", {
+ messageId,
}),
onSuccess: (data) => {
- if (data.adopted) {
- void queryClient.invalidateQueries({
- queryKey: queryKeys.coachAboutMe(),
- });
- }
- setSettled(data.adopted ? "adopted" : "duplicate");
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.coachFacts(),
+ });
+ setSettled(data.created ? "adopted" : "duplicate");
// A new entry is a write (success); an existing one is an honest
// non-event (neutral), the same mapping `toastWrittenOutcome` uses.
// Called directly: importing the outcome module here regroups the
// shared chunks and costs ~16 KB gz across the client bundle.
- (data.adopted ? toast.success : toast.info)(
+ (data.created ? toast.success : toast.info)(
t(
- data.adopted
- ? "insights.coach.rememberMessage.done"
- : "insights.coach.rememberMessage.duplicate",
+ data.created
+ ? COACH_MEMORY_KEYS.confirmed
+ : "insights.coach.rememberMessage.alreadyKept",
),
);
},
@@ -400,8 +404,8 @@ export function UserMessageActions({
messageId,
createdAt,
}: UserMessageActionsProps) {
- // Only a persisted message that fits the self-context field can be kept.
- const canRemember = !!messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS;
+ // Only a persisted message short enough to be one fact can be kept.
+ const canRemember = !!messageId && content.length <= REMEMBER_FACT_MAX_CHARS;
return (
-
- {t("coach.plans.proposalTitle")}
-
- {/* The plan prose is the user's own committed intention — content,
- never meta, so it reads in foreground per the text-colour rules. */}
-
- {t("coach.plans.ifThen", {
- cue: plan.ifCue ?? "",
- action: plan.thenAction ?? "",
- })}
-
- {plan.target ? (
-
- {t("coach.plans.targetPrefix", { target: plan.target })}
-
- ) : null}
-
- {plan.metric}
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/components/insights/coach-panel/suggested-replies.tsx b/src/components/insights/coach-panel/suggested-replies.tsx
new file mode 100644
index 000000000..ada286355
--- /dev/null
+++ b/src/components/insights/coach-panel/suggested-replies.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+/**
+ * v1.41 — the one quiet way the Coach offers a reply: pills under the latest
+ * answer, in the answer's own column, at the answer's text size.
+ *
+ * Every reply the person can give by tap uses it: the follow-ups after an
+ * answer, the choices of a clarifying question (the question itself is the
+ * answer above, its assumed choice the first pill), "Yes, remember it" /
+ * "No" under a fact proposal and "Take on this plan" / "Not now" under a
+ * plan proposal. A tap sends the pill's label as the person's own message,
+ * so it shows in the thread like anything they typed; the request carries
+ * which offer it answered, and the server resolves it from what it stored.
+ *
+ * Outline pills in the foreground at reduced contrast, never an accent
+ * colour, never a card. Hidden while a turn runs, so a pill can never answer
+ * a reply that is about to stop being the latest. The tapped pill goes away
+ * with the rest, so focus moves to the composer.
+ */
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+import { COACH_SUGGESTED_REPLIES_KEYS } from "@/lib/ai/coach/dialog-keys";
+
+import { focusCoachComposer } from "./composer-focus";
+
+/** At most this many pills render, whatever the offer carries. */
+export const MAX_SUGGESTED_REPLIES = 4;
+
+export interface SuggestedReply {
+ /** Stable within the offer; the React key. */
+ id: string;
+ /** What the pill says, and the message a tap sends. */
+ label: string;
+ /** What the offer is (`followUp`, `clarification`, `memory`, `plan`). */
+ kind: string;
+ /** Extra `data-*` attributes, for tests and the e2e specs. */
+ data?: Record<`data-${string}`, string>;
+ onSelect: () => void;
+}
+
+export interface SuggestedRepliesProps {
+ replies: SuggestedReply[];
+ /** The assistant message that offered them. */
+ messageId: string;
+ /** True while a turn is in flight. */
+ disabled: boolean;
+ /** At most this many; defaults to {@link MAX_SUGGESTED_REPLIES}. */
+ max?: number;
+ /** The group's accessible name; defaults to "Suggested replies". */
+ label?: string;
+ /** The group's `data-slot`. */
+ slot?: string;
+}
+
+export function SuggestedReplies({
+ replies,
+ messageId,
+ disabled,
+ max = MAX_SUGGESTED_REPLIES,
+ label,
+ slot = "coach-suggested-replies",
+}: SuggestedRepliesProps) {
+ const { t } = useTranslations();
+ const shown = replies.slice(0, max);
+ if (disabled || shown.length === 0) return null;
+ return (
+
+ {shown.map((reply) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/insights/coach-panel/turn-activity.tsx b/src/components/insights/coach-panel/turn-activity.tsx
new file mode 100644
index 000000000..157906a69
--- /dev/null
+++ b/src/components/insights/coach-panel/turn-activity.tsx
@@ -0,0 +1,842 @@
+"use client";
+
+/**
+ * v1.41 — the live trail of a Coach turn, one quiet line at the top of the
+ * answer.
+ *
+ * While the turn runs the line says what the Coach is doing right now, from
+ * the `activity` frames: "Thinking…", "Fetching blood pressure, last 90
+ * days…", "Summarising 214 readings…", "Writing the answer…", with the
+ * seconds so far on the right. A reasoning title replaces the thinking label
+ * when one arrives. Once the turn ends the same line settles into one calm
+ * summary: "Thought it through, 3 lookups, 12 s".
+ *
+ * Nothing opens by itself. The trail starts closed in every state of a turn
+ * (running, asking, stopped, failed, settled, reloaded) and only a tap on the
+ * line opens it; it then stays open until the person closes it, and keeps
+ * growing while the turn runs. Open, it lists one entry per phase with its
+ * status, the reasoning text of a round behind its own disclosure (closed
+ * too), the method line and the tables the answer used without pointing at
+ * them.
+ *
+ * Labels are catalog text the server rendered; titles and texts are screened
+ * model text the owner alone receives. A persisted message carries only the
+ * metadata, so its titles and texts are read from `…/trail` the first time
+ * the person opens the trail. A message saved before v1.41 has no activity
+ * and falls back to the step list (`metricSource.steps`), and one saved
+ * before steps existed to the areas it drew on.
+ *
+ * Screen readers hear the line through a polite status region, throttled to
+ * one announcement per `ANNOUNCE_THROTTLE_MS` (the latest wins), and the
+ * summary once when the turn ends. A reloaded conversation announces nothing.
+ */
+import { useEffect, useId, useRef, useState, type ReactNode } from "react";
+import { useQuery } from "@tanstack/react-query";
+import {
+ Check,
+ ChevronRight,
+ Loader2,
+ Minus,
+ TriangleAlert,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
+import { useTranslations } from "@/lib/i18n/context";
+import type { Locale } from "@/lib/i18n/config";
+import {
+ COACH_STEP_REASON_KEYS,
+ COACH_STEP_UI_KEYS,
+ coachDomainLabelKey,
+ coachGranularityLabelKey,
+ coachPeriodLabelKey,
+ coachWindowLabelKey,
+} from "@/lib/ai/coach/dialog-keys";
+import {
+ COACH_ACTIVITY_KEYS,
+ activitySummaryKey,
+} from "@/lib/ai/coach/activity/contract";
+import type {
+ CoachActivity,
+ CoachMethod,
+ CoachProvenanceMetric,
+ CoachStep,
+ CoachTrail,
+} from "@/lib/ai/coach/types";
+
+import { CoachMethodLine } from "./method-line";
+
+/** The shortest gap between two screen-reader announcements. */
+export const ANNOUNCE_THROTTLE_MS = 1500;
+
+/** "12s": the seconds on the line and beside a finished round. */
+const ELAPSED_KEY = "insights.coach.thinkingElapsed";
+
+type Translate = (
+ key: string,
+ params?: Record,
+) => string;
+type TranslateCount = (
+ baseKey: string,
+ count: number,
+ params?: Record,
+) => string;
+
+/** `t(key)`, or `fallback` when the bundle does not carry the key. */
+function tOr(
+ t: Translate,
+ key: string,
+ fallback: string,
+ params?: Record,
+) {
+ const value = t(key, params);
+ return value === key ? fallback : value;
+}
+
+// ── Steps (messages saved before v1.41, and the fetch rows) ────────────────
+
+/**
+ * The step's label in the reader's language: the catalog key rendered with
+ * the domain and window names, or the server's label when the key is not
+ * in the bundle.
+ */
+export function stepLabel(step: CoachStep, t: Translate): string {
+ return tOr(t, step.labelKey, step.label, {
+ ...(step.domain ? { domain: t(coachDomainLabelKey(step.domain)) } : {}),
+ ...(step.window ? { window: t(coachWindowLabelKey(step.window)) } : {}),
+ });
+}
+
+/**
+ * One row's text: the domain as the title, then the window, period,
+ * granularity, and either how much was read or why nothing was.
+ */
+export function describeStep(
+ step: CoachStep,
+ t: Translate,
+ tCount: TranslateCount,
+): { title: string; meta: string[] } {
+ const title = step.domain
+ ? t(coachDomainLabelKey(step.domain))
+ : stepLabel(step, t);
+ const meta: string[] = [];
+ if (step.window) meta.push(t(coachWindowLabelKey(step.window)));
+ if (step.period && step.period !== "current") {
+ meta.push(t(coachPeriodLabelKey(step.period)));
+ }
+ if (step.granularity)
+ meta.push(t(coachGranularityLabelKey(step.granularity)));
+ if (step.status === "done" && step.count !== undefined) {
+ const base =
+ step.tool === "snapshot"
+ ? "coach.step.metrics"
+ : step.tool === "get_labs"
+ ? "coach.step.rows"
+ : "coach.step.readings";
+ meta.push(tCount(base, step.count));
+ } else if (step.status !== "running" && step.reason) {
+ meta.push(t(COACH_STEP_REASON_KEYS[step.reason]));
+ }
+ return { title, meta };
+}
+
+/**
+ * How many sources the turn looked at: one per domain (a step without one,
+ * the snapshot, counts by its tool). A second read of the same domain, for
+ * the period before or a retry, is not a new source.
+ */
+export function countSources(steps: readonly CoachStep[]): number {
+ return new Set(steps.map((step) => step.domain ?? step.tool)).size;
+}
+
+/**
+ * The step a legacy running line names: the latest one still running, else
+ * the latest one overall (between two rounds nothing is running).
+ */
+export function currentStep(steps: CoachStep[]): CoachStep | null {
+ for (let i = steps.length - 1; i >= 0; i -= 1) {
+ if (steps[i].status === "running") return steps[i];
+ }
+ return steps.at(-1) ?? null;
+}
+
+/**
+ * The areas an older message drew on, in the order its provenance lists
+ * them, by their display names. A token without a name in the bundle is
+ * dropped, and so is `general`, which names no area of the record.
+ */
+export function legacyAreaLabels(
+ areas: readonly CoachProvenanceMetric[] | undefined,
+ t: Translate,
+): string[] {
+ const out: string[] = [];
+ for (const area of areas ?? []) {
+ if (area === "general") continue;
+ const key = `insights.coach.metric.${area}`;
+ const label = t(key);
+ if (label === key || out.includes(label)) continue;
+ out.push(label);
+ }
+ return out;
+}
+
+// ── Activity ───────────────────────────────────────────────────────────────
+
+/**
+ * The entry the running line names: the latest one still running, else the
+ * latest one overall. Null when the turn has sent no activity yet.
+ */
+export function currentActivity(
+ activity: readonly CoachActivity[],
+): CoachActivity | null {
+ for (let i = activity.length - 1; i >= 0; i -= 1) {
+ if (activity[i].status === "running") return activity[i];
+ }
+ return activity.at(-1) ?? null;
+}
+
+/**
+ * What an entry is called on the line: a screened reasoning title for a
+ * thinking round once one has arrived, else the server's catalog label.
+ */
+export function activityLineLabel(entry: CoachActivity): string {
+ if (
+ (entry.phase === "thinking" || entry.phase === "checkpoint") &&
+ entry.title
+ ) {
+ return entry.title;
+ }
+ // A finished entry no longer trails off: "Writing the answer", not
+ // "Writing the answer…".
+ return entry.status === "running"
+ ? entry.label
+ : entry.label.replace(/(?:…|\.\.\.)\s*$/u, "");
+}
+
+/** The lookups a turn made: one per fetch. */
+export function countLookups(activity: readonly CoachActivity[]): number {
+ return activity.filter((entry) => entry.phase === "fetch").length;
+}
+
+/**
+ * The seconds a turn took, from its trail: the phases of a round run one
+ * after the other, except its fetches, which run side by side (the longest
+ * counts). Null when the trail carries no durations.
+ */
+export function activitySeconds(
+ activity: readonly CoachActivity[],
+): number | null {
+ const fetchByRound = new Map();
+ let sequential = 0;
+ let any = false;
+ for (const entry of activity) {
+ if (entry.durationMs === undefined) continue;
+ any = true;
+ if (entry.phase === "fetch") {
+ fetchByRound.set(
+ entry.round,
+ Math.max(fetchByRound.get(entry.round) ?? 0, entry.durationMs),
+ );
+ } else {
+ sequential += entry.durationMs;
+ }
+ }
+ if (!any) return null;
+ let total = sequential;
+ for (const ms of fetchByRound.values()) total += ms;
+ return Math.max(1, Math.round(total / 1000));
+}
+
+/** Whole seconds between two instants, at least one. */
+export function elapsedSeconds(from: number, to: number): number {
+ return Math.max(1, Math.round((to - from) / 1000));
+}
+
+/**
+ * The settled line: "Thought it through, N lookups, S s". The seconds come
+ * from the trail, else from the clock of the turn that just streamed. Null
+ * when neither is known (a message saved before v1.41).
+ */
+export function activitySummary(args: {
+ activity: readonly CoachActivity[];
+ startedAt?: number | null;
+ endedAt?: number | null;
+ t: Translate;
+ locale: Locale;
+}): string | null {
+ const { activity, startedAt, endedAt, t, locale } = args;
+ if (activity.length === 0) return null;
+ const seconds =
+ activitySeconds(activity) ??
+ (startedAt && endedAt ? elapsedSeconds(startedAt, endedAt) : null);
+ if (seconds === null) return null;
+ const lookups = countLookups(activity);
+ return t(activitySummaryKey(lookups, locale), { lookups, seconds });
+}
+
+/** A tick per second while `running`; the current time otherwise. */
+function useNow(running: boolean): number {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ if (!running) return;
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, [running]);
+ return now;
+}
+
+/**
+ * The status region's text: the running line as it changes, throttled, and
+ * the summary once when the turn ends. Nothing is announced for a trail that
+ * was already settled when it mounted.
+ */
+function useLineAnnouncement(text: string, active: boolean): string {
+ const [mountedActive] = useState(active);
+ const lastAt = useRef(0);
+ const timer = useRef | null>(null);
+ const said = useRef(null);
+ const [announcement, setAnnouncement] = useState("");
+
+ useEffect(() => {
+ // A reloaded, settled trail stays quiet.
+ if (!mountedActive) return;
+ if (!text || text === said.current) return;
+ said.current = text;
+ const wait = Math.max(
+ 0,
+ lastAt.current + ANNOUNCE_THROTTLE_MS - Date.now(),
+ );
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = setTimeout(() => {
+ timer.current = null;
+ lastAt.current = Date.now();
+ setAnnouncement(text);
+ }, wait);
+ }, [text, mountedActive]);
+
+ useEffect(
+ () => () => {
+ if (timer.current) clearTimeout(timer.current);
+ },
+ [],
+ );
+
+ return announcement;
+}
+
+/** The owner-only titles and texts of a persisted message's trail. */
+function useTrailText(args: {
+ conversationId: string | null | undefined;
+ messageId: string | null | undefined;
+ enabled: boolean;
+}): CoachTrail | null {
+ const { conversationId, messageId, enabled } = args;
+ const query = useQuery({
+ queryKey: queryKeys.coachMessageTrail(
+ conversationId ?? "",
+ messageId ?? "",
+ ),
+ queryFn: async (): Promise => {
+ const data = await apiGet<
+ { trail?: CoachTrail | null } | CoachTrail | null
+ >(
+ `/api/insights/chat/${encodeURIComponent(conversationId ?? "")}/messages/${encodeURIComponent(messageId ?? "")}/trail`,
+ );
+ if (!data) return null;
+ if ("entries" in data) return data;
+ return data.trail ?? null;
+ },
+ enabled: enabled && !!conversationId && !!messageId,
+ // The trail is the stored snapshot of a finished turn; it never changes.
+ staleTime: Infinity,
+ retry: false,
+ });
+ return query.data ?? null;
+}
+
+// ── Rendering ──────────────────────────────────────────────────────────────
+
+type EntryStatus = CoachActivity["status"];
+
+function StatusIcon({
+ status,
+ active,
+}: {
+ status: EntryStatus;
+ active: boolean;
+}) {
+ const className = "mt-0.5 size-3 shrink-0";
+ if (status === "running" && active) {
+ return (
+
+ );
+ }
+ if (status === "done" || status === "running") {
+ return (
+
+ );
+ }
+ if (status === "failed") {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+/** The rows of a legacy step list: one per step, in the order they started. */
+export function CoachTurnStepList({
+ id,
+ steps,
+ active,
+}: {
+ id?: string;
+ steps: CoachStep[];
+ active: boolean;
+}) {
+ const { t, tCount } = useTranslations();
+ return (
+
+ {steps.map((step) => {
+ const { title, meta } = describeStep(step, t, tCount);
+ return (
+
+
+
+ {title}
+
+
+
+ );
+ })}
+
+ );
+}
+
+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. */}
+ ,
+ {part}
+
+ ))}
+ >
+ );
+}
+
+/**
+ * One trail entry. A fetch backed by a step reads like a step row (domain,
+ * window, count); everything else shows its label and, once finished, how
+ * long it took. A thinking round with reasoning text, and the memory entry
+ * with the things it recalled, open their detail on their own tap.
+ */
+function ActivityEntry({
+ entry,
+ step,
+ active,
+ detail,
+}: {
+ entry: CoachActivity;
+ step: CoachStep | undefined;
+ active: boolean;
+ detail: { text?: string; recalled?: string[] } | null;
+}) {
+ const { t, tCount } = useTranslations();
+ const detailId = useId();
+ const [open, setOpen] = useState(false);
+ let title: string;
+ let meta: string[] = [];
+ if (entry.phase === "fetch" && step) {
+ ({ title, meta } = describeStep(step, t, tCount));
+ } else {
+ title = activityLineLabel(entry);
+ if (entry.status !== "running" && entry.durationMs !== undefined) {
+ meta = [
+ t(ELAPSED_KEY, {
+ seconds: Math.max(1, Math.round(entry.durationMs / 1000)),
+ }),
+ ];
+ }
+ }
+ const hasDetail =
+ !!detail?.text ||
+ (detail?.recalled !== undefined && detail.recalled.length > 0);
+ return (
+
+ );
+}
+
+export interface CoachTurnActivityProps {
+ /** The live trail, or `metricSource.activity` of a persisted message. */
+ activity: CoachActivity[];
+ /** The steps the fetch entries point at; a legacy message's whole list. */
+ steps: CoachStep[];
+ /** True while the turn is still running. */
+ active: boolean;
+ /** When the live turn started and ended on this device (epoch ms). */
+ startedAt?: number | null;
+ endedAt?: number | null;
+ /**
+ * The provenance metrics, for a message saved before steps existed. Read
+ * only when there is neither activity nor steps.
+ */
+ areas?: readonly CoachProvenanceMetric[];
+ /** How the answer was worked out; shown at the end of the open trail. */
+ method?: CoachMethod | null;
+ /**
+ * The tables the answer read without pointing at them, shown last in the
+ * open trail. Pass it only when there is something to show.
+ */
+ dataUsed?: ReactNode;
+ /** The persisted message, for reading its stored titles and texts. */
+ conversationId?: string | null;
+ messageId?: string | null;
+}
+
+export function CoachTurnActivity({
+ activity,
+ steps,
+ active,
+ startedAt,
+ endedAt,
+ areas,
+ method,
+ dataUsed,
+ conversationId,
+ messageId,
+}: CoachTurnActivityProps) {
+ const { t, tCount, locale } = useTranslations();
+ const panelId = useId();
+ // Closed in every state; only the person's tap opens it, and it stays as
+ // they left it (also across the swap to the persisted copy, which keeps
+ // this component).
+ const [open, setOpen] = useState(false);
+ const now = useNow(active);
+
+ // Only a persisted message whose live entries carry no text asks the
+ // server for it, and only once the trail is open.
+ const needsStoredText =
+ !active && activity.length > 0 && !activity.some((a) => a.title || a.text);
+ const trail = useTrailText({
+ conversationId,
+ messageId,
+ enabled: open && needsStoredText,
+ });
+
+ const areaLabels =
+ activity.length === 0 && steps.length === 0
+ ? legacyAreaLabels(areas, t)
+ : [];
+ const hasMethod = !active && !!method?.text;
+ const hasDataUsed = !active && dataUsed != null && dataUsed !== false;
+
+ let line: string;
+ if (active) {
+ const entry = currentActivity(activity);
+ const step = entry ? null : currentStep(steps);
+ line = entry
+ ? activityLineLabel(entry)
+ : step
+ ? stepLabel(step, t)
+ : t(COACH_ACTIVITY_KEYS.thinking);
+ } else {
+ line =
+ activitySummary({ activity, startedAt, endedAt, t, locale }) ??
+ (steps.length > 0
+ ? tCount("coach.step.headerDone", countSources(steps))
+ : areaLabels.length > 0
+ ? tCount("insights.coach.answer.areasDone", areaLabels.length)
+ : t(COACH_STEP_UI_KEYS.listLabel));
+ }
+ const announcement = useLineAnnouncement(line, active);
+
+ // A settled message with nothing to show has no line at all.
+ if (
+ !active &&
+ activity.length === 0 &&
+ steps.length === 0 &&
+ areaLabels.length === 0 &&
+ !hasMethod &&
+ !hasDataUsed
+ ) {
+ return null;
+ }
+
+ const seconds =
+ active && startedAt
+ ? elapsedSeconds(startedAt, Math.max(now, startedAt))
+ : null;
+
+ return (
+
+
+ {open && (
+
+ )}
+
+ {announcement}
+
+
+ );
+}
diff --git a/src/components/insights/coach-panel/turn-steps.tsx b/src/components/insights/coach-panel/turn-steps.tsx
deleted file mode 100644
index 9e62b0d01..000000000
--- a/src/components/insights/coach-panel/turn-steps.tsx
+++ /dev/null
@@ -1,498 +0,0 @@
-"use client";
-
-/**
- * v1.39.4 — what the Coach read on a turn: a compact list of steps above
- * the answer, live while the turn runs (from the `step` frames) and
- * restored from `metricSource.steps` on reload.
- *
- * While the turn runs the header names the step in progress ("Reading
- * Blood pressure, last 90 days…") with a soft shimmer; once it ends the
- * header folds into "Looked at 3 sources" and the list closes. The list
- * itself is one row per step: domain, window, and a count or the reason
- * nothing was found. Everything shown comes from catalog keys and the
- * server's enums and counts; a row never shows a value, a tool argument or
- * model text.
- *
- * The open list also says how the answer was worked out (the method line)
- * and holds the tables the answer read without pointing at them. An older
- * message saved before steps existed folds into "Looked at 3 areas" and
- * lists the areas from its provenance, without counts; a metric token the
- * bundle has no name for is left out rather than shown as a raw key.
- *
- * Screen readers hear each completed step once, through a polite status
- * region, at most one announcement per `ANNOUNCE_THROTTLE_MS` (the latest
- * wins). A reloaded conversation announces nothing.
- */
-import { useEffect, useId, useRef, useState, type ReactNode } from "react";
-import {
- Check,
- ChevronRight,
- Loader2,
- Minus,
- TriangleAlert,
-} from "lucide-react";
-
-import { cn } from "@/lib/utils";
-import { useTranslations } from "@/lib/i18n/context";
-import {
- COACH_STEP_REASON_KEYS,
- COACH_STEP_UI_KEYS,
- coachDomainLabelKey,
- coachGranularityLabelKey,
- coachPeriodLabelKey,
- coachWindowLabelKey,
-} from "@/lib/ai/coach/dialog-keys";
-import type {
- CoachMethod,
- CoachProvenanceMetric,
- CoachStep,
-} from "@/lib/ai/coach/types";
-
-import { CoachMethodLine } from "./method-line";
-
-export interface CoachTurnStepsProps {
- steps: CoachStep[];
- /** True while the turn is still running. */
- active: boolean;
-}
-
-export interface CoachTurnStepsDetailProps extends CoachTurnStepsProps {
- /**
- * The provenance metrics, for a message saved before steps existed. Read
- * only when `steps` is empty.
- */
- areas?: readonly CoachProvenanceMetric[];
- /** How the answer was worked out; shown at the end of the open list. */
- method?: CoachMethod | null;
- /**
- * The tables the answer read without pointing at them, shown last in the
- * open list. Pass it only when there is something to show.
- */
- dataUsed?: ReactNode;
-}
-
-/** The shortest gap between two screen-reader announcements. */
-export const ANNOUNCE_THROTTLE_MS = 1500;
-
-type Translate = (
- key: string,
- params?: Record,
-) => string;
-type TranslateCount = (
- baseKey: string,
- count: number,
- params?: Record,
-) => string;
-
-/** `t(key)`, or `fallback` when the bundle does not carry the key. */
-function tOr(
- t: Translate,
- key: string,
- fallback: string,
- params?: Record,
-) {
- const value = t(key, params);
- return value === key ? fallback : value;
-}
-
-/**
- * The step's label in the reader's language: the catalog key rendered with
- * the domain and window names, or the server's label when the key is not
- * in the bundle.
- */
-export function stepLabel(step: CoachStep, t: Translate): string {
- return tOr(t, step.labelKey, step.label, {
- ...(step.domain ? { domain: t(coachDomainLabelKey(step.domain)) } : {}),
- ...(step.window ? { window: t(coachWindowLabelKey(step.window)) } : {}),
- });
-}
-
-/**
- * One row's text: the domain as the title, then the window, period,
- * granularity, and either how much was read or why nothing was.
- */
-export function describeStep(
- step: CoachStep,
- t: Translate,
- tCount: TranslateCount,
-): { title: string; meta: string[] } {
- const title = step.domain
- ? t(coachDomainLabelKey(step.domain))
- : stepLabel(step, t);
- const meta: string[] = [];
- if (step.window) meta.push(t(coachWindowLabelKey(step.window)));
- if (step.period && step.period !== "current") {
- meta.push(t(coachPeriodLabelKey(step.period)));
- }
- if (step.granularity)
- meta.push(t(coachGranularityLabelKey(step.granularity)));
- if (step.status === "done" && step.count !== undefined) {
- const base =
- step.tool === "snapshot"
- ? "coach.step.metrics"
- : step.tool === "get_labs"
- ? "coach.step.rows"
- : "coach.step.readings";
- meta.push(tCount(base, step.count));
- } else if (step.status !== "running" && step.reason) {
- meta.push(t(COACH_STEP_REASON_KEYS[step.reason]));
- }
- return { title, meta };
-}
-
-function rowText(step: CoachStep, t: Translate, tCount: TranslateCount) {
- const { title, meta } = describeStep(step, t, tCount);
- return [title, ...meta].join(" · ");
-}
-
-/**
- * How many sources the turn looked at: one per domain (a step without one,
- * the snapshot, counts by its tool). A second read of the same domain, for
- * the period before or a retry, is not a new source.
- */
-export function countSources(steps: readonly CoachStep[]): number {
- return new Set(steps.map((step) => step.domain ?? step.tool)).size;
-}
-
-/**
- * The step the active header names: the latest one still running, else the
- * latest one overall (between two rounds nothing is running).
- */
-export function currentStep(steps: CoachStep[]): CoachStep | null {
- for (let i = steps.length - 1; i >= 0; i -= 1) {
- if (steps[i].status === "running") return steps[i];
- }
- return steps.at(-1) ?? null;
-}
-
-/**
- * The newest settled step the reader has not been told about yet, given the
- * ids already announced (or present when the list mounted). Null when there
- * is nothing new.
- */
-export function nextAnnouncement(
- steps: CoachStep[],
- told: ReadonlySet,
-): CoachStep | null {
- let latest: CoachStep | null = null;
- for (const step of steps) {
- if (step.status !== "running" && !told.has(step.id)) latest = step;
- }
- return latest;
-}
-
-/**
- * The status region's text: each newly settled step, throttled. Steps that
- * were already settled when the list mounted are never announced, so a
- * reloaded conversation stays quiet.
- */
-function useStepAnnouncement(
- steps: CoachStep[],
- describe: (step: CoachStep) => string,
-): string {
- // Mutable on purpose: the ids the reader has been told about, seeded with
- // whatever was already settled at mount.
- const [told] = useState(
- () => new Set(steps.filter((s) => s.status !== "running").map((s) => s.id)),
- );
- const lastAt = useRef(0);
- const timer = useRef | null>(null);
- const [text, setText] = useState("");
-
- useEffect(() => {
- const next = nextAnnouncement(steps, told);
- if (!next) return;
- for (const step of steps) {
- if (step.status !== "running") told.add(step.id);
- }
- const nextText = describe(next);
- const wait = Math.max(
- 0,
- lastAt.current + ANNOUNCE_THROTTLE_MS - Date.now(),
- );
- if (timer.current) clearTimeout(timer.current);
- timer.current = setTimeout(() => {
- timer.current = null;
- lastAt.current = Date.now();
- setText(nextText);
- }, wait);
- }, [steps, describe, told]);
-
- useEffect(
- () => () => {
- if (timer.current) clearTimeout(timer.current);
- },
- [],
- );
-
- return text;
-}
-
-function StepIcon({ step, active }: { step: CoachStep; active: boolean }) {
- const className = "mt-0.5 size-3 shrink-0";
- if (step.status === "running" && active) {
- return (
-
- );
- }
- if (step.status === "done") {
- return (
-
- );
- }
- if (step.status === "failed") {
- return (
-
- );
- }
- return (
-
- );
-}
-
-/**
- * The areas an older message drew on, in the order its provenance lists
- * them, by their display names. A token without a name in the bundle is
- * dropped, and so is `general`, which names no area of the record.
- */
-export function legacyAreaLabels(
- areas: readonly CoachProvenanceMetric[] | undefined,
- t: Translate,
-): string[] {
- const out: string[] = [];
- for (const area of areas ?? []) {
- if (area === "general") continue;
- const key = `insights.coach.metric.${area}`;
- const label = t(key);
- if (label === key || out.includes(label)) continue;
- out.push(label);
- }
- return out;
-}
-
-/** The rows under the header: one per step, in the order they started. */
-export function CoachTurnStepList({
- id,
- steps,
- active,
-}: CoachTurnStepsProps & { id?: string }) {
- const { t, tCount } = useTranslations();
- return (
-
- {steps.map((step) => {
- const { title, meta } = describeStep(step, t, tCount);
- return (
-
-
-
- {title}
- {meta.map((part, i) => (
-
- ·
- {/* The comma is for a screen reader only. `select-none`
- keeps it out of a copied row, which otherwise read
- "Weight · ,all time". */}
- ,
- {part}
-
- ))}
-
-
- );
- })}
-
- );
-}
-
-/**
- * The open list: the steps (or an older message's areas), then the method
- * line, then the tables the answer read. One left rule holds them together.
- */
-export function CoachTurnStepsPanel({
- id,
- steps,
- active,
- areaLabels,
- method,
- dataUsed,
-}: CoachTurnStepsProps & {
- id?: string;
- areaLabels: string[];
- method?: CoachMethod | null;
- dataUsed?: ReactNode;
-}) {
- const { t } = useTranslations();
- return (
-