fix(coach): restore the typing animation for the live turn

Replace the elapsed-time thinking disclosure with the classic typing
indicator: while a turn is still thinking with no prose, the assistant
bubble shows the pulsing typing dots, then swaps to the streamed
word-by-word prose once tokens land. Drop the now-unused reasoning/
startedAt bubble props and the orphaned disclosure component.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 13:58:57 +02:00
1 parent bbbafca7f4
commit a86abd3662
2 files changed
+11 -178

No files matched your search

@@ -26,7 +26,6 @@ import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import { SourceChips } from "./source-chips";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { StreamedProse } from "./streamed-prose";
import { ThinkingDisclosure } from "./thinking-disclosure";
import { MessageTokenFooter } from "./message-token-footer";
import type {
CoachConversationDetailDTO,
@@ -470,11 +469,8 @@ export function MessageThread({
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
// v1.18.9 — live word-fade + the elapsed-time thinking
// disclosure + the just-landed token footer.
// v1.18.9 — live word-fade + the just-landed token footer.
streaming
startedAt={streaming.startedAt}
reasoning={streaming.reasoning}
usage={streaming.usage}
/>
</div>
@@ -513,17 +509,6 @@ interface ChatBubbleProps {
* settled plain text.
*/
streaming?: boolean;
/**
* v1.18.9 — send-anchored timestamp (epoch ms) for the streaming turn,
* feeding the thinking disclosure's elapsed timer. Null on persisted
* bubbles.
*/
startedAt?: number | null;
/**
* v1.18.9 — reasoning-summary text from the additive `reasoning` SSE
* frame, shown inside the thinking disclosure when present.
*/
reasoning?: string;
/**
* v1.18.9 — per-turn token usage for the just-finished streaming bubble
* (from `done.usage`). Persisted bubbles read `tokensUsed` / `model`
@@ -548,8 +533,6 @@ function ChatBubble({
errorCode,
messageId,
streaming,
startedAt,
reasoning,
usage,
tokensUsed,
model,
@@ -678,28 +661,16 @@ function ChatBubble({
)}
</div>
<div className="flex max-w-[calc(80%-2.625rem)] flex-col gap-2">
{/* v1.18.9 — the single thinking indicator (live elapsed-time line,
then an auto-collapsed past-tense disclosure). Renders only for
the live streaming turn — persisted bubbles pass no `startedAt`,
so the disclosure returns null and history stays settled. This
replaces the old <TypingDots> dots-plus-word combo: there is
never a three-dots indicator AND a "Thinking" word at once. */}
{streaming && startedAt != null && (
<ThinkingDisclosure
startedAt={startedAt}
inProgress={!!inProgress}
hasContent={!!content}
reasoning={reasoning}
/>
)}
{/* The prose bubble. Skipped while the live turn is still thinking
with no prose (the disclosure carries the live state above), so
the user never sees an empty bubble under the indicator. The
`<TypingDots>` fallback below only fires when there is no live
disclosure (e.g. a defensive null `startedAt`). */}
{(content ||
safeError ||
(inProgress && !(streaming && startedAt != null))) && (
{/* v1.19.1 (C3) — the live turn shows the classic typing animation
(three pulsing dots) while it is still thinking with no prose
yet, restoring the writing/typing indicator the maintainer
prefers over the "Denke nach / Nachgedacht" reasoning disclosure.
The dots render inside the prose bubble below; the disclosure is
retired. Persisted bubbles never stream, so history stays settled. */}
{/* The prose bubble. While the live turn is still thinking with no
prose the bubble carries the typing animation; once tokens land
it swaps to the streamed prose without a layout jump. */}
{(content || safeError || inProgress) && (
<div
className={cn(
"border-border/60 bg-muted/40 text-foreground",
@@ -1,138 +0,0 @@
"use client";
import { useEffect, useId, useState } from "react";
import { ChevronRight, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — the ONE Coach thinking indicator on the page surface.
*
* Replaces the old dots-plus-word combo (`<TypingDots>`), which showed
* three bouncing dots AND the word "Thinking…" at the same time — the
* exact pairing the maintainer disliked. This is a single, calm control:
*
* - While the turn is thinking (streaming, no prose yet): a live
* "Thinking… N s" line with a small spinner. `role="status"` so a
* screen reader announces "thinking" once, not on every tick.
* - Once the first token lands OR the turn settles: it relabels to the
* past tense ("Thought for N s") and auto-collapses into a quiet
* `<details>` disclosure. Expanding reveals the reasoning summary when
* a reasoning-capable provider supplied one (the additive `reasoning`
* SSE frame), otherwise a one-line factual fallback — never fabricated
* chain-of-thought.
*
* The elapsed time is computed from the send-anchored `startedAt`
* timestamp: it ticks live while thinking and freezes the instant the
* first token (or the settled state) arrives.
*/
export interface ThinkingDisclosureProps {
/**
* Epoch-ms timestamp the turn started (the moment the send fired). Null
* disables the elapsed timer (the component renders nothing).
*/
startedAt: number | null;
/** True until the turn settles (the `done`/`error` frame closed it). */
inProgress: boolean;
/**
* Whether any assistant prose has streamed in yet. Drives the live →
* past-tense relabel + auto-collapse: the moment the answer starts, the
* "Thinking…" line freezes and folds away.
*/
hasContent: boolean;
/**
* Optional reasoning-summary text from the additive `reasoning` SSE
* frame, rendered inside the expanded disclosure when present.
*/
reasoning?: string;
}
export function ThinkingDisclosure({
startedAt,
inProgress,
hasContent,
reasoning,
}: ThinkingDisclosureProps) {
const { t } = useTranslations();
const panelId = useId();
const [open, setOpen] = useState(false);
// The turn is "thinking" only while it is in progress with no prose yet.
const thinking = inProgress && hasContent === false && startedAt !== null;
// `elapsed` ticks once a second while thinking; the interval stops the
// moment `thinking` flips false, so the last value it set is the frozen
// "thought for N s" figure — no render-time ref, no sync setState in the
// effect body (the first tick lands on the interval, not inline).
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!thinking || startedAt === null) return;
const tick = () =>
setElapsed(Math.max(0, Math.round((Date.now() - startedAt) / 1000)));
tick();
const handle = window.setInterval(tick, 1000);
return () => window.clearInterval(handle);
}, [thinking, startedAt]);
if (startedAt === null) return null;
// ── Live "thinking" line (single indicator, no dots-plus-word) ──
if (thinking) {
return (
<div
data-slot="coach-thinking-live"
role="status"
aria-live="polite"
className="text-muted-foreground inline-flex items-center gap-2 py-0.5 text-xs"
>
<Loader2
aria-hidden="true"
className="text-dracula-purple/70 size-3.5 animate-spin motion-reduce:animate-none"
/>
<span>{t("insights.coach.thinking")}</span>
<span aria-hidden="true" className="tabular-nums opacity-70">
{t("insights.coach.thinkingElapsed", { seconds: elapsed })}
</span>
</div>
);
}
// ── Settled: past-tense collapsible disclosure (auto-collapsed) ──
const reasoningText = reasoning?.trim() ?? "";
return (
<details
data-slot="coach-thinking-disclosure"
open={open}
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
className="group inline-block"
>
<summary
data-slot="coach-thinking-summary"
aria-controls={panelId}
aria-expanded={open}
aria-label={t("insights.coach.thinkingExpandAria")}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex cursor-pointer items-center gap-1 text-xs",
"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.thinkingDuration", { seconds: elapsed })}
</span>
</summary>
<p
id={panelId}
data-slot="coach-thinking-detail"
className="text-muted-foreground mt-1.5 pl-4 text-xs leading-relaxed"
>
{reasoningText || t("insights.coach.thinkingDetail")}
</p>
</details>
);
}