From be5e810ac918e0ff5c3ff779a0b1b81e86e2da7a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 1 Sep 2026 23:05:55 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- docs/BUG_HISTORY.md | 32 ++++ .../home-streaming-render-benchmark.mts | 12 +- frontend/src/app/globals.css | 40 ++++- frontend/src/components/chat-message-row.tsx | 2 +- frontend/src/components/chat-transcript.tsx | 156 ++++++++++-------- .../components/consultation-run-timeline.tsx | 73 ++++++-- frontend/src/lib/chat-message-view.ts | 37 +++++ .../src/lib/home-streaming-render-probe.ts | 6 + .../chat-bundle-splitting-contract.test.ts | 4 +- .../tests/chat-stream-settle-contract.test.ts | 80 +++++++++ .../class-name-definition-contract.test.ts | 1 - .../tests/home-streaming-render-split.test.ts | 21 ++- .../tests/session-conversation-layout.test.ts | 4 +- 13 files changed, 365 insertions(+), 103 deletions(-) create mode 100644 frontend/tests/chat-stream-settle-contract.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 64b141c6..8709b6a9 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7282,3 +7282,35 @@ - 相关记录:BUG-474 - 复发自:无 - 修复版本:待发布 + +## BUG-474 | 回答结算瞬间整条消息闪一下,步骤时间线从展开直接跳成折叠 + +- 状态:resolved +- 首次发现:2026-09-01 +- 最近更新:2026-09-01 +- 影响面:`chat-transcript.tsx`、`chat-message-row.tsx`、`consultation-run-timeline.tsx`、`globals.css` `.message` +- 用户现象:流式回答写完的那一刻,正在读的整条回答淡出再淡入一次;上方「正在分析」步骤块瞬间收成「已完成 N 步」,下方正文向上跳一段。 +- 触发条件:任何一次咨询流式结束。 +- 根因:流式中的最后一条由 `StreamingMessageEntry` 渲染,结算后由 `SettledMessageEntry` 渲染,两者是不同组件,`renderKey` 相同也会卸载重挂,`ChatMessageRow` 的 GSAP 入场在新挂载的 `
` 上重放;`ConsultationRunTimeline` 用 `key={live ? "live" : "settled"}` 强制重挂,原生 `
` 的 `open` 没有过渡;`.message` 上另有一份 160ms CSS 入场关键帧与 GSAP 的 180ms 叠加。 +- 修复:`latestAssistantView` 派生最后一条 assistant 视图(流式或刚结算),`LatestAssistantEntry` 一个组件、一个 key 负责两个状态;历史列表按 `excludeLatestAssistant` 排除尾条。删除 `.message` 的 CSS 入场与 `message-enter` 关键帧,GSAP 时长改为 0.16s 与 DESIGN.md §6 一致。时间线去掉 `key`,改成 `button[aria-expanded]` + `grid-template-rows 0fr→1fr` 180ms 过渡,折叠后内容 `inert`;summary 文案换行时 120ms 淡入。 +- 验证:`tests/chat-stream-settle-contract.test.ts`(视图 key 跨结算一致、只有一处渲染尾条、`.message` 无 animation、时间线过渡与 reduced-motion、live 空行「正在处理…」);`session-conversation-layout` 与 `chat-bundle-splitting-contract` 的 0.18 锁改为 0.16 并注释原值。 +- 防复发:尾条 assistant 必须由 `LatestAssistantEntry` 单点渲染;不得给时间线加随状态变化的 `key`;入场动画只允许 GSAP 一份。 +- 相关记录:BUG-473、BUG-475 +- 复发自:无 +- 修复版本:待发布 + +## BUG-475 | 流式期间步骤时间线无法折叠,请求已发出但第一个事件到达前没有任何进行中反馈 + +- 状态:resolved +- 首次发现:2026-09-01 +- 最近更新:2026-09-01 +- 影响面:`consultation-run-timeline.tsx` +- 用户现象:回答生成中点「正在分析」想收起步骤,下一个 token 又把它撑开;发送后到服务端第一条事件之间只有头像和空白。 +- 触发条件:流式期间点击时间线 summary;服务端首事件延迟超过一两秒时。 +- 根因:`
` 由 `live` 受控,用户的开合没有进入状态;`rows` 为空时组件直接返回 `null`。 +- 修复:`open = userOpen ?? live`,用户切换后记入 state,结算时若用户未动才程序折叠;`rows` 为空且 live 时渲染一条 `QUEUED_TIMELINE_ROW`(「正在处理…」+ 行内 spinner + shimmer 文案)。 +- 验证:`tests/chat-stream-settle-contract.test.ts`。 +- 防复发:时间线开合必须以用户选择优先;live 状态下不得渲染空壳。 +- 相关记录:BUG-474 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/scripts/home-streaming-render-benchmark.mts b/frontend/scripts/home-streaming-render-benchmark.mts index 075ab1a2..c8946ba9 100644 --- a/frontend/scripts/home-streaming-render-benchmark.mts +++ b/frontend/scripts/home-streaming-render-benchmark.mts @@ -5,14 +5,14 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; 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 { streamingChatMessageView } from "../src/lib/chat-message-view.ts"; +import { settledChatMessageViews, streamingChatMessageView } from "../src/lib/chat-message-view.ts"; import { disableHomeStreamingRenderProbe, enableHomeStreamingRenderProbe, @@ -120,7 +120,13 @@ function runSplitArchitecture(name: string, messages: readonly ChatMessage[], re for (const streamingText of tokens) { const streamingMessage = streamingChatMessageView(messages, true, streamingText); if (streamingMessage) { - renderToString(createElement(StreamingMessageEntry, { message: streamingMessage })); + renderToString(createElement(LatestAssistantEntry, { + ...props, + loading: true, + message: streamingMessage, + views: [...settledChatMessageViews(messages), streamingMessage], + index: messages.length, + })); } } const elapsedMs = performance.now() - started; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 2e4c5a16..6655e9d4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -545,7 +545,6 @@ button:disabled { cursor: default; opacity: .45; } @keyframes app-loading-orbit { to { transform: rotate(360deg); } } @keyframes inline-spin { to { transform: rotate(360deg); } } -@keyframes message-enter { from { opacity: 0; transform: translateY(4px); } } @keyframes onboarding-card-enter { from { opacity: 0; transform: translateY(6px); } } @keyframes onboarding-caret { 50% { opacity: 0; } } @keyframes account-overlay-enter { from { opacity: 0; } } @@ -1034,7 +1033,7 @@ button:disabled { cursor: default; opacity: .45; } min-height: 40vh; color: var(--color-ink-secondary); } -.message { display: flex; animation: message-enter 160ms var(--ease-out) both; padding: var(--space-2) 0; } +.message { display: flex; padding: var(--space-2) 0; } .agent-avatar { width: 32px; height: 32px; display: block; flex: 0 0 32px; margin-top: var(--space-2); border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); } .message-content { min-width: 0; max-width: min(80%, 680px); } .message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); } @@ -1133,7 +1132,7 @@ button:disabled { cursor: default; opacity: .45; } .consultation-run-timeline { min-width: 0; } -.consultation-run-timeline > summary, +.consultation-run-timeline__summary, .consultation-run-timeline__details > summary { display: grid; grid-template-columns: minmax(0, 1fr) 20px; @@ -1144,12 +1143,40 @@ button:disabled { cursor: default; opacity: .45; } list-style: none; color: var(--color-ink-tertiary); } -.consultation-run-timeline > summary::-webkit-details-marker, +.consultation-run-timeline__summary { + width: 100%; + margin: 0; + padding: 0; + border: 0; + background: none; + font: inherit; + text-align: left; +} +.consultation-run-timeline__summary:focus-visible { + border-radius: var(--radius-sm); + outline: 2px solid var(--color-focus); + outline-offset: 3px; +} .consultation-run-timeline__details > summary::-webkit-details-marker { display: none; } .consultation-run-timeline__summary-label { min-width: 0; + animation: agent-activity-status-in 120ms ease-out; +} +/* Open ↔ closed is a 180ms height transition on the same element, so settling + never swaps the timeline out from under the reader. */ +.consultation-run-timeline__body-wrap { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 180ms var(--ease-out); +} +.consultation-run-timeline.is-open > .consultation-run-timeline__body-wrap { + grid-template-rows: 1fr; +} +.consultation-run-timeline__body-inner { + min-height: 0; + overflow: hidden; } .consultation-run-timeline__list { margin-top: var(--space-2); @@ -1184,7 +1211,7 @@ button:disabled { cursor: default; opacity: .45; } color: var(--color-ink-tertiary); transition: transform 160ms ease; } -.consultation-run-timeline[open] > summary > .consultation-run-timeline__chevron, +.consultation-run-timeline.is-open > .consultation-run-timeline__summary > .consultation-run-timeline__chevron, .consultation-run-timeline__details[open] > summary > .consultation-run-timeline__chevron { transform: rotate(180deg); } @@ -1221,7 +1248,8 @@ button:disabled { cursor: default; opacity: .45; } white-space: pre-wrap; } @media (prefers-reduced-motion: reduce) { - .consultation-run-timeline__spinner, + .consultation-run-timeline__summary-label, + .consultation-run-timeline__body-wrap, .consultation-run-timeline__chevron { animation: none; transition: none; diff --git a/frontend/src/components/chat-message-row.tsx b/frontend/src/components/chat-message-row.tsx index 401cc4cd..0bffd982 100644 --- a/frontend/src/components/chat-message-row.tsx +++ b/frontend/src/components/chat-message-row.tsx @@ -87,7 +87,7 @@ export function ChatMessageRow({ }, { autoAlpha: 1, clearProps: "opacity,transform,visibility", - duration: 0.18, + duration: 0.16, ease: "cubic-bezier(.22, 1, .36, 1)", y: 0, }); diff --git a/frontend/src/components/chat-transcript.tsx b/frontend/src/components/chat-transcript.tsx index a15b028e..6dc16186 100644 --- a/frontend/src/components/chat-transcript.tsx +++ b/frontend/src/components/chat-transcript.tsx @@ -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; }>; -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; -}>) { - 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({ /> ); +} + +const HistoryMessageEntry = memo(function HistoryMessageEntry(props: MessageEntryProps) { + noteSettledRowRender(); + return ; }); +/** + * 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) { +}: Omit & { + 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) => ( - ( + ) { +/** The trailing assistant reply, streaming or settled, under one React identity. */ +export const LatestAssistantEntry = memo(function LatestAssistantEntry(props: MessageEntryProps) { noteStreamingRowRender(); - return ( -
- -
- ); + useEffect(() => { + noteLatestEntryMount(); + }, []); + return ; }); 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({ <> - {streamingMessage ? : null} + {latest ? ( + + ) : 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 ( -
- - {showActions && ( - actionsRef.current.onFeedback(feedbackKey, requested)} - onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)} - onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)} - /> - )} - actionsRef.current.onFollowUp(question)} - /> -
+ ); })} diff --git a/frontend/src/components/consultation-run-timeline.tsx b/frontend/src/components/consultation-run-timeline.tsx index bf74195d..633ef3ba 100644 --- a/frontend/src/components/consultation-run-timeline.tsx +++ b/frontend/src/components/consultation-run-timeline.tsx @@ -1,5 +1,6 @@ "use client"; +import { useId, useState } from "react"; import { BookOpen, Check, ChevronDown, Layers, ListTodo, LoaderCircle, PenLine, type LucideIcon } from "lucide-react"; import { InlineSpinner } from "@/components/inline-spinner"; @@ -18,6 +19,23 @@ const KIND_ICONS: Record = { write: PenLine, }; +/** Shown while the request is in flight but no step has been reported yet. */ +export const QUEUED_TIMELINE_ROW: ConsultationTimelineRow = { + id: "queued", + kind: "method", + status: "live", + label: "正在处理…", +}; + +export function timelineSummaryLabel(rows: readonly ConsultationTimelineRow[], live: boolean): string { + return live ? "正在分析" : `已完成 ${rows.length} 步`; +} + +/** + * The step timeline of one assistant reply. Open by default while live and + * closed once settled; a reader's own toggle wins over both. The element keeps + * its identity across settlement so the collapse is a transition, not a swap. + */ export function ConsultationRunTimeline({ rows, live = false, @@ -25,26 +43,43 @@ export function ConsultationRunTimeline({ rows: readonly ConsultationTimelineRow[]; live?: boolean; }>) { - if (rows.length === 0) return null; + const bodyId = useId(); + const [userOpen, setUserOpen] = useState(null); + const visibleRows = rows.length === 0 && live ? [QUEUED_TIMELINE_ROW] : rows; + if (visibleRows.length === 0) return null; + const open = userOpen ?? live; + const summary = timelineSummaryLabel(rows, live); return ( -
- - - {live ? "正在分析" : `已完成 ${rows.length} 步`} + -
    - {rows.map((row) => ( - - ))} -
-
+ +
+
+
    + {visibleRows.map((row) => ( + + ))} +
+
+
+ ); } @@ -68,7 +103,13 @@ function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) { const label = ( ); diff --git a/frontend/src/lib/chat-message-view.ts b/frontend/src/lib/chat-message-view.ts index 91e0363f..1db38241 100644 --- a/frontend/src/lib/chat-message-view.ts +++ b/frontend/src/lib/chat-message-view.ts @@ -106,6 +106,43 @@ export function streamingChatMessageView( }; } +export type LatestAssistantView = Readonly<{ + /** The trailing assistant reply: the live stream, or the last settled answer. */ + view: ChatMessageView; + /** Every view in order, ending with `view`, for follow-up and regenerate lookups. */ + views: readonly ChatMessageView[]; +}>; + +/** + * The trailing assistant reply keeps one render key from its first streamed + * token through settlement (`message-` in both cases), so the same + * component instance can carry it across the transition without remounting. + */ +export function latestAssistantView( + messages: readonly ChatMessage[], + loading: boolean, + streamingText: string, + activity?: AgentActivityView, + thinkingText?: string, + thinkingSections?: readonly PublicThinkingSection[], + timeline?: readonly ConsultationTimelineRow[], +): LatestAssistantView | undefined { + const settled = settledChatMessageViews(messages); + const streaming = streamingChatMessageView( + messages, + loading, + streamingText, + activity, + thinkingText, + thinkingSections, + timeline, + ); + if (streaming) return { view: streaming, views: [...settled, streaming] }; + const last = settled.at(-1); + if (!last || last.role !== "assistant") return undefined; + return { view: last, views: settled }; +} + export function chatMessageViews( messages: readonly ChatMessage[], loading: boolean, diff --git a/frontend/src/lib/home-streaming-render-probe.ts b/frontend/src/lib/home-streaming-render-probe.ts index 81963d1c..6d94033e 100644 --- a/frontend/src/lib/home-streaming-render-probe.ts +++ b/frontend/src/lib/home-streaming-render-probe.ts @@ -3,6 +3,7 @@ export type HomeStreamingRenderProbeSnapshot = Readonly<{ settledRowRenders: number; streamingRowRenders: number; unsplitListRenders: number; + latestEntryMounts: number; }>; const emptySnapshot = (): HomeStreamingRenderProbeSnapshot => ({ @@ -10,6 +11,7 @@ const emptySnapshot = (): HomeStreamingRenderProbeSnapshot => ({ settledRowRenders: 0, streamingRowRenders: 0, unsplitListRenders: 0, + latestEntryMounts: 0, }); let enabled = false; @@ -48,3 +50,7 @@ export function noteStreamingRowRender() { export function noteUnsplitListRender() { if (enabled) snapshot = { ...snapshot, unsplitListRenders: snapshot.unsplitListRenders + 1 }; } + +export function noteLatestEntryMount() { + if (enabled) snapshot = { ...snapshot, latestEntryMounts: snapshot.latestEntryMounts + 1 }; +} diff --git a/frontend/tests/chat-bundle-splitting-contract.test.ts b/frontend/tests/chat-bundle-splitting-contract.test.ts index aa334bdb..bd639da4 100644 --- a/frontend/tests/chat-bundle-splitting-contract.test.ts +++ b/frontend/tests/chat-bundle-splitting-contract.test.ts @@ -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. diff --git a/frontend/tests/chat-stream-settle-contract.test.ts b/frontend/tests/chat-stream-settle-contract.test.ts new file mode 100644 index 00000000..273f5c9a --- /dev/null +++ b/frontend/tests/chat-stream-settle-contract.test.ts @@ -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, / { + 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 })), ""); +}); diff --git a/frontend/tests/class-name-definition-contract.test.ts b/frontend/tests/class-name-definition-contract.test.ts index f68f9e38..db0ee295 100644 --- a/frontend/tests/class-name-definition-contract.test.ts +++ b/frontend/tests/class-name-definition-contract.test.ts @@ -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", diff --git a/frontend/tests/home-streaming-render-split.test.ts b/frontend/tests/home-streaming-render-split.test.ts index ccd16d20..239218f3 100644 --- a/frontend/tests/home-streaming-render-split.test.ts +++ b/frontend/tests/home-streaming-render-split.test.ts @@ -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. diff --git a/frontend/tests/session-conversation-layout.test.ts b/frontend/tests/session-conversation-layout.test.ts index 6dc7f197..6426f60b 100644 --- a/frontend/tests/session-conversation-layout.test.ts +++ b/frontend/tests/session-conversation-layout.test.ts @@ -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"/); });