Files
HealthLog/src/components/insights/coach-panel/message-thread.tsx
T
Marc-André Bombeck 95f21bfa4d fix(ui): announce failures, keep focus honest and stop the last layout shifts
Hidden states of the floating coach button now leave the tab order and
accessibility tree, its focus ring carries an offset, its icon is dark
on the gradient, an unread nudge is announced once via a live region,
and it sits correctly above the fold on tablet widths and yields to
the onboarding tour. Chart error cards announce themselves, share the
exact height variables of the skeleton band, carry per-chart retry
labels, and a failed chunk renders inside the regular card shell. The
header icon cluster spaces its extended hit areas apart, the tab strip
keeps horizontal paint room for focus rings, the streak row reserves
its slot in every card state, the selection bar clears the bottom nav
on landscape phones, and the version poller keeps an in-memory reload
guard for browsers that block session storage. Mood notes render as
plain paragraphs with a toggle that appears only when text is actually
clamped, the remember control reveals on hover and hands focus to its
confirmation, and the hypnogram names its source and paints only the
lanes the night contains.
2026-06-11 23:20:37 +02:00

910 lines
35 KiB
TypeScript

"use client";
import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import {
BookmarkPlus,
Bot,
Check,
ChevronRight,
Loader2,
Sparkles,
ThumbsDown,
ThumbsUp,
User,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { useAuth } from "@/hooks/use-auth";
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import { SourceChips } from "./source-chips";
import type {
CoachConversationDetailDTO,
CoachOptimisticUserMessage,
CoachStreamingMessage,
} from "./use-coach";
import type { CoachMessageDTO } from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — message-thread renderer.
*
* Mounted inside the centre column of the Coach drawer. Renders the
* persisted message history (decrypted server-side and delivered as
* `CoachMessageDTO[]`) + an optional in-flight assistant bubble fed
* by the streaming hook.
*
* Auto-scroll behaviour: scrolls to the bottom whenever the message
* list grows OR the streaming-content length changes. The user can
* scroll up to read history; we suppress auto-scroll until the next
* "new message" tick if they're not already pinned to the bottom.
*
* Visual identity: user bubbles right-aligned, Dracula purple accent;
* assistant bubbles left-aligned with the gradient sparkle avatar
* from the artboard.
*/
export interface MessageThreadProps {
conversation: CoachConversationDetailDTO | null;
/** Optional in-flight bubble from `useSendCoachMessage()`. */
streaming?: CoachStreamingMessage;
/**
* v1.4.25 W5 — optimistic user message surfaced by the send hook so
* the user sees their own bubble before the "Thinking…" placeholder.
* Cleared by the hook once the SSE `done` frame fires (the persisted
* twin lands via the invalidate-refetch). When the persisted twin is
* already in `conversation.messages` we suppress the optimistic copy
* so the user never sees the same bubble twice.
*/
optimisticUser?: CoachOptimisticUserMessage | null;
/** Empty-state copy when no conversation is loaded yet. */
emptyHint?: string;
/**
* v1.16.5 — locally-rendered bubbles the guided clarifying-questions
* flow interleaves with the persisted history (deterministic Coach
* questions + the closing summary). Items anchored on an answer
* render immediately before the user message that answered them;
* unanchored items render at the thread tail. See `placeInterleaved`.
*/
interleaved?: InterleavedThreadItem[];
}
/**
* v1.16.5 — one locally-rendered thread bubble contributed by the
* guided clarifying-questions flow. The thread owns only the placement;
* the node's behaviour lives with the flow in `coach-conversation`.
*/
export interface InterleavedThreadItem {
key: string;
/**
* Content of the user message this item precedes (a guided question
* renders above its answer). `null` → render at the thread tail
* (the current question / the summary).
*/
anchorAnswer: string | null;
node: React.ReactNode;
}
/**
* Pure placement for interleaved items, exported for unit tests.
* Items are chronological by construction (the guided flow emits them
* in question order), so a single forward pointer suffices: each
* anchored item consumes the first remaining user message whose
* content equals its anchor. Anchors that never match (e.g. an errored
* turn whose message was never persisted) fall through to the tail so
* no bubble is ever silently dropped.
*/
export function placeInterleaved(
items: InterleavedThreadItem[],
messages: { id: string; role: string; content: string }[],
optimisticContent: string | null,
): {
before: Map<string, InterleavedThreadItem>;
beforeOptimistic: InterleavedThreadItem[];
tail: InterleavedThreadItem[];
} {
const anchored = items.filter((i) => i.anchorAnswer !== null);
const before = new Map<string, InterleavedThreadItem>();
let p = 0;
for (const m of messages) {
if (p >= anchored.length) break;
if (m.role === "user" && m.content === anchored[p].anchorAnswer) {
before.set(m.id, anchored[p]);
p += 1;
}
}
const beforeOptimistic: InterleavedThreadItem[] = [];
if (
p < anchored.length &&
optimisticContent !== null &&
optimisticContent === anchored[p].anchorAnswer
) {
beforeOptimistic.push(anchored[p]);
p += 1;
}
const tail = [
...anchored.slice(p),
...items.filter((i) => i.anchorAnswer === null),
];
return { before, beforeOptimistic, tail };
}
function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= slack;
}
/**
* v1.4.25 W5 — map server-emitted error codes to specific Coach i18n
* keys. The chat route distinguishes the daily user-quota
* (`coach.budget.exceeded`, returned as a JSON 429) from the provider
* rate-limit (`coach.provider.rate_limited`, streamed as an SSE error
* frame). Both used to surface as the generic provider-unavailable
* copy; we now route each to its dedicated translation so the user
* understands whether the limit is on their side (reset at UTC
* midnight) or transient on the provider side (retry in ~5 min).
*
* Exported so the resolver can be pinned by unit tests without
* standing up the whole thread renderer.
*/
export function errorCodeToI18nKey(code: string): string {
switch (code) {
case "coach.budget.exceeded":
return "insights.coach.dailyLimitBody";
case "coach.provider.rate_limited":
return "insights.coach.providerRateLimitBody";
case "coach.network":
// v1.4.43 QoL (M6) — a dropped network is the user's local
// problem and needs a different next action ("come back online")
// than a provider failure ("try again in a moment"). Split out
// so the user sees the actionable copy in the offline branch.
return "insights.coach.errorNetwork";
case "coach.provider.credential_expired":
// v1.11.0 W1 — the user's primary AI provider credential is dead
// (auth-class failure). The next action is "reconnect", not "try
// again later", so it carries its own copy pointing at Settings.
return "insights.coach.errorCredentialExpired";
case "coach.provider.unavailable":
case "coach.provider.empty":
case "coach.provider.none":
case "coach.stream":
return "insights.coach.errorProvider";
default:
// Forward-compat: try `insights.coach.<code>` for codes that
// ship their own translation (e.g. legacy `errorProvider`).
return `insights.coach.${code}`;
}
}
export function MessageThread({
conversation,
streaming,
optimisticUser,
emptyHint,
interleaved,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef<HTMLDivElement | null>(null);
const wasPinnedRef = useRef(true);
// v1.4.27 F14 — the evidence disclosure used to honour an opt-in
// `coachPrefs.showEvidenceByDefault` flag that surfaced the raw
// measurement values unconditionally. The flag created an UX trap
// (the maintainer 2026-05-15: "literal metric values exposed under every
// bubble") so the disclosure is now collapsed by default for every
// reply. The user expands by click; the pref is retired in the
// settings sheet so nothing flips it back on.
const messages: CoachMessageDTO[] = useMemo(
() => conversation?.messages ?? [],
[conversation?.messages],
);
// v1.16.5 — locally-rendered guided bubbles (see `placeInterleaved`).
const interleavedItems = interleaved ?? [];
// v1.4.20.1 — once the SSE stream emits `done`, the route's
// invalidate-then-refetch pulls the persisted assistant message into
// `conversation.messages`. The streaming bubble was still rendering
// because the hook keeps `streaming.content` populated to support
// the in-flight render path; the result was two assistant bubbles
// side by side until the next `send` reset the streaming state.
// Suppress the streaming bubble at render time as soon as the
// persisted twin lands — comparing on `messageId` keeps the
// transition seamless while never accidentally hiding an in-flight
// bubble (during streaming `messageId` is null).
// v1.4.22 W5 reconcile (Code-MED-3) — streaming/persisted-twin
// race. On slow connections SSE `done` fires before the
// invalidate-refetch resolves; the persisted twin lands while the
// streaming bubble is still painted, producing a 200-500ms
// duplicate render. Hide the persisted twin (matched on
// streaming.messageId) for a 150ms grace window after it first
// appears so the streaming bubble stays alone until the streaming
// state cleans up naturally on the next `send`.
const [graceWindow, setGraceWindow] = useState(false);
const lastPersistedIdRef = useRef<string | null>(null);
useEffect(() => {
const persistedId = streaming?.messageId ?? null;
if (
persistedId &&
persistedId !== lastPersistedIdRef.current &&
messages.some((m) => m.id === persistedId)
) {
lastPersistedIdRef.current = persistedId;
setGraceWindow(true);
const handle = setTimeout(() => setGraceWindow(false), 150);
return () => clearTimeout(handle);
}
}, [streaming?.messageId, messages]);
const suppressedTwinId = graceWindow ? streaming?.messageId : null;
const streamingPersisted =
!graceWindow &&
streaming?.messageId != null &&
messages.some((m) => m.id === streaming.messageId);
const streamingActive =
!streamingPersisted &&
(!!streaming?.inProgress || !!streaming?.content || !!streaming?.errorCode);
// v1.4.25 W5 — render the optimistic user bubble only when the
// persisted twin hasn't landed yet. We match on (role=user, content
// equality, no later persisted user message). The server is the
// source of truth — once the persisted user message lands (via the
// invalidate-refetch the SSE `done` frame triggers), the optimistic
// copy is dropped so the user never sees their bubble twice.
const optimisticActive = (() => {
if (!optimisticUser) return false;
// Suppress if the persisted history already contains the same
// user content as the last user message — the twin has landed.
const lastUser = [...messages].reverse().find((m) => m.role === "user");
if (lastUser && lastUser.content === optimisticUser.content) return false;
return true;
})();
// v1.16.5 — slot the guided bubbles around the persisted history,
// the optimistic user bubble, and the streaming tail. Cheap on every
// render: at most a handful of items over a single message walk.
const placement = placeInterleaved(
interleavedItems,
messages,
optimisticActive && optimisticUser ? optimisticUser.content : null,
);
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const onScroll = () => {
wasPinnedRef.current = isPinnedToBottom(el);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
// Auto-scroll on new messages OR streaming-content growth, but only
// when the user was already at the bottom. v1.4.25 W5 — the
// optimistic user bubble counts as a new message; scroll on its
// localId so the user sees their own bubble land at the bottom.
// v1.16.5 — guided bubbles count as new messages for the auto-scroll.
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
if (wasPinnedRef.current) {
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
}
}, [
messages.length,
streaming?.content,
optimisticUser?.localId,
interleavedItems.length,
]);
// v1.4.27 R3d MB4 / CF-74 — re-pin to the bottom when the
// visual viewport shrinks (typically the soft keyboard opening on
// a phone). Without this the last bubble drifts behind the keyboard
// because the scroller's `scrollHeight` references the layout
// viewport, not the visible region. Listening on
// `window.visualViewport.resize` and re-issuing the scroll keeps the
// tail of the thread visible as the keyboard slides in and out.
useEffect(() => {
if (typeof window === "undefined") return;
const vv = window.visualViewport;
if (!vv) return;
const handleResize = () => {
const el = scrollerRef.current;
if (!el) return;
if (wasPinnedRef.current) {
el.scrollTo({ top: el.scrollHeight, behavior: "auto" });
}
};
vv.addEventListener("resize", handleResize);
return () => vv.removeEventListener("resize", handleResize);
}, []);
if (
messages.length === 0 &&
!streamingActive &&
!optimisticActive &&
interleavedItems.length === 0
) {
return (
<div
data-slot="coach-message-thread"
role="status"
aria-live="polite"
className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 px-6 text-center"
>
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-12 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-5" />
</div>
<p className="max-w-sm text-sm leading-relaxed">
{emptyHint ?? t("insights.coach.threadEmpty")}
</p>
</div>
);
}
return (
<div
ref={scrollerRef}
data-slot="coach-message-thread"
className={cn(
"flex h-full flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6",
"scroll-smooth",
)}
>
{messages.map((m) => {
// v1.4.22 W5 reconcile (Code-MED-3) — suppress the persisted
// twin during the 150ms grace window so the streaming bubble
// stays alone on slow connections.
if (m.id === suppressedTwinId) return null;
// v1.16.5 — a guided question renders directly above the user
// message that answered it.
const guidedBefore = placement.before.get(m.id);
return (
<Fragment key={m.id}>
{guidedBefore?.node}
<ChatBubble
role={m.role}
content={m.content}
metricSource={m.metricSource}
providerType={m.providerType}
messageId={m.id}
/>
</Fragment>
);
})}
{/* v1.4.25 W5 — optimistic user bubble surfaces between the
persisted history and the streaming assistant placeholder so
the visible order matches the user's mental model. The
send-hook drops it as soon as the SSE `done` frame fires +
the invalidate-refetch lands the persisted twin. */}
{/* v1.16.5 — guided question whose answer is still optimistic-only. */}
{placement.beforeOptimistic.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
{optimisticActive && optimisticUser && (
<ChatBubble
key={optimisticUser.localId}
role="user"
content={optimisticUser.content}
/>
)}
{streamingActive && streaming && (
// role=log + aria-live=polite so screen-reader users hear the
// assistant prose announce as tokens land. aria-relevant=text
// limits announcements to the streamed content; additions
// covers the bubble-mount edge case.
<div role="log" aria-live="polite" aria-relevant="additions text">
<ChatBubble
role="assistant"
content={streaming.content}
metricSource={streaming.metricSource}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
/>
</div>
)}
{/* v1.16.5 — thread tail: the current guided question and/or the
closing summary follow the last completed turn. */}
{placement.tail.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
</div>
);
}
interface ChatBubbleProps {
role: "user" | "assistant";
content: string;
metricSource?: import("@/lib/ai/coach/types").CoachProvenance | null;
providerType?: string | null;
inProgress?: boolean;
errorCode?: string | null;
/**
* v1.4.23 H7 — present only on persisted assistant messages.
* Streaming bubbles (no message id yet) skip the thumbs row so the
* user can't rate before the message lands on disk.
*/
messageId?: string;
}
function ChatBubble({
role,
content,
metricSource,
providerType,
inProgress,
errorCode,
messageId,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
// v1.4.27 B7 / L3 — pair the evidence `<details>` and its disclosed
// list explicitly so screen-readers announce the panel relationship.
const evidencePanelId = useId();
// v1.4.27 MB3 / CF-32 — track the disclosure state in React so the
// summary can carry an accurate `aria-expanded`. Native `<details>`
// reflects its open state via the `open` attribute, but that does
// not surface as `aria-expanded` on the summary by default; screen
// readers still need the explicit attribute to announce the panel
// as expanded vs collapsed.
const [evidenceOpen, setEvidenceOpen] = useState(false);
if (role === "user") {
// v1.5.5 — pull the user's self-hosted avatar so the user
// bubble matches the Coach avatar in size and visual weight.
// Falls back to initials when the user has not uploaded an
// avatar. Replaces the Gravatar leak (v1.4.22 B3).
const avatarUrl = user?.avatarUrl ?? null;
const initials = user?.username
? user.username.slice(0, 2).toUpperCase()
: null;
return (
<div
data-slot="coach-bubble-user"
className="flex items-start justify-end gap-2.5"
>
<div
className={cn(
// Budget the avatar column (size-8 + gap-2.5 ≈ 2.625rem) out
// of the 80% cap so the bubble + avatar together never
// overflow a comfortable width on a narrow phone.
// `group/user-bubble` scopes the remember control's
// hover/focus reveal (see `RememberUserMessage`).
"group/user-bubble flex max-w-[calc(80%-2.625rem)] flex-col items-end gap-1",
)}
>
<div
className={cn(
"border-dose-accent/30 bg-dose-accent/12 text-foreground",
"rounded-xl rounded-tr-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
)}
>
{content}
</div>
{/* v1.16.8 — explicit remember control. Stating an allergy in
chat used to leave no durable trace unless a narrow
pattern pass happened to match the phrasing; this stores
the message into the editable self-context (Settings → AI)
on one tap, so it rides every future system prompt. Only
persisted messages get the control (an optimistic bubble
has no id yet), and only when the text fits the
self-context field cap. */}
{messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS && (
<RememberUserMessage content={content} />
)}
</div>
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarUrl}
alt=""
aria-hidden="true"
data-slot="coach-bubble-user-avatar"
className="border-border/50 mt-0.5 size-8 shrink-0 rounded-full border object-cover"
/>
) : (
<div
aria-hidden="true"
data-slot="coach-bubble-user-avatar"
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold"
>
{initials ?? <User className="size-3.5" />}
</div>
)}
</div>
);
}
// v1.4.25 W5 — map server-emitted error codes to specific Coach
// i18n keys. Distinct user-quota and provider-rate-limit copy so the
// user understands daily-cap (resets at UTC midnight) vs. transient
// provider load (retry in ~5 min). Codes that have no dedicated
// translation fall back to the generic provider-unavailable copy.
const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null;
const errorMessage = errorKey ? t(errorKey, {}) : null;
// When a translated message comes back unchanged (i.e. key missing)
// we fall back to a generic provider error string so the bubble
// doesn't surface raw `coach.http.503` text to the user.
const safeError =
errorMessage && errorMessage !== errorKey
? errorMessage
: errorCode
? t("insights.coach.errorProvider")
: null;
const keyValues = metricSource?.keyValues ?? [];
// v1.12.0 — the provenance disclosure surfaces whenever there is any
// grounding to show: the source chips (metrics/windows) and/or the
// raw key-values. `SourceChips` itself returns null when the envelope
// carries neither metric nor window, so we mirror that condition here
// to decide whether the `<details>` shell renders at all.
const hasChips =
!!metricSource &&
((metricSource.metrics?.length ?? 0) > 0 ||
(metricSource.windows?.length ?? 0) > 0);
const hasProvenance = hasChips || keyValues.length > 0;
return (
<div
data-slot="coach-bubble-assistant"
className="flex items-start gap-2.5"
>
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
{providerType === "refusal" ? (
<Bot className="text-background size-3.5" />
) : (
<Sparkles className="text-background size-3.5" />
)}
</div>
<div className="flex max-w-[calc(80%-2.625rem)] flex-col gap-2">
<div
className={cn(
"border-border/60 bg-muted/40 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed whitespace-pre-wrap",
)}
>
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from
the assistant prose before it lands in the bubble. The
raw `content` is the streamed Coach reply (or the
persisted twin after `done` fires); both paths are AI-
authored so they share the same leak surface as the
insight prose elsewhere.
v1.16.1 — before the first token lands, the bubble shows
a classic typing indicator (three pulsing dots) instead
of the old whole-bubble pulse + "Thinking…" prose. The
prose stays as the screen-reader announcement. */}
{/* v1.16.4 — a failed turn with no streamed prose renders the
error copy INSIDE the bubble instead of an empty bubble
with a caption below it. With partial prose the prose
keeps the bubble and the error stays a caption. */}
{content ? (
stripChartTokens(content)
) : inProgress ? (
<TypingDots label={t("insights.coach.thinking")} />
) : safeError ? (
<span className="text-warning/90">{safeError}</span>
) : (
""
)}
</div>
{safeError && content && (
<p className="text-warning/90 text-xs">{safeError}</p>
)}
{/* v1.12.0 — collapse the whole provenance block ("what was
included") behind one disclosure, collapsed by default. The
source chips used to render fully expanded above the
evidence `<details>`, so the grounding context was often
taller than the answer itself. Folding the chips + the raw
key-values into a single closed disclosure keeps the reply
the focus and lets the user expand the grounding on demand —
and removes the always-on chip row that duplicated what the
disclosure already names. The disclosure surfaces whenever
there is any provenance to show (chips and/or key-values). */}
{hasProvenance && (
<details
data-slot="coach-evidence"
open={evidenceOpen}
onToggle={(e) =>
setEvidenceOpen((e.target as HTMLDetailsElement).open)
}
// v1.4.27 F14 — always closed by default. The `open`
// attribute was previously tied to a per-user pref that
// surfaced raw values unconditionally; that pref is now
// retired and the disclosure is a true progressive-
// disclosure surface — the user clicks to expand.
//
// v1.4.27 MB3 / CF-32 — the `open` attribute is now
// controlled from local state so the summary's
// `aria-expanded` stays in lock-step. The native disclosure
// semantics (Enter / Space toggle) are preserved.
className={cn(
"border-border/50 bg-muted/30 group rounded-md border",
"px-2.5 py-1.5 text-xs",
)}
>
<summary
data-slot="coach-evidence-summary"
aria-controls={evidencePanelId}
aria-expanded={evidenceOpen}
className={cn(
"text-muted-foreground hover:text-foreground flex cursor-pointer",
"items-center gap-1.5 leading-relaxed",
"marker:hidden [&::-webkit-details-marker]:hidden",
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className="size-3 transition-transform group-open:rotate-90"
/>
<span>{t("insights.coach.evidenceLabel")}</span>
</summary>
<div
id={evidencePanelId}
data-slot="coach-evidence-panel"
className="mt-2 flex flex-col gap-2"
>
{/* v1.12.0 — the source chips now live inside the
disclosure so they expand with the rest of the
grounding instead of always painting above the
answer. */}
{hasChips && metricSource && (
<SourceChips provenance={metricSource} />
)}
{keyValues.length > 0 && (
<ul
data-slot="coach-evidence-list"
className="text-foreground flex flex-col gap-1"
>
{keyValues.map((kv, idx) => (
<li
key={`${kv.label}-${idx}`}
data-slot="coach-evidence-row"
className="leading-relaxed"
>
{/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic")
was rendered prefixed to every row, repeating
framing the disclosure heading already gives.
Drop the label and lead with the value; the
window stays as a parenthetical tail so the row
still answers "over what timeframe?". */}
<strong className="font-semibold">
{kv.value}
{kv.unit ? ` ${kv.unit}` : ""}
</strong>
{kv.window && (
<span className="text-muted-foreground">
{" "}
({kv.window})
</span>
)}
</li>
))}
</ul>
)}
</div>
</details>
)}
{/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
assistant messages get the row (skipped for refusals,
errors, in-flight stream bubbles). The aggregator buckets
the rating by (promptVersion, tone, verbosity). */}
{messageId &&
!inProgress &&
!errorCode &&
providerType !== "refusal" && (
<CoachMessageFeedback messageId={messageId} />
)}
</div>
</div>
);
}
/**
* v1.16.8 — per-message remember control under the user bubble.
*
* One tap stores the message text into the structured self-context via
* `POST /api/coach/about-me/adopt` (no `question` — the server matches
* the target field from the text itself: an allergy statement lands on
* the allergies field, a stated condition on conditions, everything
* else on the coach-focus slot). The stored text is visible and
* editable under Settings → AI and rides every future Coach system
* prompt. Settled states mirror `SelfContextAdoptOffer`: a short
* confirmation replaces the button after an adoption or a server-side
* dedupe; a failure surfaces a toast and the button stays tappable.
*/
function RememberUserMessage({ content }: { content: string }) {
const { t } = useTranslations();
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
// On settle the button unmounts while it holds focus, which would
// drop keyboard focus to <body>. The confirmation paragraph takes
// the focus instead (`tabIndex={-1}` + programmatic focus) so the
// reading position survives the swap.
const statusRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
if (settled) statusRef.current?.focus();
}, [settled]);
const remember = useMutation({
mutationFn: async () => {
return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
answer: content,
});
},
onSuccess: (data) => {
setSettled(data.adopted ? "adopted" : "duplicate");
},
onError: () => {
toast.error(t("insights.coach.rememberMessage.failed"));
},
});
if (settled) {
return (
<p
role="status"
ref={statusRef}
tabIndex={-1}
data-slot="coach-remember-message-done"
className="text-muted-foreground flex items-center gap-1 text-xs outline-none"
>
<Check className="text-dracula-green size-3" aria-hidden="true" />
{t(
settled === "adopted"
? "insights.coach.rememberMessage.done"
: "insights.coach.rememberMessage.duplicate",
)}
</p>
);
}
return (
<button
type="button"
data-slot="coach-remember-message"
onClick={() => remember.mutate()}
disabled={remember.isPending}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-8 items-center gap-1 rounded px-1.5 py-1 text-xs",
"outline-none focus-visible:ring-2 disabled:opacity-50",
// Calmer thread on pointer devices: the control stays invisible
// until its bubble is hovered or holds focus. Touch viewports
// (no hover media) keep it always visible — there is nothing to
// hover. `opacity-0` (not `invisible`) keeps it focusable so
// keyboard users can reach it; focus then reveals it.
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:focus-visible:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
{remember.isPending ? (
<Loader2
className="size-3 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<BookmarkPlus className="size-3" aria-hidden="true" />
)}
{t("insights.coach.rememberMessage.action")}
</button>
);
}
/**
* v1.16.1 — classic chat typing indicator: three dots pulsing in
* sequence inside the assistant bubble, shown only between submit and
* the first streamed token. Uses the stock `animate-pulse` keyframe
* with staggered delays so no custom keyframe is introduced;
* `motion-reduce` freezes the dots and the `label` stays as the
* screen-reader text either way.
*
* Exported since v1.16.5: the guided clarifying-question bubble replays
* the same indicator before a deterministic question reveals, so the
* scripted turns share one rhythm with the streamed ones.
*/
export function TypingDots({ label }: { label: string }) {
return (
<span
data-slot="coach-typing-indicator"
className="inline-flex items-center gap-1 py-1"
>
<span className="sr-only">{label}</span>
{[0, 1, 2].map((i) => (
<span
key={i}
aria-hidden="true"
className="bg-muted-foreground/70 size-1.5 animate-pulse rounded-full motion-reduce:animate-none"
style={{ animationDelay: `${i * 250}ms` }}
/>
))}
</span>
);
}
interface CoachMessageFeedbackProps {
messageId: string;
}
function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
const { t } = useTranslations();
const [submittedRating, setSubmittedRating] = useState<
"helpful" | "unhelpful" | null
>(null);
const submit = useMutation({
mutationFn: async (rating: "helpful" | "unhelpful") => {
try {
await apiPost(`/api/insights/chat/messages/${messageId}/feedback`, {
rating,
});
} catch (err) {
// Treat 409 (already_rated) as a successful no-op so the user
// never sees an error toast for double-clicking the same chip.
if (!(err instanceof ApiError && err.status === 409)) {
throw err;
}
}
return rating;
},
onSuccess: (rating) => setSubmittedRating(rating),
// v1.16.4 — a failed rating used to fail silently; the chips stayed
// tappable with no signal that nothing was recorded.
onError: () => {
toast.error(t("insights.coach.feedbackError"));
},
});
if (submittedRating) {
return (
<p
data-slot="coach-message-feedback-thanks"
className="text-muted-foreground text-xs"
>
{t("insights.coach.feedbackThanks")}
</p>
);
}
return (
<div data-slot="coach-message-feedback" className="flex items-center gap-2">
<button
type="button"
data-slot="coach-message-feedback-helpful"
onClick={() => submit.mutate("helpful")}
disabled={submit.isPending}
className="text-muted-foreground hover:text-success focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
<ThumbsUp className="size-3" aria-hidden="true" />
{t("insights.coach.feedbackHelpful")}
</button>
<button
type="button"
data-slot="coach-message-feedback-unhelpful"
onClick={() => submit.mutate("unhelpful")}
disabled={submit.isPending}
className="text-muted-foreground hover:text-warning focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
<ThumbsDown className="size-3" aria-hidden="true" />
{t("insights.coach.feedbackUnhelpful")}
</button>
</div>
);
}