Files
Jyotisha/frontend/src/components/chat-message-row.tsx
T
Jesse_Chen be5e810ac9 fix(chat): keep the latest reply mounted through settlement and animate the timeline collapse
The streaming and settled versions of the trailing assistant reply were
two components, so settling unmounted one and mounted the other and the
entrance tween replayed over text the reader was already on. One
LatestAssistantEntry now owns that row under a single key, and the
history list excludes it. The CSS entrance keyframe that doubled the
GSAP tween is gone and the tween matches the documented 160ms. The step
timeline no longer remounts on settle: it is a button-controlled
disclosure with a 180ms grid-rows transition, the reader's own toggle
wins over the live default, and an in-flight request with no events yet
shows a queued row instead of an empty shell.

BUG-474 BUG-475

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:02:15 +00:00

182 lines
6.3 KiB
TypeScript

"use client";
import { AgentActivityStatus } from "@/components/agent-activity-status";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ConsultationRunTimeline } from "@/components/consultation-run-timeline";
import { ConsultationThinkingReport } from "@/components/consultation-thinking-report";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { useEffect, useLayoutEffect, useRef } from "react";
type GsapCore = typeof import("gsap")["gsap"];
const useEntryEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
let gsapCore: GsapCore | undefined;
let gsapRequest: Promise<GsapCore> | undefined;
function loadGsap() {
gsapRequest ??= import("gsap").then((module) => {
const core = module.gsap ?? module.default;
gsapCore = (core as GsapCore & { gsap?: GsapCore }).gsap ?? core;
return gsapCore;
});
return gsapRequest;
}
function motionPreferred() {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
if (motionPreferred()) prefetchOnIdle(loadGsap);
export function AgentAvatar() {
return <span className="agent-avatar" aria-hidden="true" />;
}
export function ChatMessageRow({
message,
showActivity = message.state !== "settled",
vargaSentence,
}: Readonly<{
message: ChatMessageView;
showActivity?: boolean;
vargaSentence?: string | null;
}>) {
const messageRow = useRef<HTMLElement>(null);
const assistantLabel = message.state === "thinking"
? "Jyotisha 正在分析"
: message.state === "streaming"
? "Jyotisha 正在回答"
: "Jyotisha";
const activityState = message.activity
? ({
"loading-method": "searching",
"chart-calculation": "solving",
"evidence-validation": "working",
"answer-composition": "composing",
} as const)[message.activity.phase]
: message.state === "thinking" ? "working" : "composing";
const activityLabel = message.activity?.label
?? (message.state === "thinking" ? "正在处理…" : undefined);
const hasAnswer = Boolean(message.text.trim());
const consultTimeline = message.timeline;
const thinkingSections = message.thinkingSections ?? [];
const hasTrace = (message.activityTrace?.length ?? 0) > 0;
const showReport = consultTimeline === undefined && thinkingSections.length > 0;
const showLiveActivity = showActivity && !showReport && consultTimeline === undefined;
const showThinkingPanel = showLiveActivity || (!showReport && consultTimeline === undefined && (Boolean(message.thinkingText?.trim()) || hasTrace));
const showSpokenAnswer = !showReport && Boolean(message.text);
const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer;
useEntryEffect(() => {
const row = messageRow.current;
if (!row) return;
if (!gsapCore) {
if (motionPreferred()) void loadGsap();
return;
}
const gsap = gsapCore;
const motion = gsap.matchMedia();
motion.add("(prefers-reduced-motion: no-preference)", () => {
gsap.fromTo(row, {
autoAlpha: 0,
y: message.role === "user" ? 8 : 12,
}, {
autoAlpha: 1,
clearProps: "opacity,transform,visibility",
duration: 0.16,
ease: "cubic-bezier(.22, 1, .36, 1)",
y: 0,
});
});
return () => motion.revert();
}, [message.role]);
const thinkingPanel = showThinkingPanel
? (
<AgentActivityStatus
state={activityState}
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
thinkingText={hasTrace ? undefined : message.thinkingText}
activityTrace={message.activityTrace}
vargaSentence={vargaSentence}
hasAnswer={hasAnswer}
showLive={showLiveActivity}
/>
)
: null;
const spokenAnswer = showSpokenAnswer || (consultTimeline !== undefined && hasAnswer)
? (
<ChatMessageContent
text={message.text}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={showThinkingPanel ? null : vargaSentence}
streaming={message.state !== "settled"}
/>
)
: null;
return (
<article
ref={messageRow}
className={`message message-${message.role}`}
aria-label={message.role === "assistant" ? assistantLabel : "你"}
>
{message.role === "assistant" && <AgentAvatar />}
<div className="message-content">
<div className="message-bubble">
{message.role === "assistant" ? (
consultTimeline !== undefined ? (
<div className="consultation-thinking-report">
<ConsultationRunTimeline
rows={consultTimeline}
live={showActivity && message.state !== "settled"}
/>
{hasAnswer ? (
<section className="consultation-report-analysis" aria-label="回复">
{spokenAnswer}
</section>
) : null}
</div>
) : (
<>
{showReport && (
<ConsultationThinkingReport
sections={thinkingSections}
thinkingText={message.thinkingText}
answer={message.text}
live={showActivity && !hasAnswer}
liveLabel={activityLabel}
liveState={activityState}
startedAt={message.activity?.startedAt}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={vargaSentence}
/>
)}
{stackedThinkingAndAnswer ? (
<div className="message-stage-and-answer">
{thinkingPanel}
<section className="consultation-report-analysis" aria-label="回复">
{spokenAnswer}
</section>
</div>
) : (
<>
{thinkingPanel}
{spokenAnswer}
</>
)}
</>
)
) : <p>{message.text}</p>}
</div>
</div>
</article>
);
}