From 6eadb62eb769d6dd4c9bc64ea345b6e18007f0a4 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 1 Sep 2026 21:27:15 +0000 Subject: [PATCH 1/9] perf(chat): coalesce stream events per frame and pace text release Every NDJSON event used to commit its own React update and re-parse the whole partial answer through react-markdown, so long replies grew quadratically slower. Stream events now land in a frame buffer that flushes at most once per animation frame, releases answer and thinking text at a steady pace with a twelve-frame catch-up, and settles synchronously on completion, failure and abort. Streaming markdown is split at the last completed block so only the tail is re-parsed each frame. Applied to both the consultation hook and the rectification chat. BUG-473 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- docs/BUG_HISTORY.md | 16 ++ .../src/components/chat-message-content.tsx | 57 ++++- frontend/src/components/chat-message-row.tsx | 1 + .../components/rectification-agentic-chat.tsx | 106 +++++---- frontend/src/hooks/use-consultation-run.ts | 79 ++++--- frontend/src/lib/chat-markdown-split.ts | 69 ++++++ frontend/src/lib/stream-frame-buffer.ts | 212 ++++++++++++++++++ frontend/tests/chat-markdown-split.test.ts | 74 ++++++ .../tests/home-streaming-render-split.test.ts | 47 +++- frontend/tests/stream-frame-buffer.test.ts | 210 +++++++++++++++++ 10 files changed, 778 insertions(+), 93 deletions(-) create mode 100644 frontend/src/lib/chat-markdown-split.ts create mode 100644 frontend/src/lib/stream-frame-buffer.ts create mode 100644 frontend/tests/chat-markdown-split.test.ts create mode 100644 frontend/tests/stream-frame-buffer.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 13f0ec14..64b141c6 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7266,3 +7266,19 @@ - 相关记录:BUG-453、BUG-463 - 复发自:BUG-463(idle 不再追问,拒答路径与 occupation blocking 仍挡住采用卡) - 修复版本:待发布 + +## BUG-473 | 流式回答每个网络事件都全量重渲染,长回答越写越卡 + +- 状态:resolved +- 首次发现:2026-09-01 +- 最近更新:2026-09-01 +- 影响面:`use-consultation-run.ts` NDJSON 回调、`rectification-agentic-chat.tsx` `send` 循环、`chat-message-content.tsx` +- 用户现象:回答越长越卡,文字一坨一坨蹦出来而不是流出来;思考步骤更新时整条消息跟着抖。 +- 触发条件:任何一次流式咨询或校正回合,回答超过一两千字后明显。 +- 根因:服务端每个模型 text-delta 立即发一条 `answer.delta`,客户端每条事件各自 `setStreamingReply` / `setMessages`;每次提交都对整段部分回答跑 `parseAgentReply`、`applyThinkingSectionProgress`、`splitSpokenAnswerAndTechniqueAudit` 和 `react-markdown` 全量 parse,随回答长度呈平方级增长。 +- 修复:新增 `lib/stream-frame-buffer.ts`,所有流式事件先落到累加器,`requestAnimationFrame` 合并成每帧最多一次提交;正文与思考文本按 `max(2, ceil(积压/12))` 每帧匀速释放,突发积压约十二帧追平,页面隐藏时退化为 250ms 定时并一次放完,`run.completed`/失败/中止时同步冲干净。`chat-message-content.tsx` 在流式时把已完成段落(最后一个空行之前,不切进代码围栏、列表项之间或表格)交给按内容记忆的前缀组件,只有尾块每帧重 parse;结算后仍整篇一次 parse。 +- 验证:`tests/stream-frame-buffer.test.ts`(释放公式、200 token/50 帧只提交 50 次、突发 12 帧追平、隐藏退化、reset/dispose)、`tests/chat-markdown-split.test.ts`(切分规则、前缀稳定、前缀与尾块分开 parse)、`tests/home-streaming-render-split.test.ts` 新增按帧驱动的渲染次数上限。 +- 防复发:流式状态必须经 `stream-frame-buffer` 提交,不得在事件回调里直接 `setState`;流式期间的 Markdown 渲染必须走前缀/尾块切分。 +- 相关记录:BUG-474 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/src/components/chat-message-content.tsx b/frontend/src/components/chat-message-content.tsx index bec2ea9d..24cafa41 100644 --- a/frontend/src/components/chat-message-content.tsx +++ b/frontend/src/components/chat-message-content.tsx @@ -1,10 +1,11 @@ "use client"; -import { useEffect, useState, type ReactNode } from "react"; +import { memo, useEffect, useState, type ReactNode } from "react"; import { prefetchOnIdle } from "@/components/chat-chunk-prefetch"; import { plainParagraphs } from "@/components/chat-message-paragraphs"; import { TechniqueAuditDisclosure } from "@/components/technique-audit-disclosure"; +import { splitStableMarkdown } from "@/lib/chat-markdown-split"; import type { TechniqueAuditRow } from "@/lib/consultation-agent-events"; import { resolveTechniqueAuditRows, @@ -41,14 +42,56 @@ function useMarkdownRenderer() { return renderer; } +function renderProse(text: string, renderMarkdown: MarkdownRenderer | undefined): ReactNode { + if (!text) return null; + return renderMarkdown + ? renderMarkdown(text) + : (plainParagraphs(text) ?? []).map((paragraph, index) => ( +

{paragraph}

+ )); +} + +/** + * The completed part of a streaming answer. `memo` keeps React from calling the + * markdown parser again while `text` is unchanged, so a frame that only grew + * the tail costs one small parse instead of one over the whole answer. + */ +const StableMarkdownPrefix = memo(function StableMarkdownPrefix({ + text, + renderMarkdown, +}: Readonly<{ + text: string; + renderMarkdown: MarkdownRenderer | undefined; +}>) { + return <>{renderProse(text, renderMarkdown)}; +}); + +export function StreamingMarkdown({ + text, + renderMarkdown, +}: Readonly<{ + text: string; + renderMarkdown: MarkdownRenderer | undefined; +}>) { + const split = splitStableMarkdown(text); + return ( + <> + {split.stable ? : null} + {renderProse(split.tail, renderMarkdown)} + + ); +} + export function ChatMessageContent({ text, auditRows, vargaSentence, + streaming = false, }: { text: string; auditRows?: readonly TechniqueAuditRow[]; vargaSentence?: string | null; + streaming?: boolean; }) { const renderMarkdown = useMarkdownRenderer(); const split = splitSpokenAnswerAndTechniqueAudit(text); @@ -60,11 +103,13 @@ export function ChatMessageContent({
{spoken ? (
- {renderMarkdown - ? renderMarkdown(spoken) - : (plainParagraphs(spoken) ?? []).map((paragraph, index) => ( -

{paragraph}

- ))} + {streaming + ? + : renderMarkdown + ? renderMarkdown(spoken) + : (plainParagraphs(spoken) ?? []).map((paragraph, index) => ( +

{paragraph}

+ ))}
) : null} {vargaSentence ?

{vargaSentence}

: null} diff --git a/frontend/src/components/chat-message-row.tsx b/frontend/src/components/chat-message-row.tsx index 03d0a1c2..401cc4cd 100644 --- a/frontend/src/components/chat-message-row.tsx +++ b/frontend/src/components/chat-message-row.tsx @@ -116,6 +116,7 @@ export function ChatMessageRow({ text={message.text} auditRows={message.agentExecutionReceipt?.techniqueAuditTable} vargaSentence={showThinkingPanel ? null : vargaSentence} + streaming={message.state !== "settled"} /> ) : null; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 1872245f..563b70f0 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -4,7 +4,8 @@ import { ArrowUp, Square } from "lucide-react"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { parseAgentReply } from "@/lib/agent-reply"; -import { nextActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view"; +import { nextActivityView, type AgentActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view"; +import { createStreamFrameBuffer } from "@/lib/stream-frame-buffer"; import { completeActivityTrace, completeActivityTraceStep, @@ -494,6 +495,30 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { let activityReceiptState = createRectificationActivityReceiptState(); let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); let completedTurnId: string | undefined; + let currentActivity: AgentActivityView | undefined = { + phase: "evidence-validation", + label: "正在处理…", + startedAt: Date.now(), + }; + // Every stream event lands in `frames`; it commits at most once per animation + // frame and releases text at a steady pace. The loop below never calls + // setMessages for a live turn directly except on `attempt.reset`. + const frames = createStreamFrameBuffer({ + initialMeta: null, + flush: (frame) => { + const text = frame.answer; + setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey + ? { + ...message, + text, + activityTrace, + completedReceipt, + state: text.trim() ? "streaming" : "thinking", + activity: currentActivity, + } + : message)); + }, + }); const abortController = new AbortController(); runAbort.current = abortController; try { @@ -569,35 +594,29 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { if (event.type === "answer.delta" && typeof event.text === "string") { raw = event.replace === true ? event.text : raw + event.text; activityTrace = freezeLiveThink(activityTrace); - setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey - ? { - ...message, - text: raw, - activityTrace, - state: raw.trim() ? "streaming" : "thinking", - activity: nextActivityView(message.activity, { - phase: "answer-composition", - label: "正在组织回答…", - }), - } - : message)); + currentActivity = nextActivityView(currentActivity, { + phase: "answer-composition", + label: "正在组织回答…", + }); + frames.setAnswer(raw); } else if (event.type === "activity.changed" && isPublicRectificationActivity(event.activity)) { const activity = event.activity; - setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey - ? { - ...message, - activity: nextActivityView(message.activity, { - phase: "evidence-validation", - label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity], - }), - } - : message)); + currentActivity = nextActivityView(currentActivity, { + phase: "evidence-validation", + label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity], + }); + frames.touch(); } else if (event.type === "attempt.reset") { raw = ""; activityTrace = emptyActivityTrace(); activityReceiptState = createRectificationActivityReceiptState(); completedReceipt = receiptFromRectificationActivityState(activityReceiptState); completedTurnId = undefined; + currentActivity = nextActivityView(undefined, { + phase: "evidence-validation", + label: "正在处理…", + }); + frames.reset(); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, @@ -608,10 +627,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { completedReceipt: undefined, failed: false, turnId: undefined, - activity: nextActivityView(undefined, { - phase: "evidence-validation", - label: "正在处理…", - }), + activity: currentActivity, } : message)); } else if (event.type === "run.failed") { @@ -634,17 +650,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { tool, RECTIFICATION_TOOL_PROGRESS_LABELS[tool], ); - setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey - ? { - ...message, - activityTrace, - activity: nextActivityView(message.activity, { - phase: rectificationToolActivityPhase(tool), - label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool], - completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), - }), - } - : message)); + currentActivity = nextActivityView(currentActivity, { + phase: rectificationToolActivityPhase(tool), + label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool], + completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), + }); + frames.touch(); continue; } if (event.status !== "completed" && event.status !== "failed") continue; @@ -661,21 +672,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { : [], }); completedReceipt = receiptFromRectificationActivityState(activityReceiptState); - setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey - ? { - ...message, - activityTrace, - completedReceipt, - activity: nextActivityView(message.activity, { - phase: rectificationToolActivityPhase(tool), - label: RECTIFICATION_TOOL_DONE_LABELS[tool], - completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), - }), - } - : message)); + currentActivity = nextActivityView(currentActivity, { + phase: rectificationToolActivityPhase(tool), + label: RECTIFICATION_TOOL_DONE_LABELS[tool], + completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), + }); + frames.touch(); } } } + frames.settle(); const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined }; const succeeded = completed && !streamFailed && Boolean(parsed.text); @@ -722,6 +728,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { await loadCaseSnapshot(); } } catch (caught) { + frames.settle(); const aborted = caught instanceof DOMException ? caught.name === "AbortError" : caught instanceof Error && caught.name === "AbortError"; @@ -757,6 +764,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { : []; })); } finally { + frames.dispose(); if (runAbort.current === abortController) runAbort.current = null; setPending(false); } diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index 160fb1fa..b766adb2 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -22,6 +22,7 @@ import { reduceConsultationTimeline, } from "@/lib/consultation-run-timeline"; import { createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "@/lib/consultation-agent-events"; +import { createStreamFrameBuffer } from "@/lib/stream-frame-buffer"; import { applyThinkingSectionProgress, upsertThinkingSection, @@ -659,6 +660,29 @@ export function useConsultationRun(params: ConsultationRunParams) { let thinkingSections: PublicThinkingSection[] = []; let streamedThinking = ""; let timelineState = emptyConsultationTimeline(); + let currentActivity: AgentActivityView | undefined; + // Every stream event lands in `frames`; it commits at most once per animation + // frame and releases text at a steady pace. Nothing below calls + // setStreamingReply directly while the response body is being read. + const frames = createStreamFrameBuffer({ + initialMeta: null, + flush: (frame) => { + const partialReply = parseAgentReply(frame.answer).text; + latestPartialReply = partialReply; + thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply); + setStreamingReply({ + sessionId, + text: partialReply, + thinkingText: frame.thinking.trim() || undefined, + thinkingSections: thinkingSections.length ? thinkingSections : undefined, + timeline: timelineState.rows, + activity: currentActivity, + }); + if (partialReply && pendingConsultation.current?.requestId === requestId) { + pendingConsultation.current = { ...pendingConsultation.current, partialReply }; + } + }, + }); try { const response = await fetch("/api/consult", { method: "POST", @@ -721,24 +745,6 @@ export function useConsultationRun(params: ConsultationRunParams) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let answer = ""; - const updateStreamingAnswer = (activity?: AgentActivityView) => { - const partialReply = parseAgentReply(answer).text; - latestPartialReply = partialReply; - thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply); - setStreamingReply((current) => ({ - sessionId, - text: partialReply, - thinkingText: streamedThinking.trim() || undefined, - thinkingSections: thinkingSections.length ? thinkingSections : undefined, - timeline: timelineState.rows, - activity: activity - ? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity) - : current?.sessionId === sessionId ? current.activity : undefined, - })); - if (partialReply && pendingConsultation.current?.requestId === requestId) { - pendingConsultation.current = { ...pendingConsultation.current, partialReply }; - } - }; const updateActivity = (event: ConsultationAgentPublicEvent) => { let activity: AgentActivityView | undefined; if (event.type === "skill.started") { @@ -766,23 +772,23 @@ export function useConsultationRun(params: ConsultationRunParams) { } else if (event.type === "answer.delta") { activity = { phase: "answer-composition", label: CONSULTATION_COMPOSING_LABEL }; } - if (activity) updateStreamingAnswer(activity); + if (activity) { + currentActivity = nextActivityView(currentActivity, activity); + frames.touch(); + } }; if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) { const parser = createNdjsonParser((event) => { timelineState = reduceConsultationTimeline(timelineState, event); - if (event.type === "answer.delta") answer += event.text; + frames.touch(); + if (event.type === "answer.delta") { + answer += event.text; + frames.setAnswer(answer); + } if (event.type === "thinking.delta" && typeof event.text === "string") { streamedThinking += event.text; - setStreamingReply((current) => ({ - sessionId, - text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text, - thinkingText: streamedThinking.trim() || undefined, - thinkingSections: current?.sessionId === sessionId ? current.thinkingSections : thinkingSections, - timeline: timelineState.rows, - activity: current?.sessionId === sessionId ? current.activity : undefined, - })); + frames.setThinking(streamedThinking); } if (event.type === "thinking.section") { thinkingSections = applyThinkingSectionProgress( @@ -794,14 +800,6 @@ export function useConsultationRun(params: ConsultationRunParams) { }), parseAgentReply(answer).text, ); - setStreamingReply((current) => ({ - sessionId, - text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text, - thinkingText: streamedThinking.trim() || undefined, - thinkingSections, - timeline: timelineState.rows, - activity: current?.sessionId === sessionId ? current.activity : undefined, - })); } if (event.type === "run.completed") { runCompleted = true; @@ -829,6 +827,7 @@ export function useConsultationRun(params: ConsultationRunParams) { parser.push(decoder.decode(value, { stream: true })); } parser.finish(decoder.decode()); + frames.settle(); if (truncatedFailure) { const reply = parseAgentReply(answer); if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message); @@ -867,9 +866,11 @@ export function useConsultationRun(params: ConsultationRunParams) { const { done, value } = await reader.read(); if (done) break; answer += decoder.decode(value, { stream: true }); - updateStreamingAnswer(); + frames.setAnswer(answer); } answer += decoder.decode(); + frames.setAnswer(answer); + frames.settle(); } if (controller.signal.aborted) return Boolean(latestPartialReply); const reply = parseAgentReply(answer); @@ -915,6 +916,9 @@ export function useConsultationRun(params: ConsultationRunParams) { void refreshAccount(); return true; } catch (caught) { + // Whatever arrived before the failure is what gets kept, not just the + // part the pacing had released so far. + frames.settle(); const cancelled = controller.signal.aborted; const ownsInterface = pendingConsultation.current?.requestId === requestId; const partialReply = latestPartialReply; @@ -992,6 +996,7 @@ export function useConsultationRun(params: ConsultationRunParams) { } return Boolean(partialReply); } finally { + frames.dispose(); cancellationRequests.current.delete(requestId); const pending = pendingConsultation.current; if (pending?.requestId !== requestId || pending.phase !== "recovering") { diff --git a/frontend/src/lib/chat-markdown-split.ts b/frontend/src/lib/chat-markdown-split.ts new file mode 100644 index 00000000..7e73ed86 --- /dev/null +++ b/frontend/src/lib/chat-markdown-split.ts @@ -0,0 +1,69 @@ +/** + * Split streaming markdown into a stable prefix and a live tail. + * + * While an answer streams, only the tail can still change; everything before + * the last completed block is final. Rendering the prefix through a memoised + * component means each frame re-parses a paragraph, not the whole answer. + * + * The cut is only allowed at a blank line where both sides parse the same on + * their own as they would together: never inside a fenced code block, never + * between two items of the same list (a second `
    ` would add margin that + * the settled render does not have), and never inside a table or blockquote. + */ + +export type StableMarkdownSplit = Readonly<{ + stable: string; + tail: string; +}>; + +const FENCE = /^\s{0,3}(`{3,}|~{3,})/; +const LIST_ITEM = /^\s{0,3}(?:[-*+]|\d{1,9}[.)])\s/; +const INDENTED = /^\s{2,}\S/; +const TABLE_ROW = /^\s{0,3}\|/; + +function lastNonBlank(lines: readonly string[]): string | undefined { + return [...lines].reverse().find((line) => line.trim().length > 0); +} + +/** A blank line does not end a list or a table when the next block continues it. */ +function continuesPreviousBlock(previous: readonly string[], next: string): boolean { + const last = lastNonBlank(previous); + if (last === undefined) return false; + const previousIsList = LIST_ITEM.test(last) || INDENTED.test(last); + const nextIsList = LIST_ITEM.test(next) || INDENTED.test(next); + if (previousIsList && nextIsList) return true; + return TABLE_ROW.test(last) && TABLE_ROW.test(next); +} + +export function splitStableMarkdown(text: string): StableMarkdownSplit { + const lines = text.split("\n"); + let insideFence = false; + let cut = -1; + let currentBlock: string[] = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (FENCE.test(line)) insideFence = !insideFence; + if (insideFence) { + currentBlock.push(line); + continue; + } + if (line.trim().length > 0) { + currentBlock.push(line); + continue; + } + // Blank line: the block that just ended is complete only if a non-blank + // line follows later, and the cut is safe only when the next block does + // not continue the previous one. + const nextIndex = lines.findIndex((candidate, at) => at > index && candidate.trim().length > 0); + if (nextIndex < 0) break; + const next = lines[nextIndex] ?? ""; + if (currentBlock.length > 0 && !continuesPreviousBlock(currentBlock, next)) cut = index; + currentBlock = []; + } + + if (cut < 0) return { stable: "", tail: text }; + const stable = lines.slice(0, cut).join("\n"); + const tail = lines.slice(cut + 1).join("\n"); + return { stable, tail }; +} diff --git a/frontend/src/lib/stream-frame-buffer.ts b/frontend/src/lib/stream-frame-buffer.ts new file mode 100644 index 00000000..c96b2c0b --- /dev/null +++ b/frontend/src/lib/stream-frame-buffer.ts @@ -0,0 +1,212 @@ +/** + * Frame-coalesced release of streamed agent output. + * + * Every network chunk used to become its own React commit, and each commit + * re-parsed the whole partial answer. This buffer sits between the event + * parser and `setState`: events mutate an accumulator, and at most one flush + * happens per animation frame. Answer and thinking text are released at a + * steady per-frame pace so a burst of chunks reads as flowing text instead of + * a jump, while a large backlog (reconnect, slow tab) catches up in roughly a + * dozen frames. + * + * Pure release arithmetic lives in exported functions so the policy is + * testable without a DOM; scheduling is injectable for the same reason. + */ + +export const STREAM_RELEASE_MIN_CHARS = 2; +export const STREAM_RELEASE_CATCHUP_DIVISOR = 12; +export const STREAM_HIDDEN_FLUSH_MS = 250; + +/** + * Characters to reveal on one frame. `backlogChars` is how much was waiting + * when the newest text arrived: dividing that by twelve clears any burst in + * about twelve frames, while the two-character floor keeps a slow model from + * reading as stalled. Callers without a backlog figure pass the pending count. + */ +export function streamReleaseCount(pendingChars: number, backlogChars = pendingChars): number { + if (pendingChars <= 0) return 0; + return Math.min( + pendingChars, + Math.max(STREAM_RELEASE_MIN_CHARS, Math.ceil(backlogChars / STREAM_RELEASE_CATCHUP_DIVISOR)), + ); +} + +/** Advance a released prefix toward its target by one frame's worth of text. */ +export function advanceStreamRelease(released: string, target: string, backlogChars?: number): string { + if (!target.startsWith(released)) { + // The target was replaced rather than extended: restart from its head. + return target.slice(0, streamReleaseCount(target.length, backlogChars ?? target.length)); + } + const pending = target.length - released.length; + if (pending <= 0) return target; + return target.slice(0, released.length + streamReleaseCount(pending, backlogChars ?? pending)); +} + +export type StreamFrameSnapshot = Readonly<{ + answer: string; + thinking: string; + meta: Meta; + /** True when this flush released everything that had arrived. */ + settled: boolean; +}>; + +export type StreamFrameScheduler = Readonly<{ + requestFrame: (callback: () => void) => number; + cancelFrame: (handle: number) => void; + requestTimeout: (callback: () => void, delayMs: number) => number; + cancelTimeout: (handle: number) => void; + hidden: () => boolean; +}>; + +export type StreamFrameBufferOptions = Readonly<{ + initialMeta: Meta; + flush: (snapshot: StreamFrameSnapshot) => void; + scheduler?: StreamFrameScheduler; +}>; + +export type StreamFrameBuffer = Readonly<{ + setAnswer: (fullText: string) => void; + setThinking: (fullText: string) => void; + setMeta: (next: Meta | ((current: Meta) => Meta)) => void; + /** Publish meta-only changes (timeline rows, activity) on the next frame. */ + touch: () => void; + /** Release everything received and flush synchronously. */ + settle: () => void; + /** Drop everything, including scheduled work, without flushing. */ + reset: (meta?: Meta) => void; + dispose: () => void; + /** Text released so far, for callers that persist partial output. */ + released: () => Readonly<{ answer: string; thinking: string }>; +}>; + +function pendingChars(released: string, target: string): number { + return target.startsWith(released) ? target.length - released.length : target.length; +} + +function browserScheduler(): StreamFrameScheduler { + return { + requestFrame: (callback) => window.requestAnimationFrame(callback), + cancelFrame: (handle) => window.cancelAnimationFrame(handle), + requestTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs), + cancelTimeout: (handle) => window.clearTimeout(handle), + hidden: () => typeof document !== "undefined" && document.hidden, + }; +} + +export function createStreamFrameBuffer( + options: StreamFrameBufferOptions, +): StreamFrameBuffer { + const scheduler = options.scheduler ?? browserScheduler(); + let targetAnswer = ""; + let targetThinking = ""; + let releasedAnswer = ""; + let releasedThinking = ""; + let answerBacklog = 0; + let thinkingBacklog = 0; + let meta = options.initialMeta; + let dirty = false; + let disposed = false; + let frameHandle: number | null = null; + let timeoutHandle: number | null = null; + + const cancelScheduled = () => { + if (frameHandle !== null) { + scheduler.cancelFrame(frameHandle); + frameHandle = null; + } + if (timeoutHandle !== null) { + scheduler.cancelTimeout(timeoutHandle); + timeoutHandle = null; + } + }; + + const emit = (settled: boolean) => { + dirty = false; + options.flush({ + answer: releasedAnswer, + thinking: releasedThinking, + meta, + settled, + }); + }; + + const step = () => { + frameHandle = null; + timeoutHandle = null; + if (disposed) return; + if (scheduler.hidden()) { + releasedAnswer = targetAnswer; + releasedThinking = targetThinking; + } else { + releasedAnswer = advanceStreamRelease(releasedAnswer, targetAnswer, answerBacklog); + releasedThinking = advanceStreamRelease(releasedThinking, targetThinking, thinkingBacklog); + } + if (releasedAnswer === targetAnswer) answerBacklog = 0; + if (releasedThinking === targetThinking) thinkingBacklog = 0; + const caughtUp = releasedAnswer === targetAnswer && releasedThinking === targetThinking; + emit(caughtUp); + if (!caughtUp) schedule(); + }; + + const schedule = () => { + if (disposed || frameHandle !== null || timeoutHandle !== null) return; + if (scheduler.hidden()) { + timeoutHandle = scheduler.requestTimeout(step, STREAM_HIDDEN_FLUSH_MS); + } else { + frameHandle = scheduler.requestFrame(step); + } + }; + + return { + setAnswer(fullText) { + if (disposed || fullText === targetAnswer) return; + targetAnswer = fullText; + answerBacklog = Math.max(answerBacklog, pendingChars(releasedAnswer, targetAnswer)); + schedule(); + }, + setThinking(fullText) { + if (disposed || fullText === targetThinking) return; + targetThinking = fullText; + thinkingBacklog = Math.max(thinkingBacklog, pendingChars(releasedThinking, targetThinking)); + schedule(); + }, + setMeta(next) { + if (disposed) return; + meta = typeof next === "function" ? (next as (current: Meta) => Meta)(meta) : next; + dirty = true; + schedule(); + }, + touch() { + if (disposed) return; + dirty = true; + schedule(); + }, + settle() { + if (disposed) return; + cancelScheduled(); + releasedAnswer = targetAnswer; + releasedThinking = targetThinking; + answerBacklog = 0; + thinkingBacklog = 0; + emit(true); + }, + reset(nextMeta) { + cancelScheduled(); + targetAnswer = ""; + targetThinking = ""; + releasedAnswer = ""; + releasedThinking = ""; + answerBacklog = 0; + thinkingBacklog = 0; + dirty = false; + if (nextMeta !== undefined) meta = nextMeta; + }, + dispose() { + disposed = true; + cancelScheduled(); + }, + released() { + return { answer: releasedAnswer, thinking: releasedThinking }; + }, + }; +} diff --git a/frontend/tests/chat-markdown-split.test.ts b/frontend/tests/chat-markdown-split.test.ts new file mode 100644 index 00000000..fd8586e9 --- /dev/null +++ b/frontend/tests/chat-markdown-split.test.ts @@ -0,0 +1,74 @@ +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 { StreamingMarkdown } from "../src/components/chat-message-content.tsx"; +import { splitStableMarkdown } from "../src/lib/chat-markdown-split.ts"; + +const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8"); +const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8"); + +test("the stable prefix ends at the last completed paragraph and the tail keeps streaming", () => { + assert.deepEqual(splitStableMarkdown("只有一段还没写完"), { stable: "", tail: "只有一段还没写完" }); + assert.deepEqual(splitStableMarkdown("第一段。\n\n第二段还在"), { stable: "第一段。", tail: "第二段还在" }); + assert.deepEqual( + splitStableMarkdown("## 标题\n\n第一段。\n\n第二段。\n\n第三"), + { stable: "## 标题\n\n第一段。\n\n第二段。", tail: "第三" }, + ); +}); + +test("the cut never lands inside a fence, between list items, or inside a table", () => { + const fenced = "前言。\n\n```txt\n第一行\n\n第二行"; + assert.deepEqual(splitStableMarkdown(fenced), { stable: "前言。", tail: "```txt\n第一行\n\n第二行" }); + + const looseList = "- 甲\n\n- 乙\n\n- 丙还在"; + assert.deepEqual(splitStableMarkdown(looseList), { stable: "", tail: looseList }); + + const listThenParagraph = "- 甲\n- 乙\n\n总结一下"; + assert.deepEqual(splitStableMarkdown(listThenParagraph), { stable: "- 甲\n- 乙", tail: "总结一下" }); + + const orderedListContinues = "1. 甲\n\n2. 乙\n\n 缩进的补充"; + assert.deepEqual(splitStableMarkdown(orderedListContinues), { stable: "", tail: orderedListContinues }); + + const table = "说明。\n\n| 技法 | 状态 |\n| --- | --- |\n| 甲 | 已执行 |\n\n> 引用"; + assert.deepEqual( + splitStableMarkdown(table), + { stable: "说明。\n\n| 技法 | 状态 |\n| --- | --- |\n| 甲 | 已执行 |", tail: "> 引用" }, + ); +}); + +test("growing the tail keeps the prefix string identical so the memoised prefix is not re-parsed", () => { + const before = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段正在"); + const after = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段正在写,还没有换段"); + assert.equal(before.stable, after.stable); + assert.notEqual(before.tail, after.tail); + + // Only once a new paragraph completes does the prefix move forward. + const later = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段写完了。\n\n第四"); + assert.equal(later.stable, "第一段。\n\n第二段。\n\n第三段写完了。"); +}); + +test("the streaming renderer parses the prefix and the tail as two separate documents", () => { + const calls: string[] = []; + const renderMarkdown = (text: string) => { + calls.push(text); + return createElement("p", null, text); + }; + renderToString(createElement(StreamingMarkdown, { + text: "第一段。\n\n第二段。\n\n第三段还在", + renderMarkdown, + })); + // Server rendering evaluates the tail in the parent and the memoised prefix as a child, + // so compare as a set: what matters is that neither call sees the whole answer. + assert.deepEqual([...calls].sort(), ["第一段。\n\n第二段。", "第三段还在"].sort()); + + // The prefix component is memoised on its text, so an unchanged prefix costs no parse. + assert.match(contentSource, /const StableMarkdownPrefix = memo\(function StableMarkdownPrefix/); + assert.match(contentSource, /splitStableMarkdown\(text\)/); + assert.match(contentSource, /streaming\s*\?\s*= 1); assert.equal(unsplit.unsplitListRenders, tokens.length); assert.ok(unsplit.settledRowRenders > split.settledRowRenders); }); + +test("frame coalescing renders the streaming row once per frame, not once per token", () => { + const messages: ChatMessage[] = [{ role: "user", text: "请继续说明这个月的安排。" }]; + const frames: Array<() => void> = []; + const scheduler: StreamFrameScheduler = { + requestFrame(callback) { + frames.push(callback); + return frames.length; + }, + cancelFrame() { frames.length = 0; }, + requestTimeout() { return 0; }, + cancelTimeout() {}, + hidden: () => false, + }; + + resetHomeStreamingRenderProbe(); + enableHomeStreamingRenderProbe(); + const buffer = createStreamFrameBuffer({ + initialMeta: null, + scheduler, + flush: (frame) => { + const streamingMessage = streamingChatMessageView(messages, true, frame.answer); + assert.ok(streamingMessage); + renderToString(createElement(StreamingMessageEntry, { message: streamingMessage })); + }, + }); + // 200 one-character tokens arrive four per frame across fifty frames. + let answer = ""; + for (let index = 0; index < 200; index += 1) { + answer += "字"; + buffer.setAnswer(answer); + if (index % 4 === 3) for (const callback of frames.splice(0)) callback(); + } + buffer.settle(); + const probe = homeStreamingRenderProbeSnapshot(); + disableHomeStreamingRenderProbe(); + + assert.ok(probe.streamingRowRenders <= 51, `rendered ${probe.streamingRowRenders} times for 200 tokens`); + assert.ok(probe.streamingRowRenders * 3 <= 200); +}); diff --git a/frontend/tests/stream-frame-buffer.test.ts b/frontend/tests/stream-frame-buffer.test.ts new file mode 100644 index 00000000..81fbf787 --- /dev/null +++ b/frontend/tests/stream-frame-buffer.test.ts @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + STREAM_HIDDEN_FLUSH_MS, + STREAM_RELEASE_CATCHUP_DIVISOR, + STREAM_RELEASE_MIN_CHARS, + advanceStreamRelease, + createStreamFrameBuffer, + streamReleaseCount, + type StreamFrameScheduler, + type StreamFrameSnapshot, +} from "../src/lib/stream-frame-buffer.ts"; + +function fakeScheduler(hidden = () => false) { + const frames: Array<() => void> = []; + const timeouts: Array<{ callback: () => void; delayMs: number }> = []; + let handle = 0; + const scheduler: StreamFrameScheduler = { + requestFrame(callback) { + frames.push(callback); + handle += 1; + return handle; + }, + cancelFrame() { + frames.length = 0; + }, + requestTimeout(callback, delayMs) { + timeouts.push({ callback, delayMs }); + handle += 1; + return handle; + }, + cancelTimeout() { + timeouts.length = 0; + }, + hidden, + }; + return { + scheduler, + tick() { + const pending = frames.splice(0); + for (const callback of pending) callback(); + return pending.length; + }, + tickTimeouts() { + const pending = timeouts.splice(0); + for (const entry of pending) entry.callback(); + return pending; + }, + get scheduledFrames() { + return frames.length; + }, + }; +} + +test("release count is at least two characters and catches up a backlog within about twelve frames", () => { + assert.equal(streamReleaseCount(0), 0); + assert.equal(streamReleaseCount(1), 1); + assert.equal(streamReleaseCount(2), STREAM_RELEASE_MIN_CHARS); + assert.equal(streamReleaseCount(5), STREAM_RELEASE_MIN_CHARS); + assert.equal(streamReleaseCount(24), STREAM_RELEASE_MIN_CHARS); + assert.equal(streamReleaseCount(25), 3); + assert.equal(streamReleaseCount(1200), 1200 / STREAM_RELEASE_CATCHUP_DIVISOR); + + let released = ""; + const target = "字".repeat(3_000); + let frames = 0; + while (released !== target && frames < 100) { + released = advanceStreamRelease(released, target, target.length); + frames += 1; + } + // A 3,000-character backlog that arrived at once clears in twelve frames (~200ms). + assert.equal(frames, STREAM_RELEASE_CATCHUP_DIVISOR); + assert.equal(released, target); + + // Without the backlog figure the pace still floors at two characters per frame. + assert.equal(advanceStreamRelease("", "十二个字符十二个字符十二"), "十二"); +}); + +test("a replaced target that no longer extends the released prefix jumps instead of stalling", () => { + assert.equal(advanceStreamRelease("旧的回答", "新"), "新"); + // A replacement restarts at the paced rate from the new head rather than showing stale text. + assert.equal(advanceStreamRelease("abc", "abd"), "ab"); +}); + +test("many events collapse into one flush per frame and settle releases everything synchronously", () => { + const fake = fakeScheduler(); + const flushes: StreamFrameSnapshot[] = []; + const buffer = createStreamFrameBuffer({ + initialMeta: [], + scheduler: fake.scheduler, + flush: (snapshot) => flushes.push(snapshot), + }); + + // 200 one-character tokens arrive four per frame across fifty frames, the way a + // model streams Chinese text; each frame is allowed one commit. + let answer = ""; + let frameCount = 0; + for (let index = 0; index < 200; index += 1) { + answer += "字"; + buffer.setAnswer(answer); + buffer.setMeta((rows) => [...rows, `row-${index}`]); + if (index % 4 === 3) frameCount += fake.tick(); + } + assert.equal(frameCount, 50); + assert.equal(flushes.length, 50); + assert.ok(flushes.length * 3 <= 200, "at most one commit per frame, not per token"); + for (let index = 1; index < flushes.length; index += 1) { + assert.ok(flushes[index]!.answer.length >= flushes[index - 1]!.answer.length); + assert.ok(flushes[index]!.answer.length - flushes[index - 1]!.answer.length >= STREAM_RELEASE_MIN_CHARS); + } + assert.equal(flushes.at(-1)!.meta.length, 200); + assert.ok(flushes.at(-1)!.answer.length < 200, "pacing is still behind the network"); + + buffer.settle(); + assert.equal(flushes.at(-1)!.answer, answer); + assert.equal(flushes.at(-1)!.settled, true); + assert.equal(fake.scheduledFrames, 0); + assert.equal(buffer.released().answer, answer); +}); + +test("thinking text is paced separately from the answer and meta-only touches still flush", () => { + const fake = fakeScheduler(); + const flushes: StreamFrameSnapshot[] = []; + const buffer = createStreamFrameBuffer({ + initialMeta: null, + scheduler: fake.scheduler, + flush: (snapshot) => flushes.push(snapshot), + }); + buffer.setThinking("先看事业宫,再看大运。"); + fake.tick(); + assert.equal(flushes.length, 1); + assert.equal(flushes[0]!.answer, ""); + assert.ok(flushes[0]!.thinking.length >= STREAM_RELEASE_MIN_CHARS); + assert.equal(flushes[0]!.settled, false); + + buffer.settle(); + assert.equal(flushes.at(-1)!.thinking, "先看事业宫,再看大运。"); + + buffer.touch(); + fake.tick(); + assert.equal(flushes.length, 3); + assert.equal(flushes.at(-1)!.settled, true); +}); + +test("a burst that lands mid-stream is cleared within about twelve frames instead of trickling", () => { + const fake = fakeScheduler(); + const flushes: StreamFrameSnapshot[] = []; + const buffer = createStreamFrameBuffer({ + initialMeta: null, + scheduler: fake.scheduler, + flush: (snapshot) => flushes.push(snapshot), + }); + buffer.setAnswer("字".repeat(20)); + fake.tick(); + buffer.setAnswer("字".repeat(2_420)); + let frames = 0; + while (fake.scheduledFrames > 0 && frames < 100) { + fake.tick(); + frames += 1; + } + assert.equal(flushes.at(-1)!.answer.length, 2_420); + assert.ok(frames <= STREAM_RELEASE_CATCHUP_DIVISOR + 1, `took ${frames} frames`); +}); + +test("a hidden document falls back to a timeout and releases everything at once", () => { + const fake = fakeScheduler(() => true); + const flushes: StreamFrameSnapshot[] = []; + const buffer = createStreamFrameBuffer({ + initialMeta: null, + scheduler: fake.scheduler, + flush: (snapshot) => flushes.push(snapshot), + }); + buffer.setAnswer("字".repeat(500)); + assert.equal(fake.scheduledFrames, 0); + const fired = fake.tickTimeouts(); + assert.equal(fired.length, 1); + assert.equal(fired[0]!.delayMs, STREAM_HIDDEN_FLUSH_MS); + assert.equal(flushes.length, 1); + assert.equal(flushes[0]!.answer.length, 500); + assert.equal(flushes[0]!.settled, true); +}); + +test("reset drops received and released text plus scheduled work, and dispose silences the buffer", () => { + const fake = fakeScheduler(); + const flushes: StreamFrameSnapshot[] = []; + const buffer = createStreamFrameBuffer({ + initialMeta: 1, + scheduler: fake.scheduler, + flush: (snapshot) => flushes.push(snapshot), + }); + buffer.setAnswer("第一次尝试的正文"); + fake.tick(); + assert.equal(flushes.length, 1); + + buffer.reset(2); + assert.equal(fake.scheduledFrames, 0); + assert.deepEqual(buffer.released(), { answer: "", thinking: "" }); + buffer.touch(); + fake.tick(); + assert.equal(flushes.at(-1)!.answer, ""); + assert.equal(flushes.at(-1)!.meta, 2); + + buffer.dispose(); + buffer.setAnswer("不再发布"); + buffer.touch(); + assert.equal(fake.tick(), 0); + buffer.settle(); + assert.equal(flushes.at(-1)!.answer, ""); +}); From be5e810ac918e0ff5c3ff779a0b1b81e86e2da7a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 1 Sep 2026 23:05:55 +0000 Subject: [PATCH 2/9] 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"/); }); From ccdae76dd32a59e52b071b1eff75bd038eb4981b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 1 Sep 2026 23:10:28 +0000 Subject: [PATCH 3/9] refactor(chat): render rectification activity through the shared step timeline The rectification surface had its own activity pipeline: a trace panel with a 20px canvas orb clipped inside a 14px marker, a list that never collapsed, a second receipt disclosure under every reply, an inline failure banner and a staged label while regenerating. Its trace and receipt are now projected onto ConsultationTimelineRow so both surfaces render one ConsultationRunTimeline with one live marker and one settled summary; receipt methods become source chips on the last completed row. The unreachable sections-report/step-tree path, the trace panel, the receipt component and the thinking-orbs dependency are removed. The server-side drop of rectification thinking deltas is untouched. BUG-476 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- docs/BUG_HISTORY.md | 16 ++ frontend/package-lock.json | 107 --------- frontend/package.json | 1 - frontend/src/app/globals.css | 152 ------------- .../src/components/agent-activity-status.tsx | 96 ++------ frontend/src/components/chat-message-row.tsx | 28 +-- .../components/completed-activity-receipt.tsx | 113 ---------- .../consultation-thinking-report.tsx | 69 ------ .../components/rectification-agentic-chat.tsx | 29 ++- .../src/components/thinking-step-tree.tsx | 213 ------------------ .../src/lib/rectification-timeline-adapter.ts | 85 +++++++ .../chat-bundle-splitting-contract.test.ts | 20 +- frontend/tests/chat-stream-layout.test.ts | 37 ++- .../class-name-definition-contract.test.ts | 1 - .../rectification-activity-receipt.test.ts | 18 +- .../tests/rectification-agentic-entry.test.ts | 16 +- .../rectification-timeline-adapter.test.ts | 131 +++++++++++ 17 files changed, 313 insertions(+), 819 deletions(-) delete mode 100644 frontend/src/components/completed-activity-receipt.tsx delete mode 100644 frontend/src/components/consultation-thinking-report.tsx delete mode 100644 frontend/src/components/thinking-step-tree.tsx create mode 100644 frontend/src/lib/rectification-timeline-adapter.ts create mode 100644 frontend/tests/rectification-timeline-adapter.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 8709b6a9..8db2208c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7314,3 +7314,19 @@ - 相关记录:BUG-474 - 复发自:无 - 修复版本:待发布 + +## BUG-476 | 生时校正会话的 Agent 活动 UI 与普通会话是两套,live 标记被 14px 格子裁切 + +- 状态:resolved +- 首次发现:2026-09-01 +- 最近更新:2026-09-01 +- 影响面:`rectification-agentic-chat.tsx`、`agent-activity-status.tsx`、`chat-message-row.tsx`、`globals.css`、`package.json`(`thinking-orbs`) +- 用户现象:校正会话里步骤标记是一个 20px 的 canvas 小球,被压在 14px 的格子里边缘裁切;结算后步骤列表永远全展开,下面还多挂一个「本轮完成 · N 个步骤」折叠块;失败时消息上方多一行红字;重新生成时先出现一个假的「正在组织回答…」;普通会话则是行内 spinner、结算收成「已完成 N 步」一行。两边看起来不像一个产品。 +- 触发条件:任何一次校正回合与任何一次咨询回合并排对比。 +- 根因:校正走 `activityTrace` + `AgentActivityStatus` 的 trace 分支,普通会话走 `ConsultationRunTimeline`;`chat-message-row.tsx` 里并存三条思考渲染路径(timeline、`ConsultationThinkingReport`+`ThinkingStepTree`、trace panel),其中 sections report 已无任何调用方可达;`.conversation.is-rectification .agent-thinking-marker { 14px }` 覆写与 `ThinkingOrb size={20}` 冲突。 +- 修复:新增 `lib/rectification-timeline-adapter.ts`,把 trace/receipt/activity 映射成 `ConsultationTimelineRow`(tool → calculate 行;失败 tool 标签加「未完成」;回执方法作为最后一条完成行的 sources chips,去重 ≤8;无 live 行时把「正在…」类活动文案作为 live 行,answer-composition 归 write)。校正消息由此走同一个 `ConsultationRunTimeline`。删除 `consultation-thinking-report.tsx`、`thinking-step-tree.tsx`、`completed-activity-receipt.tsx` 及其 CSS;`AgentActivityStatus` 只保留无 timeline 的兜底,live 标记统一 `InlineSpinner` 12px,移除 `thinking-orbs` 依赖;删除 14px 覆写;失败态改走既有 error 通知;重新生成显示 queued 行由真实事件填充。校正 `thinking.delta` 仍在服务端公开边界丢弃,本轮未动。 +- 验证:`tests/rectification-timeline-adapter.test.ts`;`chat-stream-layout`、`chat-bundle-splitting-contract`、`rectification-agentic-entry`、`rectification-activity-receipt` 中锁死路径的断言按「注明原值与错因」改为锁新路径。 +- 防复发:任何会话面的 Agent 活动都必须投影成 `ConsultationTimelineRow` 交给 `ConsultationRunTimeline`;live 标记只允许 `InlineSpinner`;不得再引入第二种等待词汇。 +- 相关记录:BUG-474、BUG-475 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 542f3768..13b48200 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,7 +39,6 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", - "thinking-orbs": "^0.1.1", "tsx": "^4.23.1", "tw-animate-css": "^1.4.0", "zod": "^3.25.76" @@ -1745,9 +1744,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1764,9 +1760,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1783,9 +1776,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1802,9 +1792,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1821,9 +1808,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1840,9 +1824,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1859,9 +1840,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1878,9 +1856,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1897,9 +1872,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1922,9 +1894,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1947,9 +1916,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1972,9 +1938,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1997,9 +1960,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2022,9 +1982,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2047,9 +2004,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2072,9 +2026,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2508,9 +2459,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2527,9 +2475,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2546,9 +2491,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2565,9 +2507,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3886,9 +3825,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3903,9 +3839,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3920,9 +3853,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3937,9 +3867,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4205,9 +4132,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4224,9 +4148,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4243,9 +4164,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4262,9 +4180,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9371,9 +9286,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9394,9 +9306,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9417,9 +9326,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9440,9 +9346,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13217,16 +13120,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/thinking-orbs": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/thinking-orbs/-/thinking-orbs-0.1.1.tgz", - "integrity": "sha512-nLvLTGJtk74K13MmP7XiRdzFiQx7UsoZAIyBZNgXyl7Q3h2mdz0r3eKn9LCsuzb3DGOVjwDvMhtnAkIqJJRVQA==", - "license": "MIT", - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index ebec2b02..980a68e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,7 +48,6 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", - "thinking-orbs": "^0.1.1", "tsx": "^4.23.1", "tw-animate-css": "^1.4.0", "zod": "^3.25.76" diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 6655e9d4..4d629c32 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -426,13 +426,6 @@ button:disabled { cursor: default; opacity: .45; } gap: var(--space-2); min-height: 24px; } -.agent-thinking-step.is-think:has(.message-thinking) { - display: block; - min-height: 0; -} -.agent-thinking-step.is-think .message-thinking { - margin-bottom: 0; -} .agent-thinking-marker { width: 20px; height: 20px; @@ -449,8 +442,6 @@ button:disabled { cursor: default; opacity: .45; } } .agent-activity-status__row { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; width: 100%; } .agent-activity-status__live { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; } -.agent-activity-status canvas, -.agent-thinking-marker canvas { flex: 0 0 auto; } .agent-activity-status__elapsed, .agent-thinking-elapsed { flex: 0 0 auto; @@ -1054,67 +1045,10 @@ button:disabled { cursor: default; opacity: .45; } line-height: 1.55; white-space: pre-wrap; } -.consultation-step-tree__group { - display: grid; - gap: var(--space-2); -} -.consultation-step-tree__stage { - display: grid; - grid-template-columns: 20px minmax(0, 1fr); - align-items: center; - gap: var(--space-2); - min-height: 24px; - margin: 0; - color: var(--color-ink-strong); - font-family: inherit; - font-size: var(--type-caption); - font-weight: 600; - line-height: 1.35; - text-wrap: balance; -} -.consultation-step-tree__stage-index, -.consultation-step-tree__stage-mark { - width: 20px; - height: 20px; - display: grid; - place-items: center; - border-radius: var(--radius-xs); - background: var(--color-canvas-muted); - box-shadow: inset 0 0 0 1px var(--color-border); - color: var(--color-ink-secondary); - font-size: 10px; - font-weight: 600; - font-variant-numeric: tabular-nums; - letter-spacing: 0.02em; -} -.consultation-step-tree__stage-mark { - background: transparent; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 80%, transparent); -} -.consultation-step-tree__stage-title { - min-width: 0; -} -.agent-thinking-marker.is-pending { - background: var(--color-canvas-muted); - box-shadow: inset 0 0 0 1px var(--color-border); -} -.agent-thinking-step.is-more { - color: var(--color-ink-tertiary); -} .consultation-thinking-report { display: grid; gap: var(--space-6); } -.consultation-step-tree__group + .consultation-step-tree__group { - margin-top: var(--space-6); -} -.consultation-step-tree__reasoning { - margin: 0 0 var(--space-4); - color: var(--color-ink-secondary); - font-size: var(--type-caption); - line-height: 1.55; - white-space: pre-wrap; -} .consultation-report-analysis { min-width: 0; } @@ -1124,11 +1058,6 @@ button:disabled { cursor: default; opacity: .45; } .consultation-report-analysis .message-markdown { color: var(--color-ink-strong); } -.consultation-thinking-report .message-thinking { - margin-bottom: 0; - padding-bottom: var(--space-3); - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent); -} .consultation-run-timeline { min-width: 0; } @@ -2682,14 +2611,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class max-width: 720px; padding-bottom: var(--rectification-jump-clearance); } -.conversation.is-rectification .message-assistant .agent-thinking-step { - grid-template-columns: 14px minmax(0, 1fr); - gap: 6px; -} -.conversation.is-rectification .message-assistant .agent-thinking-marker { - width: 14px; - height: 14px; -} .conversation.is-rectification .message-actions { margin-inline-start: var(--assistant-content-inset); } @@ -3545,78 +3466,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .rectification-message-wrap { display: grid; gap: var(--space-1); } -.rectification-activity-receipt { - width: min(720px, 100%); - margin: 0 0 var(--space-2) 36px; - color: var(--color-ink-tertiary); - font-size: var(--type-caption); -} -.rectification-activity-receipt summary { - display: flex; - min-height: 32px; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - padding: 0; - color: var(--color-ink-tertiary); - cursor: pointer; - font-weight: 500; - list-style: none; -} -.rectification-activity-receipt summary::-webkit-details-marker { display: none; } -.rectification-activity-receipt summary:focus-visible { - border-radius: var(--radius-sm); - outline: 2px solid var(--color-focus); - outline-offset: 3px; -} -.rectification-activity-receipt__summary-main, -.rectification-activity-receipt__toggle { - display: inline-flex; - align-items: center; - gap: var(--space-1); -} -.rectification-activity-receipt__summary-main svg { color: var(--color-success); } -.rectification-activity-receipt__toggle { flex: 0 0 auto; color: var(--color-ink-tertiary); } -.rectification-activity-receipt__toggle svg { transition: transform 160ms ease; } -.rectification-activity-receipt[open] .rectification-activity-receipt__toggle svg { transform: rotate(180deg); } -.rectification-activity-receipt__failure-mark { - display: inline-grid; - width: 15px; - height: 15px; - place-items: center; - border: 1px solid currentColor; - border-radius: 50%; - font-size: 10px; - font-weight: 700; - line-height: 1; -} -.rectification-activity-receipt.is-failed, -.rectification-activity-failure { color: var(--color-danger); } -.rectification-activity-failure { margin: 0 0 var(--space-2) 36px; font-size: var(--type-caption); } -.rectification-activity-receipt__details { - display: grid; - gap: var(--space-3); - margin-top: var(--space-1); - padding: var(--space-3) 0 var(--space-2) 19px; - border-left: 1px solid var(--color-border); -} -.rectification-activity-receipt__details section { display: grid; gap: var(--space-1); } -.rectification-activity-receipt__details h3 { - margin: 0; - color: var(--color-ink-tertiary); - font-size: var(--type-overline); - font-weight: 650; -} -.rectification-activity-receipt__details p { margin: 0; color: var(--color-ink-secondary); line-height: 1.65; } -.rectification-activity-receipt__method-groups { display: grid; gap: 2px; } -.rectification-activity-receipt__method-groups strong { color: var(--color-ink-secondary); font-weight: 600; } - -@media (max-width: 640px) { - .rectification-activity-receipt, - .rectification-activity-failure { margin-left: 32px; } - .rectification-activity-receipt summary { align-items: flex-start; } - .rectification-activity-receipt__toggle { padding-left: var(--space-1); } -} @media (prefers-reduced-motion: reduce) { .agent-activity-status__text { @@ -3624,6 +3473,5 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class background: none; -webkit-text-fill-color: currentColor; } - .rectification-activity-receipt__toggle svg { transition: none; } .technique-audit > summary::before { transition: none; } } diff --git a/frontend/src/components/agent-activity-status.tsx b/frontend/src/components/agent-activity-status.tsx index eba9b608..641895c8 100644 --- a/frontend/src/components/agent-activity-status.tsx +++ b/frontend/src/components/agent-activity-status.tsx @@ -1,13 +1,12 @@ "use client"; import { useEffect, useState } from "react"; -import dynamic from "next/dynamic"; import { Check } from "lucide-react"; -import type { OrbState } from "thinking-orbs"; -import { prefetchOnIdle } from "@/components/chat-chunk-prefetch"; +import { InlineSpinner } from "@/components/inline-spinner"; import { activityCompletedSteps, activityElapsedLabel } from "@/lib/chat-message-view"; -import type { AgentActivityTraceItem } from "@/lib/agent-activity-trace"; + +export type AgentActivityState = "working" | "searching" | "solving" | "listening" | "composing" | "shaping"; const labels = { working: "正在处理任务…", @@ -16,20 +15,7 @@ const labels = { listening: "正在聆听…", composing: "正在组织回答…", shaping: "正在生成结果…", -} as const satisfies Record; - -const importThinkingOrb = () => import("thinking-orbs"); - -const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, { - loading: () => ( -