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 ;
+});
+
+async function streamFrames(Page: () => ReactNode) {
+ const harness = createClientLifecycleHarness();
+ const commits = { home: 0 };
+ const onRender: ProfilerOnRenderCallback = (id) => { if (id === "home") commits.home += 1; };
+ useSessionStore.getState().setStreamingReply({ sessionId: SESSION, text: "" });
+ try {
+ await harness.render();
+ await harness.idle();
+ assert.equal(harness.errors.length, 0);
+ commits.home = 0;
+ panelRenders.count = 0;
+ let text = "";
+ for (let frame = 1; frame <= FRAMES; frame += 1) {
+ text += "字字";
+ await harness.update(() => useSessionStore.getState().setStreamingReply({ sessionId: SESSION, text }));
+ }
+ const shown = harness.container.text.includes(text);
+ return { homeCommits: commits.home, panelRenders: panelRenders.count, transcriptShowsText: shown };
+ } finally {
+ await harness.close();
+ useSessionStore.getState().setStreamingReply(null);
+ }
+}
+
+function HomeBefore() {
+ // The pre-#123 wiring: Home subscribes to the whole reply object.
+ const reply = useSessionStore((state) => state.streamingReply);
+ const text = reply?.sessionId === SESSION ? reply.text : "";
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+function HomeAfter() {
+ const hasText = useStreamingReplyHasText(SESSION);
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+test("100 streamed frames: the old wiring re-rendered Home every frame, the panel now not at all", async () => {
+ const before = await streamFrames(HomeBefore);
+ const after = await streamFrames(HomeAfter);
+ assert.ok(before.homeCommits >= FRAMES, `the old wiring committed Home ${before.homeCommits} times`);
+ // The Profiler counts its subtree, so the transcript's own per-frame commits land
+ // under "home" as well; Home's own renders are counted in the next test.
+ assert.equal(after.panelRenders, 0, `panel rendered ${after.panelRenders} times`);
+ assert.equal(after.transcriptShowsText, true, "the streamed text is still on screen");
+ assert.equal(before.transcriptShowsText, true);
+});
+
+test("Home renders at most twice while the transcript follows every frame", async () => {
+ const counts = { home: 0, transcript: 0 };
+ const onTranscript: ProfilerOnRenderCallback = () => { counts.transcript += 1; };
+ function Page() {
+ const hasText = useStreamingReplyHasText(SESSION);
+ // A dependency-free effect runs once per commit of this component itself.
+ useEffect(() => { counts.home += 1; });
+ return (
+ <>
+
+
+ >
+ );
+ }
+ await streamFrames(Page);
+ // Subtract the mount (one commit each).
+ const home = counts.home - 1;
+ assert.ok(home <= 2, `Home rendered ${home} times over ${FRAMES} frames`);
+ assert.ok(home <= FRAMES / 10);
+ assert.ok(counts.transcript - 1 >= FRAMES, "the transcript still repaints every frame");
+});
+
+test("the stop label still knows whether text has arrived", async () => {
+ const seen: boolean[] = [];
+ function Probe() {
+ seen.push(useStreamingReplyHasText(SESSION));
+ return null;
+ }
+ await streamFrames(Probe);
+ assert.equal(seen[0], false);
+ assert.equal(seen.at(-1), true);
+ assert.ok(seen.length <= 3, `probe rendered ${seen.length} times`);
+});
+
+test("Home no longer subscribes to the streaming reply, and the chart split is memoized", () => {
+ const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
+ assert.doesNotMatch(page, /state\.streamingReply\b/);
+ assert.doesNotMatch(page, /\bstreamingReply\b(?!:)/);
+ assert.match(page, /