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
This commit is contained in:
Jesse_Chen
2026-09-01 23:05:55 +00:00
parent 6eadb62eb7
commit be5e810ac9
13 changed files with 365 additions and 103 deletions
+86 -70
View File
@@ -11,16 +11,21 @@ import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint";
import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups";
import type { ConsultationDomain } from "@/lib/consultation-domain-registry";
import type { AgentActivityView, ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import { chatMessageViews, settledChatMessageViews, streamingChatMessageView } from "@/lib/chat-message-view";
import {
chatMessageViews,
latestAssistantView,
settledChatMessageViews,
} from "@/lib/chat-message-view";
import type { PublicThinkingSection } from "@/lib/consultation-thinking-plan";
import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline";
import {
noteLatestEntryMount,
noteSettledListRender,
noteSettledRowRender,
noteStreamingRowRender,
noteUnsplitListRender,
} from "@/lib/home-streaming-render-probe";
import { memo, type MutableRefObject } from "react";
import { memo, useEffect, type MutableRefObject } from "react";
export type ChatTranscriptActions = Readonly<{
onFeedback: (feedbackKey: string, requested: ChatMessageFeedback) => void;
@@ -47,20 +52,7 @@ export type ChatTranscriptProps = Readonly<{
actionsRef: MutableRefObject<ChatTranscriptActions>;
}>;
const SettledMessageEntry = memo(function SettledMessageEntry({
message,
views,
index,
sessionId,
sessionType,
theme,
messageFeedback,
copiedMessageKey,
loading,
cancellationPending,
productEntrypointsDisabled,
actionsRef,
}: Readonly<{
type MessageEntryProps = Readonly<{
message: ChatMessageView;
views: readonly ChatMessageView[];
index: number;
@@ -73,8 +65,27 @@ const SettledMessageEntry = memo(function SettledMessageEntry({
cancellationPending: boolean;
productEntrypointsDisabled: boolean;
actionsRef: MutableRefObject<ChatTranscriptActions>;
}>) {
noteSettledRowRender();
}>;
/**
* One transcript row. The same component renders history rows, the row that is
* still streaming and the row that just settled: actions and follow-ups appear
* once the view is settled, nothing remounts when it does.
*/
function MessageEntry({
message,
views,
index,
sessionId,
sessionType,
theme,
messageFeedback,
copiedMessageKey,
loading,
cancellationPending,
productEntrypointsDisabled,
actionsRef,
}: MessageEntryProps) {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
@@ -116,10 +127,21 @@ const SettledMessageEntry = memo(function SettledMessageEntry({
/>
</div>
);
}
const HistoryMessageEntry = memo(function HistoryMessageEntry(props: MessageEntryProps) {
noteSettledRowRender();
return <MessageEntry {...props} />;
});
/**
* History rows: every settled message except the trailing assistant reply,
* which `LatestAssistantEntry` owns so it keeps one identity from the first
* streamed token through settlement.
*/
export const SettledMessageList = memo(function SettledMessageList({
messages,
excludeLatestAssistant = false,
sessionId,
sessionType,
theme,
@@ -129,13 +151,16 @@ export const SettledMessageList = memo(function SettledMessageList({
cancellationPending,
productEntrypointsDisabled,
actionsRef,
}: Omit<ChatTranscriptProps, "streamingText" | "streamingActivity" | "streamingThinking" | "streamingSections" | "streamingTimeline">) {
}: Omit<ChatTranscriptProps, "streamingText" | "streamingActivity" | "streamingThinking" | "streamingSections" | "streamingTimeline"> & {
excludeLatestAssistant?: boolean;
}) {
noteSettledListRender();
const views = settledChatMessageViews(messages);
const history = excludeLatestAssistant && views.at(-1)?.role === "assistant" ? views.slice(0, -1) : views;
return (
<>
{views.map((message, index) => (
<SettledMessageEntry
{history.map((message, index) => (
<HistoryMessageEntry
key={message.renderKey}
message={message}
views={views}
@@ -155,15 +180,13 @@ export const SettledMessageList = memo(function SettledMessageList({
);
});
export const StreamingMessageEntry = memo(function StreamingMessageEntry({
message,
}: Readonly<{ message: ChatMessageView }>) {
/** The trailing assistant reply, streaming or settled, under one React identity. */
export const LatestAssistantEntry = memo(function LatestAssistantEntry(props: MessageEntryProps) {
noteStreamingRowRender();
return (
<div className="message-entry">
<ChatMessageRow message={message} />
</div>
);
useEffect(() => {
noteLatestEntryMount();
}, []);
return <MessageEntry {...props} />;
});
export const ChatTranscript = memo(function ChatTranscript({
@@ -183,7 +206,7 @@ export const ChatTranscript = memo(function ChatTranscript({
productEntrypointsDisabled,
actionsRef,
}: ChatTranscriptProps) {
const streamingMessage = streamingChatMessageView(
const latest = latestAssistantView(
messages,
loading,
streamingText,
@@ -197,6 +220,7 @@ export const ChatTranscript = memo(function ChatTranscript({
<>
<SettledMessageList
messages={messages}
excludeLatestAssistant
loading={loading}
sessionId={sessionId}
sessionType={sessionType}
@@ -207,7 +231,23 @@ export const ChatTranscript = memo(function ChatTranscript({
productEntrypointsDisabled={productEntrypointsDisabled}
actionsRef={actionsRef}
/>
{streamingMessage ? <StreamingMessageEntry message={streamingMessage} /> : null}
{latest ? (
<LatestAssistantEntry
key={latest.view.renderKey}
message={latest.view}
views={latest.views}
index={latest.views.length - 1}
sessionId={sessionId}
sessionType={sessionType}
theme={theme}
messageFeedback={messageFeedback}
copiedMessageKey={copiedMessageKey}
loading={loading}
cancellationPending={cancellationPending}
productEntrypointsDisabled={productEntrypointsDisabled}
actionsRef={actionsRef}
/>
) : null}
</>
);
});
@@ -244,46 +284,22 @@ export function UnsplitChatTranscript({
{views.map((message, index) => {
noteSettledRowRender();
if (message.state !== "settled") noteStreamingRowRender();
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
const feedbackKey = `${sessionId}:${message.renderKey}`;
const latestRegeneratableKey = !loading && !cancellationPending
? [...views].reverse().find((item) => (
item.role === "assistant" && item.state === "settled" && Boolean(item.text)
))?.renderKey
: undefined;
const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : "";
const followUps = showActions
&& message.renderKey === latestRegeneratableKey
&& sessionType === "consultation"
&& previousQuestion
? deriveConsultationFollowUps({
question: previousQuestion,
answer: message.text,
theme,
entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null,
})
: [];
return (
<div className="message-entry" key={message.renderKey}>
<ChatMessageRow message={message} />
{showActions && (
<ChatMessageActions
feedback={messageFeedback[feedbackKey]}
copied={copiedMessageKey === feedbackKey}
canRegenerate={message.renderKey === latestRegeneratableKey}
onFeedback={(requested) => actionsRef.current.onFeedback(feedbackKey, requested)}
onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)}
onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)}
/>
)}
<ConversationFollowUps
questions={followUps}
disabled={productEntrypointsDisabled}
onSelect={(question) => actionsRef.current.onFollowUp(question)}
/>
</div>
<MessageEntry
key={message.renderKey}
message={message}
views={views}
index={index}
sessionId={sessionId}
sessionType={sessionType}
theme={theme}
messageFeedback={messageFeedback}
copiedMessageKey={copiedMessageKey}
loading={loading}
cancellationPending={cancellationPending}
productEntrypointsDisabled={productEntrypointsDisabled}
actionsRef={actionsRef}
/>
);
})}
</>