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"/); });