mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user