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-02 04:02:15 +00:00
co-authored by Claude Fable 5.1
parent 6eadb62eb7
commit be5e810ac9
13 changed files with 365 additions and 103 deletions
@@ -61,7 +61,9 @@ test("gsap loads on demand while keeping the reduced-motion gate", () => {
assert.match(messageRowSource, /prefers-reduced-motion: reduce/);
assert.match(messageRowSource, /prefers-reduced-motion: no-preference/);
assert.match(messageRowSource, /gsap\.matchMedia\(\)/);
assert.match(messageRowSource, /duration: 0\.18/);
// Former value: `duration: 0\.18`. DESIGN.md §6 gives the message entrance 160ms and the CSS
// keyframe that used to run alongside was 160ms too; 0.18 was the GSAP copy drifting.
assert.match(messageRowSource, /duration: 0\.16/);
assert.match(messageRowSource, /clearProps: "opacity,transform,visibility"/);
// And: a row that mounts before the chunk lands renders unanimated instead of flashing.
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { ConsultationRunTimeline, timelineSummaryLabel } from "../src/components/consultation-run-timeline.tsx";
import { latestAssistantView, type ChatMessage } from "../src/lib/chat-message-view.ts";
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
const transcriptSource = read("src/components/chat-transcript.tsx");
const timelineSource = read("src/components/consultation-run-timeline.tsx");
const messageRowSource = read("src/components/chat-message-row.tsx");
const globalStyles = read("src/app/globals.css");
test("the trailing assistant reply keeps one view identity from first token to settlement", () => {
const history: ChatMessage[] = [{ role: "user", text: "问题" }];
const streaming = latestAssistantView(history, true, "完整答案");
const settled = latestAssistantView([...history, { role: "assistant", text: "完整答案" }], false, "");
assert.ok(streaming && settled);
assert.equal(streaming.view.renderKey, settled.view.renderKey);
assert.equal(streaming.view.state, "streaming");
assert.equal(settled.view.state, "settled");
assert.equal(streaming.views.length, 2);
assert.equal(settled.views.length, 2);
// A trailing user message means nothing is pending and nothing is latest.
assert.equal(latestAssistantView(history, false, ""), undefined);
// A settled reply still in the loading gap is rendered once, not duplicated.
const lagging = latestAssistantView([...history, { role: "assistant", text: "完整答案" }], true, "完整答案");
assert.equal(lagging?.view.state, "settled");
});
test("one component renders the latest reply in both states; nothing remounts on settle", () => {
assert.match(transcriptSource, /export const LatestAssistantEntry = memo\(/);
assert.match(transcriptSource, /<LatestAssistantEntry\s+key=\{latest\.view\.renderKey\}/);
assert.match(transcriptSource, /excludeLatestAssistant/);
assert.match(transcriptSource, /noteLatestEntryMount\(\)/);
assert.doesNotMatch(transcriptSource, /StreamingMessageEntry|SettledMessageEntry/);
// Exactly one place in the split transcript renders the trailing reply.
assert.equal(transcriptSource.match(/<LatestAssistantEntry\b/g)?.length, 1);
});
test("a message enters once, through GSAP at the documented 160ms, never twice", () => {
assert.match(messageRowSource, /duration: 0\.16/);
assert.doesNotMatch(globalStyles, /message-enter/);
const messageRule = globalStyles.match(/\n\.message \{[^}]*\}/)?.[0] ?? "";
assert.ok(messageRule, "the .message rule exists");
assert.doesNotMatch(messageRule, /animation/);
});
test("the timeline collapses in place with a 180ms height transition and honours the reader's toggle", () => {
assert.doesNotMatch(timelineSource, /key=\{live/);
assert.match(timelineSource, /const open = userOpen \?\? live/);
assert.match(timelineSource, /aria-expanded=\{open\}/);
assert.match(timelineSource, /inert=\{open \? undefined : true\}/);
assert.match(timelineSource, /className="consultation-run-timeline__summary"/);
assert.match(timelineSource, /agent-activity-status__text/);
assert.match(globalStyles, /\.consultation-run-timeline__body-wrap \{[^}]*grid-template-rows: 0fr/);
assert.match(globalStyles, /\.consultation-run-timeline__body-wrap \{[^}]*transition: grid-template-rows 180ms var\(--ease-out\)/);
assert.match(globalStyles, /\.consultation-run-timeline\.is-open > \.consultation-run-timeline__body-wrap \{[^}]*grid-template-rows: 1fr/);
assert.match(globalStyles, /\.consultation-run-timeline__summary-label \{[^}]*animation: agent-activity-status-in 120ms/);
assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.consultation-run-timeline__summary-label,\s*\.consultation-run-timeline__body-wrap,/);
assert.equal(timelineSummaryLabel([], true), "正在分析");
assert.equal(timelineSummaryLabel([{ id: "method", kind: "method", status: "done", label: "已加载方法" }], false), "已完成 1 步");
const live = renderToString(createElement(ConsultationRunTimeline, { rows: [], live: true }));
assert.match(live, /aria-expanded="true"/);
assert.match(live, /正在处理…/);
assert.match(live, /inline-spinner/);
const settled = renderToString(createElement(ConsultationRunTimeline, {
rows: [{ id: "method", kind: "method", status: "done", label: "已加载方法" }],
live: false,
}));
assert.match(settled, /aria-expanded="false"/);
assert.match(settled, /inert=""/);
assert.match(settled, /已完成 1 步/);
assert.equal(renderToString(createElement(ConsultationRunTimeline, { rows: [], live: false })), "");
});
@@ -54,7 +54,6 @@ const tailwindCollisions = new Set([
const knownUnstyled = new Set([
"birth-time-evidence-receipt",
"chart-nav",
"consultation-run-timeline__summary",
"consultation-step-tree",
"is-changed",
"is-done",
@@ -4,15 +4,15 @@ import { renderToString } from "react-dom/server";
import test from "node:test";
import {
LatestAssistantEntry,
SettledMessageList,
StreamingMessageEntry,
UnsplitChatTranscript,
type ChatTranscriptActions,
type ChatTranscriptProps,
} from "../src/components/chat-transcript.tsx";
import type { ChatMessage } from "../src/lib/chat-message-view.ts";
import { createStreamFrameBuffer, type StreamFrameScheduler } from "../src/lib/stream-frame-buffer.ts";
import { streamingChatMessageView } from "../src/lib/chat-message-view.ts";
import { settledChatMessageViews, streamingChatMessageView } from "../src/lib/chat-message-view.ts";
import {
disableHomeStreamingRenderProbe,
enableHomeStreamingRenderProbe,
@@ -58,7 +58,14 @@ test("the split architecture renders settled history once while streaming tokens
for (const streamingText of tokens) {
const streamingMessage = streamingChatMessageView(messages, true, streamingText);
assert.ok(streamingMessage);
renderToString(createElement(StreamingMessageEntry, { message: streamingMessage }));
renderToString(createElement(LatestAssistantEntry, {
...base,
loading: true,
message: streamingMessage,
views: [...settledChatMessageViews(messages), streamingMessage],
index: messages.length,
cancellationPending: false,
}));
}
const split = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
@@ -103,7 +110,13 @@ test("frame coalescing renders the streaming row once per frame, not once per to
flush: (frame) => {
const streamingMessage = streamingChatMessageView(messages, true, frame.answer);
assert.ok(streamingMessage);
renderToString(createElement(StreamingMessageEntry, { message: streamingMessage }));
renderToString(createElement(LatestAssistantEntry, {
...propsFor(messages),
loading: true,
message: streamingMessage,
views: [...settledChatMessageViews(messages), streamingMessage],
index: messages.length,
}));
},
});
// 200 one-character tokens arrive four per frame across fifty frames.
@@ -25,7 +25,9 @@ test("keeps onboarding transcript and intake card on the same session column", (
test("keeps message motion restrained and honors reduced-motion preferences", () => {
assert.match(messageRowSource, /gsap\.matchMedia\(\)/);
assert.match(messageRowSource, /prefers-reduced-motion:\s*no-preference/);
assert.match(messageRowSource, /duration:\s*0\.18/);
// Former value: `duration:\s*0\.18`. DESIGN.md §6 gives the message entrance 160ms; 0.18 was
// the GSAP copy drifting from the (now removed) 160ms CSS keyframe that ran alongside it.
assert.match(messageRowSource, /duration:\s*0\.16/);
assert.match(messageRowSource, /ease:\s*"cubic-bezier\(\.22,\s*1,\s*\.36,\s*1\)"/);
assert.match(messageRowSource, /clearProps:\s*"opacity,transform,visibility"/);
});