diff --git a/CHANGELOG.md b/CHANGELOG.md index 26225254..288683da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 回答出字时页面不再整页重画 + +- 回答逐字出来时,只有回答那一块在更新;顶栏、输入框和右侧本命盘不再每一帧跟着重画(以前最多每秒 60 次)。长回答时电脑更省力,滚动和点按更跟手。看到的内容不变。 +- Skill 版本不变(BUG-1472,issue #123)。 + ## 2026-10-28 — 个人长报告生成快约四分之一 - 第一次生成一份完整的个人长报告,本机实测从约 26 秒降到约 20 秒;报告内容一字不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ae1894a2..b15409ef 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20080,3 +20080,19 @@ - 复发自:无 - 修复版本:分支 `codex/report-fast-default-20261028`。 + +## BUG-1472 | 回答出字时首页整页和右侧本命盘每帧重画 + +- 状态:resolved(代码 + 回归测试;待部署) +- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 13 条 → #123) +- 影响面:首页对话(`frontend/src/app/(app)/page.tsx` `Home`)、桌面端默认打开的本命盘面板(`chat-chart-split.tsx`)。 +- 现象:回答逐字出来时,整个首页(顶栏、人物选择、余额、输入框、模型选择等)和右侧整张星盘跟着每一帧重画,最多 60 次/秒;一条 1,500 字的回答约 375 次。外观和内容没有变化,只是白耗主线程。 +- 触发条件:已登录首页,任意一次流式回答;桌面端本命盘面板打开时更重。 +- 根因:`Home` 用 `useShallow` 订阅了整个 `streamingReply` 对象,流式缓冲每帧提交一个新对象,`Home` 整体重跑;`ChatChartSplit` 没有 `memo`,跟着 `ChatChartFrame` 的 children 变化重画。BUG-1329 只修了侧栏重注册,`Home` 自身的每帧重渲染留着。 +- 修复:新增 `components/chat-streaming-transcript.tsx`。`ChatStreamingTranscript` 自己按会话订阅流式回复并喂给 `ChatTranscript`;`Home` 只订阅派生布尔值 `useStreamingReplyHasText`(停止按钮文案用,一次回答最多变两次)。`ChatChartSplit` 包 `memo`(props 都是 store 值)。`page.tsx` 少 19 行,未新增 useState / useRef。 +- 验证:新测试 `frontend/tests/home-stream-rerender-20261028.test.tsx`:100 帧真实写 store,旧接法 Home 提交 ≥100 次;新接法 Home 自身提交 ≤2 次,面板 0 次,正文仍逐帧更新并显示完整;停止文案在出字前 / 后正确切换;源码断言 `page.tsx` 不再读 `streamingReply`、`ChatChartSplit` 是 memo。改前代码上源码断言失败。tsc 0、相关文件 lint 0。 +- 防复发:上述测试。流式状态只让消息区订阅;页面级组件只订阅会变得很少的派生值。 +- 未做:新手引导打字机(`setPresetMessageLength` 每 26 ms 一次)仍在 Home 顶层。它被 `use-profile-onboarding` 的 8 处重置驱动,其中几处是「同一句话重放」,下放需要改重置语义,超出本单验收;只在建档那几秒发生。 +- 关联:BUG-1329(侧栏每帧重注册)、BUG-1130。 +- 复发自:无 +- 修复版本:分支 `codex/home-stream-rerender-20261028`。 diff --git a/docs/testing/code-health-118-20261028.md b/docs/testing/code-health-118-20261028.md index 78dc4956..c5a74039 100644 --- a/docs/testing/code-health-118-20261028.md +++ b/docs/testing/code-health-118-20261028.md @@ -18,3 +18,8 @@ 9. 点盘型条右边的 ≡:「星盘类型」标题在左、叉号在右,同一行;电脑上整块面板完整可见。 10. 手机聊天页点开盘面:顶边没有白线,盘离屏幕两边更近。 +## #123 回答出字时页面不再整页重画(BUG-1472) + +11. 电脑打开首页、右侧本命盘开着,问一个会写长回答的问题:回答照常逐字出来,右侧盘不闪、不跳;出字过程中拖动右侧盘的宽度、点盘上的宫位都跟手。 +12. 回答还没出字时点「停止」:提示是「停止回答并申请退回本次点数」;出了一些字以后再问一次并点停止:提示变成「停止回答,保留已生成内容并退回本次点数」。 + diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index a4ab0ad1..4ca96d9c 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -25,7 +25,8 @@ import { import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification"; import { declaredBirthTime } from "@/lib/rectification-surface-state"; import { toggleChatMessageFeedback } from "@/components/chat-message-actions"; -import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-transcript"; +import type { ChatTranscriptActions } from "@/components/chat-transcript"; +import { ChatStreamingTranscript, useStreamingReplyHasText } from "@/components/chat-streaming-transcript"; import { ChatProfilePicker } from "@/components/chat-profile-picker"; import { ChatChartFrame, ChatChartToggle } from "@/components/chat-chart-frame"; import { ModelSelector } from "@/components/model-selector"; @@ -244,7 +245,6 @@ export default function Home() { setPendingSessionId, pendingRequestId, setPendingRequestId, - streamingReply, setStreamingReply, messageFeedback, setMessageFeedback, @@ -262,7 +262,6 @@ export default function Home() { setPendingSessionId: state.setPendingSessionId, pendingRequestId: state.pendingRequestId, setPendingRequestId: state.setPendingRequestId, - streamingReply: state.streamingReply, setStreamingReply: state.setStreamingReply, messageFeedback: state.messageFeedback, setMessageFeedback: state.setMessageFeedback, @@ -421,19 +420,7 @@ export default function Home() { || rectificationMutationPending || !account || !modelCatalog; - const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : ""; - const activeStreamingActivity = streamingReply && streamingReply.sessionId === activeSession?.id - ? streamingReply.activity - : undefined; - const activeStreamingThinking = streamingReply && streamingReply.sessionId === activeSession?.id - ? streamingReply.thinkingText - : undefined; - const activeStreamingSections = streamingReply && streamingReply.sessionId === activeSession?.id - ? streamingReply.thinkingSections - : undefined; - const activeStreamingTimeline = streamingReply && streamingReply.sessionId === activeSession?.id - ? streamingReply.timeline - : undefined; + const activeStreamingHasText = useStreamingReplyHasText(activeSession?.id); const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null; const replyPhase: ChatReplyPhase = isLoading ? consultationPhase === "recovering" ? "recovering" : "generating" @@ -1319,15 +1306,9 @@ export default function Home() { ) : (
- { setDraft(event.target.value); diff --git a/frontend/src/components/chat-chart-split.tsx b/frontend/src/components/chat-chart-split.tsx index ca0a1be8..8a81a95f 100644 --- a/frontend/src/components/chat-chart-split.tsx +++ b/frontend/src/components/chat-chart-split.tsx @@ -1,5 +1,7 @@ "use client"; +import { memo } from "react"; + import { ChatNatalChart } from "@/components/chat-natal-chart"; import { Button } from "@/components/ui/button"; import { useDragWidth } from "@/hooks/use-drag-width"; @@ -8,9 +10,10 @@ import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "@/lib/chat-chart-pan /** * The right-hand natal column. The chat column stays outside it, so loading this * never remounts the chat (BUG-1448). Its left edge drags or takes the arrow - * keys; dragging below the minimum closes the panel (BUG-1456). + * keys; dragging below the minimum closes the panel (BUG-1456). Memoized: its + * props are store values, so a Home render alone never redraws the chart (issue #123). */ -export function ChatChartSplit({ +export const ChatChartSplit = memo(function ChatChartSplit({ open, width, subjectId, @@ -45,4 +48,4 @@ export function ChatChartSplit({ ); -} +}); diff --git a/frontend/src/components/chat-streaming-transcript.tsx b/frontend/src/components/chat-streaming-transcript.tsx new file mode 100644 index 00000000..39635115 --- /dev/null +++ b/frontend/src/components/chat-streaming-transcript.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { ChatTranscript, type ChatTranscriptProps } from "@/components/chat-transcript"; +import type { StreamingReply } from "@/lib/home-types"; +import { useSessionStore } from "@/stores/session-store"; + +type StreamingFields = "streamingText" | "streamingActivity" | "streamingThinking" | "streamingSections" | "streamingTimeline" | "streamingResponseKind"; + +function replyFor(state: { streamingReply: StreamingReply | null }, sessionId: string | undefined) { + const reply = state.streamingReply; + return reply && reply.sessionId === sessionId ? reply : null; +} + +/** + * The transcript is the only subscriber to the streaming reply (issue #123). + * The stream commits once per animation frame; when Home subscribed, every + * frame re-rendered the whole page and the desktop chart panel with it. + */ +export function ChatStreamingTranscript(props: Omit) { + const reply = useSessionStore((state) => replyFor(state, props.sessionId)); + return ( + + ); +} + +/** Whether the active reply has text yet; flips at most twice per answer, so Home can read it. */ +export function useStreamingReplyHasText(sessionId: string | undefined) { + return useSessionStore((state) => Boolean(replyFor(state, sessionId)?.text)); +} diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index 197c0193..d6410bdd 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -161,7 +161,10 @@ test("the streaming reply announces state transitions, never streamed deltas", ( // And: the old start-only region that lived inside the busy message list is gone. assert.doesNotMatch(pageSource, /\{isLoading \? "Jyotisha 正在回答" : ""\}<\/span>/); - assert.match(pageSource, /
\n\s*\n\s* { diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts index b5eaebf5..e88f7fa0 100644 --- a/frontend/tests/chat-stream-layout.test.ts +++ b/frontend/tests/chat-stream-layout.test.ts @@ -10,6 +10,7 @@ const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta. const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8"); const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8"); const timelineSource = readFileSync(new URL("../src/components/consultation-run-timeline.tsx", import.meta.url), "utf8"); +const streamingTranscriptSource = readFileSync(new URL("../src/components/chat-streaming-transcript.tsx", import.meta.url), "utf8"); const reducerSource = readFileSync(new URL("../src/lib/consultation-run-timeline.ts", import.meta.url), "utf8"); const previousMessages = [ @@ -123,7 +124,10 @@ test("shows honest agent activity states before and during streamed text", () => assert.match(pageSource, /createNdjsonParser/); assert.match(pageSource, /event.type === "thinking.section"/); assert.match(pageSource, /event.type === "thinking.delta"/); - assert.match(pageSource, /activeStreamingSections/); + // 原值:page.tsx 里有 `activeStreamingSections` + // 新值:流式的思考分节由 ChatStreamingTranscript 自己订阅后传给 ChatTranscript + // 原因:issue #123(BUG-1472)Home 不再订阅流式回复,避免每帧整页重画 + assert.match(streamingTranscriptSource, /streamingSections=\{reply\?\.thinkingSections\}/); assert.match(pageSource, /streamedThinking/); assert.match(messageRowSource, /showLiveActivity/); // Former locks on `ConsultationThinkingReport`, `hasTrace`, `activityTrace={message.activityTrace}`, @@ -145,7 +149,8 @@ test("shows honest agent activity states before and during streamed text", () => assert.match(pageSource, /\.\.\.\(thinkingSections\.length \? \{ thinkingSections \} : \{\}\)/); assert.match(pageSource, /reduceConsultationTimeline/); assert.match(pageSource, /timeline: timelineState\.rows/); - assert.match(pageSource, /activeStreamingTimeline/); + // 原值:page.tsx 里有 `activeStreamingTimeline`;新值与原因同上(issue #123) + assert.match(streamingTranscriptSource, /streamingTimeline=\{reply\?\.timeline\}/); assert.match(messageRowSource, /ConsultationRunTimeline/); assert.match(messageRowSource, /message\.timeline/); assert.match(timelineSource, /BookOpen/); diff --git a/frontend/tests/home-stream-rerender-20261028.test.tsx b/frontend/tests/home-stream-rerender-20261028.test.tsx new file mode 100644 index 00000000..ca7ebfb5 --- /dev/null +++ b/frontend/tests/home-stream-rerender-20261028.test.tsx @@ -0,0 +1,147 @@ +/** + * Issue #123: while an answer streams, only the transcript follows the reply. + * Home used to subscribe to `streamingReply`, so every frame re-rendered the + * whole page and the desktop chart panel under it. + */ +import "./install-test-document"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { createRef, memo, Profiler, useEffect, type ProfilerOnRenderCallback, type ReactNode } from "react"; + +import { ChatChartSplit } from "../src/components/chat-chart-split.tsx"; +import { ChatStreamingTranscript, useStreamingReplyHasText } from "../src/components/chat-streaming-transcript.tsx"; +import { ChatTranscript, type ChatTranscriptActions, type ChatTranscriptProps } from "../src/components/chat-transcript.tsx"; +import type { ChatMessage } from "../src/lib/chat-message-view.ts"; +import { useSessionStore } from "../src/stores/session-store.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +const SESSION = "session-stream"; +const FRAMES = 100; +const messages: ChatMessage[] = [{ role: "user", text: "这个月工作上要注意什么?" }]; + +function transcriptProps(): Omit { + const actionsRef = createRef() as ChatTranscriptProps["actionsRef"]; + actionsRef.current = { onFeedback() {}, onCopy() {}, onRegenerate() {}, onFollowUp() {}, onStartRectification() {} }; + return { + messages, + loading: true, + sessionId: SESSION, + sessionType: "consultation", + theme: "career", + messageFeedback: {}, + copiedMessageKey: null, + cancellationPending: false, + productEntrypointsDisabled: true, + actionsRef, + }; +} + +// Stands in for the chart drawing: counts its own renders without fetching a chart. +const panelRenders = { count: 0 }; +const PanelStandIn = memo(function PanelStandIn({ subjectId }: { subjectId: string }) { + useEffect(() => { panelRenders.count += 1; }); + return