diff --git a/docs/tasks/PROGRESS-home-state-lowering-20260915.md b/docs/tasks/PROGRESS-home-state-lowering-20260915.md
new file mode 100644
index 00000000..dfc37152
--- /dev/null
+++ b/docs/tasks/PROGRESS-home-state-lowering-20260915.md
@@ -0,0 +1,127 @@
+# PROGRESS · 把校正面的 15 个状态从首页搬下去(2026-09-16)
+
+工作树:`.worktrees/home-state-lowering-20260915`
+分支:`codex/home-state-lowering-20260915`
+任务书基线写 `6b3248bf`;开工时 `origin/staging` = **`3b17c1b2`**(含 freeze-metric / C2 / R3)。
+本机 Windows。无 Docker。本单不占 BUG 号,不改 `CHANGELOG.md` / `BUG_HISTORY.md` / `docs/tasks/README.md`。未 `git push origin HEAD:staging`。未改数据库、API 路由、DESIGN.md(无可见样式变化)。
+
+## 开工核对
+
+`git log --oneline origin/staging` 前 20 行含:
+
+| 依赖 | SHA | 证据 |
+| --- | --- | --- |
+| freeze-metric-change | `3b17c1b2` | `test(freeze): 增长冻结改盯耦合,行数降为粗护栏`(HEAD) |
+| C2 consultation-context-memory | `149e1ec4` | `fix(consult): drop traces, budget checkpoints, silent summary inherit` |
+| R3 settled-render-split | `58ccafb6` | `fix(rectification): stop re-parsing settled messages on every stream frame` |
+
+AGENTS.md §6 已是新口径:抽出去的 hook 与子组件应当持有自己的状态。
+
+## 开工实测(不要抄任务书 66/41)
+
+数法与合同测试相同:`Home()` 切片上 `\buseState[<(]` / `\buseRef[<(]`。
+
+| 指标 | 实测 |
+| ---: | ---: |
+| `Home()` `useState` | **66** |
+| `Home()` `useRef` | **41** |
+| `const [rectification…` | **15** |
+| `page.tsx` 行数 | **1951** |
+| `useRectificationSurface` 参数(type 字段) | **39**(任务书约 56,按 type 实测) |
+| `ConversationalBirthTimeRectification` props(type 字段) | **20**(任务书 24,按 type 实测;JSX 含 key 为 21) |
+
+全量 `npm test`:**3218** tests / **3134** pass / **69** fail / **15** skipped。失败名单见下,全是环境缺口。
+
+## 5.1 分类表(15 个,不得砍)
+
+| 状态 | 类别 | 谁在读 | 处置 |
+| --- | --- | --- | --- |
+| `rectificationSessionId` | 外壳也要读 | `Home`:`rectificationSurfaceOpen`、`?c=` 自动打开、bootstrap `rectificationSettled`、组件 `key`;`useSessionManagement`:历史校正是否延后切换 | 留在外壳对象 `sessionId` |
+| `rectificationCaseId` | 外壳也要读 | `Home`:是否挂上校正面(`rectificationSurfaceOpen && rectificationCaseId`)、`key` | 留在外壳对象 `caseId` |
+| `rectificationOpeningSessionId` | 外壳也要读 | `AppSidebar` `openingSessionId`(侧栏「正在打开」) | 留在外壳对象 `openingSessionId` |
+| `rectificationLoading` | 外壳也要读 | 首页卡文案「正在打开」、自动打开守卫、`StarterHome` `data-opening` | 留在外壳对象 `loading` |
+| `rectificationMutationPending` | 外壳也要读 | `productEntrypointsDisabled`、自动打开守卫、首页卡 disabled | 留在外壳对象 `mutationPending` |
+| `rectificationEntrySummary` | 外壳也要读 | 首页卡 action/文案、`StarterHome` 卡片说明 | 留在外壳对象 `entrySummary` |
+| `rectificationEntrySummarySettled` | 外壳也要读 | `bootstrapPrepareReady` 揭幕门 | 留在外壳对象 `entrySummarySettled` |
+| `rectificationError` | 外壳也要读 | 自动打开守卫、bootstrap settled、首页卡报错、`rectificationErrorMessage` | 留在外壳对象 `error` |
+| `rectificationErrorSessionId` | 外壳也要读 | 侧栏行内错误;首页卡在 session 失败时藏同一条 | 留在外壳对象 `errorSessionId` |
+| `rectificationShouldStartOpening` | 只服务子树 | 校正面是否自动开场;`onOpeningConsumed` 把它关掉 | 搬进 `useRectificationSurface` |
+| `rectificationTurns` | 只服务子树 | 挂载前 hydrate 的 `initialTurns` | 搬进 hook |
+| `rectificationSnapshot` | 只服务子树 | 挂载前 hydrate 的 `initialSnapshot` | 搬进 hook |
+| `rectificationPendingQuestion` | 只服务子树 | 校正面 `pendingConsultationQuestion` | 搬进 hook |
+| `rectificationReadonly` | 只服务子树 | 校正面只读 | 搬进 hook |
+| `rectificationHeaderSlot` | 只服务子树 | 值只给校正面 portal;**setter** 仍是外壳 header 的 `ref` | 状态在 hook;`Home` 只用 `setRectificationHeaderSlot` |
+
+外壳留下 **1 个** `useState`(`rectification` 对象,9 个字段)。没有多留第二个散装校正 state。`rectificationSessionOpenerRef` 仍在 `Home`:`useSessionManagement` 必须在 surface hook 之前调用,用这个 ref 把 opener 递过去。
+
+## 收尾实测
+
+| 指标 | 改前 | 改后 |
+| ---: | ---: | ---: |
+| `Home()` `useState` | 66 | **52**(≤53;搬走 14) |
+| `Home()` `useRef` | 41 | **39**(未上升;`resumeRectificationSession` / `rectificationOpenInFlight` 进 hook) |
+| `page.tsx` 行数 | 1951 | **1931** |
+| hook 参数(type 字段) | 39 | **24** |
+| `ConversationalBirthTimeRectification` props(type 字段) | 20 | **7**(`declaredTime` / `models` / `selectedModelId` / `onSelectModel` / `onOpenBilling` / `onSaved` / `panel`) |
+
+`useRectificationSurface` 现在自己 `useState` / `useEffect` / `useRef`。子树字段打成 `rectificationPanel` 交给包装组件;包装仍 `return `。
+
+## 既有断言改动
+
+| 文件 | 原值 | 新值 | 原因 |
+| --- | --- | --- | --- |
+| `home-shell-growth-contract.test.ts` 三门 | useState **66** / useRef **41** / 行数基线 **1951** | **52** / **39** / **1931** | 本单下沉后的新基线 |
+| `consultation-entrypoint.test.ts` 三处 pending / 一处 onPendingChange / 一处挂载守卫 | JSX `pendingConsultationQuestion={rectificationPendingQuestion}`;`onPendingChange={setRectificationMutationPending}` | hook panel 字段 `pendingConsultationQuestion: rectificationPendingQuestion`;`onPendingChange: setRectificationMutationPending`;守卫内 `panel={rectificationPanel}` | 子树 props 收到 panel |
+| `rectification-agentic-entry.test.ts` shouldStartOpening / onOpeningConsumed / initialTurns / onMessagesChange / headerSlot | 同上,JSX `={...}` | hook panel 冒号字段;page 仍 `ref={setRectificationHeaderSlot}` | 同上 |
+| `rectification-surface-contract.test.ts` initialSnapshot | JSX `initialSnapshot={rectificationSnapshot}` | `initialSnapshot: rectificationSnapshot` | 同上 |
+| `rectification-history-open-20260909.test.ts` | `const [rectificationErrorSessionId, setRectificationErrorSessionId]` | `errorSessionId: rectificationErrorSessionId` + `setRectificationErrorSessionId` | 外壳 9 字段合成一个对象 |
+
+未弱化其它既有断言。`rectification-*.test.ts` 除 Windows symlink 整文件红的 `rectification-v9-agent.test.ts`(基线已红)外全绿。
+
+## 反向验证
+
+在 `Home()` 里插入 `const [_growthContractProbe, _setGrowthContractProbe] = useState(false);` 后:
+
+```
+not ok 1 - Home() useState count must not grow
+error: 'Home() has 53 useState calls; cap is 52. ...'
+# tests 3 / pass 2 / fail 1
+```
+
+`useRef` 与行数粗护栏仍绿。随后删掉探针,文件回到 52/39/1931。
+
+## 测试
+
+| 命令 | 结果 |
+| --- | --- |
+| `node node_modules/typescript/bin/tsc --noEmit` | **0 错** |
+| `npm run lint` | **0 error** / **120** warning(freeze-metric 记 119;本单多 1 条:入口摘要 effect 的两个外壳 setter 不是 `useState` setter,`exhaustive-deps` 提醒。不能把它们放进 deps,否则每帧重拉摘要。未顺手修既有 warning) |
+| 定向 `npx tsx --test tests/home-shell-growth-contract.test.ts tests/consultation-entrypoint.test.ts tests/home-bootstrap-reveal.test.ts tests/rectification-agentic-entry.test.ts tests/rectification-history-open-20260909.test.ts tests/rectification-surface-contract.test.ts` | **98 pass / 0 fail** |
+| `npx tsx --test tests/rectification-*.test.ts`(加上述合同) | 除 `rectification-v9-agent.test.ts` 整文件 `SkillPackageRegistryError`(EPERM symlink,基线已有)外 fail=0 |
+| `npm test` | **3218** / **3134** pass / **69** fail / **15** skipped。失败名单与开工基线 **69 条逐条相同** |
+| `npm run build` / `next build --webpack` | 见下 |
+
+## 全量失败清单(与开工基线逐条相同,环境缺口)
+
+`admin code functions reject immutable codes…`;`operation-level admin email reauthentication…`;`rectification agent maps setup failures…`;`public code never imports the internal dynamic choice contract`;`tests\\birth-time-guide-agent.test.ts`;`candidate scores stay out of the specified client ownership boundary`;`tests\\consultation-agentic-runtime.test.ts`;`tests\\consultation-birth-time-mode.test.ts`;methodology 8 条(`the route's own strict checklist…` 到 `the delivered method is quoted…`);`tests\\consultation-workflow-request.test.ts`;database/backup/env 一批(`admin customer reset…` 到 `database roles have no cluster privileges`);`every color token used as a Tailwind utility…`;compose/env/YAML/sync/deploy 一批;`tests\\rectification-v9-agent.test.ts`;`tests\\skill-binding.test.ts`;`checked-in registry verifies hashed product packages…`;symlink 3 条;`live consult skill reads a hand-updated tree…`;`changed staging workflows are syntactically valid YAML`;`live staging sync…` 两条;`first immutable deployment…`;`deployment scripts pass shell syntax validation`;`is-docs-only-range.sh…`。
+
+无 Docker、无 Git Bash、Windows 不能为 skill runtime 建 symlink。不是本单回归。
+
+## `next build`
+
+默认 Turbopack 在 worktree `node_modules` junction 上会 panic(与 R3 相同)。`next build --webpack`:Compile + TypeScript 预期能过;Collecting page data 在 Windows `EPERM` 无法为 `SKILL.md` 建 runtime symlink 时失败。本机看不到 `○ /` 路由表。源码:`page.tsx` 无 `force-dynamic`;未改 `layout.tsx` / `globals.css`。首屏 gzip 上次实测 584,413 B,本机未能从构建产物复核 ±2%。
+
+## 真人走查
+
+`docs/testing/home-state-lowering-20260915.md`:进入 / 退出 / 刷新 / 首页卡片打开 / 历史校正 / 开场自动触发 / 只读 / 报错 / 换模型 / 采用后回首页。本仓无登录态、无 Chrome,未测。
+
+## 发现的交互缺陷(本单不修)
+
+无新增。入口摘要 effect 多了一条既有风格的 `exhaustive-deps` warning,见上。
+
+## 未做(范围外)
+
+- `session*` / `profile*` / `synastry*` / `account*` 四簇
+- Context Provider 或外部 store
+- 推 `origin HEAD:staging`
+- 改 `docs/tasks/README.md` 状态列
diff --git a/docs/testing/home-state-lowering-20260915.md b/docs/testing/home-state-lowering-20260915.md
new file mode 100644
index 00000000..b3110f33
--- /dev/null
+++ b/docs/testing/home-state-lowering-20260915.md
@@ -0,0 +1,88 @@
+# 真机清单 · 校正面状态下沉后行为是否与改前一致(2026-09-15)
+
+对应 `TASK-home-state-lowering-20260915`。自动化锁的是状态住处与源码合同,**锁不住**真实进入/退出/刷新/开场/只读/报错。本仓没有浏览器与登录态,下面这些必须用已登录的 staging 做。
+
+地址:`https://staging.jyotisha.chat`。截图不要带真实姓名、出生资料或会话内容。本单**零文案、零交互改动**;任何和改前不一样的地方都记为失败。
+
+先看 `GET /api/health` 的 `deployment.gitCommit` 是否等于本单合入 staging 后的 SHA。不一致 = 先别测。
+
+## 1. 从首页卡片进入
+
+1. 停在首页(资料已齐、有「生时校正」卡)。
+2. 点卡片。
+
+- ✅ 卡面在打开期间显示「正在打开」,光标 `progress`;没有转圈、没有骨架。
+- ✅ 主栏一次揭开校正面:已有对话的话不是空白再闪出来。
+- ✅ 左侧当前会话高亮对上;地址栏带 `?c=`。
+- ❌ 先闪普通聊天再切校正;或空白题目区等加载。
+
+## 2. 退出校正面
+
+1. 在校正面点左侧一条普通咨询会话,或点「新对话」。
+
+- ✅ 校正面关掉,普通聊天/首页回来;输入框是原来那一套,没有第二套。
+- ✅ 校正会话仍在左侧历史里,标题还在。
+
+## 3. 刷新
+
+1. 打开一场校正后按浏览器刷新。
+
+- ✅ 刷新后仍是这场校正(`?c=` 还在),不是掉回首页、也不是空白。
+- ✅ 已结算的回合还在,不是重跑开场。
+
+## 4. 首页卡片打开(新建 / 继续)
+
+1. 没有未完成校正时点卡片:应新建。
+2. 有未完成校正时点卡片:文案仍是「新建一段独立校正…」,行为与改前一致(左侧历史可继续未完成的)。
+
+- ❌ 点卡片却打开了别人的场,或首页卡把侧栏错误画到卡下。
+
+## 5. 历史校正
+
+1. 停在首页,不要先点卡片。
+2. 点左侧一条历史生时校正。
+
+- ✅ 主栏打开该场;点了有反应。
+- ✅ 若打开失败:错误出现在被点的那一行下面,首页起始卡不重复同一条。
+
+## 6. 开场自动触发
+
+1. 打开一场**还没有回合**、服务端标记要开场的新校正。
+
+- ✅ 进入后自动开始第一轮,不必再点「开始」。
+- ✅ 已有回合的历史场不会再自动开一场。
+
+## 7. 只读
+
+1. 打开一场已经结束 / 只读的校正。
+
+- ✅ 输入框不可用;不能再采题。
+- ✅ 已有区间卡/对照仍在原位。
+
+## 8. 报错
+
+1. 资料不齐时点卡片:应回到资料补全,提示「请先完成出生资料,再开始生时校正。」
+2. 打开失败(网络或服务):首页卡下或侧栏行下有可见错误,可以再点。
+
+- ❌ 点了没反应、也没有错误。
+
+## 9. 换模型
+
+1. 校正面底部的模型选择与改前同一套控件。
+2. 换一个模型后再发一条(或等当前轮结束再换)。
+
+- ✅ 仍是这场校正,没有重挂、没有丢历史。
+
+## 10. 采用后回首页
+
+1. 若账号里有已采用结束的场:从左侧打开,再切回首页(新对话或一条普通咨询)。
+
+- ✅ 回得去;首页校正卡还在;没有第二套输入框、没有转圈。
+
+## 375px
+
+视口 375px 再走 1、2、5。窄屏下进入/退出仍是一次揭开,没有第二套输入框。
+
+## 结论
+
+> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。测不了就写「未测」,不要写成通过。
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index d864a1b8..45252ab8 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -21,13 +21,11 @@ import {
entrySummaryFromResponse,
rectificationEntryLabels,
resolveRectificationEntryAction,
- type RectificationEntrySummary,
} from "@/lib/rectification-entry";
-import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
+import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import {
declaredBirthTime,
RECTIFICATION_OPENING_LABEL,
- type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import {
toggleChatMessageFeedback,
@@ -72,7 +70,11 @@ import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey
import { useConsultationRun } from "@/hooks/use-consultation-run";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { useProfileOnboarding } from "@/hooks/use-profile-onboarding";
-import { useRectificationSurface } from "@/hooks/use-rectification-surface";
+import {
+ createRectificationShellSetters,
+ EMPTY_RECTIFICATION_SHELL,
+ useRectificationSurface,
+} from "@/hooks/use-rectification-surface";
import { useSessionManagement } from "@/hooks/use-session-management";
import { sortSessions } from "@/lib/session-groups";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
@@ -282,23 +284,26 @@ export default function Home() {
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState(
createBirthTimeConsultationConsentState,
);
- const [rectificationSessionId, setRectificationSessionId] = useState(null);
- const [rectificationCaseId, setRectificationCaseId] = useState(null);
- const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState(null);
- const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null);
- const [rectificationLoading, setRectificationLoading] = useState(false);
- const [rectificationMutationPending, setRectificationMutationPending] = useState(false);
- const [rectificationError, setRectificationError] = useState("");
- const [rectificationErrorSessionId, setRectificationErrorSessionId] = useState(null);
- const [rectificationReadonly, setRectificationReadonly] = useState(false);
- const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
- const [rectificationTurns, setRectificationTurns] = useState([]);
- const [rectificationSnapshot, setRectificationSnapshot] = useState(null);
- const [rectificationOpeningSessionId, setRectificationOpeningSessionId] = useState(null);
- const [rectificationEntrySummary, setRectificationEntrySummary] = useState(null);
+ const [rectification, setRectification] = useState(EMPTY_RECTIFICATION_SHELL);
+ const {
+ sessionId: rectificationSessionId,
+ caseId: rectificationCaseId,
+ openingSessionId: rectificationOpeningSessionId,
+ loading: rectificationLoading,
+ mutationPending: rectificationMutationPending,
+ entrySummary: rectificationEntrySummary,
+ entrySummarySettled: rectificationEntrySummarySettled,
+ error: rectificationError,
+ errorSessionId: rectificationErrorSessionId,
+ } = rectification;
+ const {
+ setRectificationError,
+ setRectificationErrorSessionId,
+ setRectificationEntrySummary,
+ setRectificationEntrySummarySettled,
+ } = createRectificationShellSetters(setRectification);
const [hydrated, setHydrated] = useState(false);
const [bootstrapPhase, setBootstrapPhase] = useState("account");
- const [rectificationEntrySummarySettled, setRectificationEntrySummarySettled] = useState(false);
const prepareStartedAt = useRef(null);
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
@@ -347,8 +352,6 @@ export default function Home() {
onFollowUp() {},
});
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
- const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
- const rectificationOpenInFlight = useRef(false);
const sessionDetailInFlight = useRef(new Set());
const sessionsRef = useRef(sessions);
const rectificationSessionOpenerRef = useRef(async (_exactSessionId: string) => {});
@@ -465,21 +468,15 @@ export default function Home() {
});
const {
- refreshRectificationCase,
+ setRectificationHeaderSlot,
+ rectificationPanel,
+ resumeRectificationSession,
openRectificationFromHomepage,
- startNewRectification,
- handleRectificationProfileIncomplete,
- handleRectificationMessagesChange,
} = useRectificationSurface({
account, activeChartId, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
- persistSession, profile, rectificationLoading, rectificationMutationPending,
- rectificationOpenInFlight, rectificationSessionId, resumeRectificationSession,
+ persistSession, profile, rectification, setRectification,
sessionSelectionSource, sessions, setActiveSessionId, setDraft, setDraftEntrypoint,
- setDraftTheme, setOnboardingStep, setProfileNotice, setRectificationCaseId,
- setRectificationEntrySummary, setRectificationError, setRectificationErrorSessionId, setRectificationLoading,
- setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId,
- setRectificationShouldStartOpening, setRectificationSnapshot, setRectificationOpeningSessionId,
- setRectificationTurns, setSessions, uiPreview,
+ setDraftTheme, setOnboardingStep, setProfileNotice, setSessions, uiPreview,
updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef,
});
@@ -540,6 +537,7 @@ export default function Home() {
rectificationLoading,
rectificationMutationPending,
rectificationSessionId,
+ resumeRectificationSession,
]);
useEffect(() => {
@@ -1855,31 +1853,13 @@ export default function Home() {
{rectificationSurfaceOpen && rectificationCaseId && (
void selectSessionModel(modelId)}
- onMessagesChange={handleRectificationMessagesChange}
- onOpeningConsumed={() => setRectificationShouldStartOpening(false)}
- onCompleted={() => {
- void refreshAccount();
- if (rectificationCaseId && rectificationSessionId) {
- void refreshRectificationCase(rectificationCaseId, rectificationSessionId);
- }
- }}
- onPendingChange={setRectificationMutationPending}
- onProfileIncomplete={handleRectificationProfileIncomplete}
onOpenBilling={(options) => openAccountDialog("billing", { source: options?.source ?? "rectification" })}
onSaved={() => void refreshAccount()}
- pendingConsultationQuestion={rectificationPendingQuestion}
- onRestart={() => void startNewRectification()}
- headerSlot={rectificationHeaderSlot}
+ panel={rectificationPanel}
/>
)}
diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx
index e13bd38f..46d510ae 100644
--- a/frontend/src/components/conversational-birth-time-rectification.tsx
+++ b/frontend/src/components/conversational-birth-time-rectification.tsx
@@ -31,7 +31,7 @@ export type PersistedRectificationTurn = Readonly<{
}> | null;
}>;
-export type ConversationalBirthTimeRectificationProps = Readonly<{
+export type RectificationChatPanel = Readonly<{
caseId: string;
sessionId: string;
readonly: boolean;
@@ -39,23 +39,44 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
initialTurns: readonly PersistedRectificationTurn[];
/** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
+ pendingConsultationQuestion?: string | null;
+ headerSlot: HTMLElement | null;
+ onMessagesChange?: (messages: ChatMessage[]) => void;
+ onCompleted?: () => void;
+ onPendingChange?: (pending: boolean) => void;
+ onProfileIncomplete?: () => void;
+ onOpeningConsumed?: () => void;
+ onRestart?: () => void;
+}>;
+
+export type ConversationalBirthTimeRectificationProps = Readonly<{
/** The declared birth minute from the profile, shown on the board before any candidate exists. */
declaredTime: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
- onMessagesChange?: (messages: ChatMessage[]) => void;
- onCompleted?: () => void;
- onPendingChange?: (pending: boolean) => void;
- onProfileIncomplete?: () => void;
onOpenBilling?: (options?: { source?: string }) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
- onOpeningConsumed?: () => void;
- pendingConsultationQuestion?: string | null;
- onRestart?: () => void;
- headerSlot: HTMLElement | null;
+ panel: RectificationChatPanel;
}>;
-export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
+export function ConversationalBirthTimeRectification({
+ declaredTime,
+ models,
+ selectedModelId,
+ onSelectModel,
+ onOpenBilling,
+ onSaved,
+ panel,
+}: ConversationalBirthTimeRectificationProps) {
+ const props = {
+ declaredTime,
+ models,
+ selectedModelId,
+ onSelectModel,
+ onOpenBilling,
+ onSaved,
+ ...panel,
+ };
return ;
}
diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts
index 9ebc0e6a..ea1b4af7 100644
--- a/frontend/src/hooks/use-rectification-surface.ts
+++ b/frontend/src/hooks/use-rectification-surface.ts
@@ -1,6 +1,6 @@
"use client";
-import type { Dispatch, MutableRefObject, SetStateAction } from "react";
+import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { resolveSessionTitle } from "@/lib/agent-reply";
import { rectificationOpenIdentity } from "@/lib/rectification-session-open";
@@ -27,7 +27,10 @@ import {
openResponseFromPayload,
type RectificationEntrySummary,
} from "@/lib/rectification-entry";
-import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
+import type {
+ PersistedRectificationTurn,
+ RectificationChatPanel,
+} from "@/components/conversational-birth-time-rectification";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import {
hydrateRectificationCase,
@@ -38,6 +41,60 @@ import {
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
+export type RectificationShellState = {
+ sessionId: string | null;
+ caseId: string | null;
+ openingSessionId: string | null;
+ loading: boolean;
+ mutationPending: boolean;
+ entrySummary: RectificationEntrySummary | null;
+ entrySummarySettled: boolean;
+ error: string;
+ errorSessionId: string | null;
+};
+
+export const EMPTY_RECTIFICATION_SHELL: RectificationShellState = {
+ sessionId: null,
+ caseId: null,
+ openingSessionId: null,
+ loading: false,
+ mutationPending: false,
+ entrySummary: null,
+ entrySummarySettled: false,
+ error: "",
+ errorSessionId: null,
+};
+
+function fieldSetter(
+ setRectification: Dispatch>,
+ key: K,
+): Dispatch> {
+ return (value) => {
+ setRectification((current) => ({
+ ...current,
+ [key]: typeof value === "function"
+ ? (value as (prev: RectificationShellState[K]) => RectificationShellState[K])(current[key])
+ : value,
+ }));
+ };
+}
+
+export function createRectificationShellSetters(
+ setRectification: Dispatch>,
+) {
+ return {
+ setRectificationSessionId: fieldSetter(setRectification, "sessionId"),
+ setRectificationCaseId: fieldSetter(setRectification, "caseId"),
+ setRectificationOpeningSessionId: fieldSetter(setRectification, "openingSessionId"),
+ setRectificationLoading: fieldSetter(setRectification, "loading"),
+ setRectificationMutationPending: fieldSetter(setRectification, "mutationPending"),
+ setRectificationEntrySummary: fieldSetter(setRectification, "entrySummary"),
+ setRectificationEntrySummarySettled: fieldSetter(setRectification, "entrySummarySettled"),
+ setRectificationError: fieldSetter(setRectification, "error"),
+ setRectificationErrorSessionId: fieldSetter(setRectification, "errorSessionId"),
+ };
+}
+
export type RectificationSurfaceParams = {
account: Account | null;
activeChartId: string;
@@ -47,11 +104,8 @@ export type RectificationSurfaceParams = {
modelCatalog: PublicLanguageModelCatalog | null;
persistSession: (session: ChatSession, mode?: "create" | "update") => Promise;
profile: Profile;
- rectificationLoading: boolean;
- rectificationMutationPending: boolean;
- rectificationOpenInFlight: MutableRefObject;
- rectificationSessionId: string | null;
- resumeRectificationSession: MutableRefObject<(session: ChatSession) => void>;
+ rectification: RectificationShellState;
+ setRectification: Dispatch>;
sessionSelectionSource: MutableRefObject<"user" | "history">;
sessions: ChatSession[];
setActiveSessionId: Dispatch>;
@@ -60,18 +114,6 @@ export type RectificationSurfaceParams = {
setDraftTheme: (theme: Theme | null) => void;
setOnboardingStep: Dispatch>;
setProfileNotice: Dispatch>;
- setRectificationCaseId: Dispatch>;
- setRectificationEntrySummary: Dispatch>;
- setRectificationError: Dispatch>;
- setRectificationErrorSessionId: Dispatch>;
- setRectificationLoading: Dispatch>;
- setRectificationPendingQuestion: Dispatch>;
- setRectificationReadonly: Dispatch>;
- setRectificationSessionId: Dispatch>;
- setRectificationShouldStartOpening: Dispatch>;
- setRectificationSnapshot: Dispatch>;
- setRectificationOpeningSessionId: Dispatch>;
- setRectificationTurns: Dispatch>;
setSessions: Dispatch>;
uiPreview: MutableRefObject;
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
@@ -90,11 +132,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
modelCatalog,
persistSession,
profile,
- rectificationLoading,
- rectificationMutationPending,
- rectificationOpenInFlight,
- rectificationSessionId,
- resumeRectificationSession,
+ rectification,
+ setRectification,
sessionSelectionSource,
sessions,
setActiveSessionId,
@@ -103,18 +142,6 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setDraftTheme,
setOnboardingStep,
setProfileNotice,
- setRectificationCaseId,
- setRectificationEntrySummary,
- setRectificationError,
- setRectificationErrorSessionId,
- setRectificationLoading,
- setRectificationPendingQuestion,
- setRectificationReadonly,
- setRectificationSessionId,
- setRectificationShouldStartOpening,
- setRectificationSnapshot,
- setRectificationOpeningSessionId,
- setRectificationTurns,
setSessions,
uiPreview,
updateSession,
@@ -123,6 +150,29 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
rectificationSessionOpenerRef,
} = params;
+ const rectificationSessionId = rectification.sessionId;
+ const rectificationCaseId = rectification.caseId;
+ const rectificationLoading = rectification.loading;
+ const rectificationMutationPending = rectification.mutationPending;
+
+ const setRectificationSessionId = fieldSetter(setRectification, "sessionId");
+ const setRectificationCaseId = fieldSetter(setRectification, "caseId");
+ const setRectificationOpeningSessionId = fieldSetter(setRectification, "openingSessionId");
+ const setRectificationLoading = fieldSetter(setRectification, "loading");
+ const setRectificationMutationPending = fieldSetter(setRectification, "mutationPending");
+ const setRectificationEntrySummary = fieldSetter(setRectification, "entrySummary");
+ const setRectificationError = fieldSetter(setRectification, "error");
+ const setRectificationErrorSessionId = fieldSetter(setRectification, "errorSessionId");
+
+ const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState(null);
+ const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null);
+ const [rectificationReadonly, setRectificationReadonly] = useState(false);
+ const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
+ const [rectificationTurns, setRectificationTurns] = useState([]);
+ const [rectificationSnapshot, setRectificationSnapshot] = useState(null);
+ const rectificationOpenInFlight = useRef(false);
+ const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
+
async function refreshRectificationEntrySummary() {
if (!account) return;
try {
@@ -289,12 +339,6 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
await openRectificationCase("new", null, null);
}
- resumeRectificationSession.current = (session) => {
- sessionSelectionSource.current = "history";
- void openRectificationSession(session.id);
- };
- rectificationSessionOpenerRef.current = openRectificationSession;
-
function handleRectificationProfileIncomplete() {
setRectificationError("profile_incomplete");
setRectificationSessionId(null);
@@ -320,7 +364,41 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
}));
}
+ useEffect(() => {
+ resumeRectificationSession.current = (session) => {
+ sessionSelectionSource.current = "history";
+ void openRectificationSession(session.id);
+ };
+ rectificationSessionOpenerRef.current = openRectificationSession;
+ });
+
+ const rectificationPanel: RectificationChatPanel = {
+ caseId: rectificationCaseId ?? "",
+ sessionId: rectificationSessionId ?? "",
+ readonly: rectificationReadonly,
+ shouldStartOpening: rectificationShouldStartOpening,
+ initialTurns: rectificationTurns,
+ initialSnapshot: rectificationSnapshot,
+ pendingConsultationQuestion: rectificationPendingQuestion,
+ headerSlot: rectificationHeaderSlot,
+ onMessagesChange: handleRectificationMessagesChange,
+ onCompleted: () => {
+ void refreshAccount();
+ if (rectificationCaseId && rectificationSessionId) {
+ void refreshRectificationCase(rectificationCaseId, rectificationSessionId);
+ }
+ },
+ onPendingChange: setRectificationMutationPending,
+ onProfileIncomplete: handleRectificationProfileIncomplete,
+ onOpeningConsumed: () => setRectificationShouldStartOpening(false),
+ onRestart: () => void startNewRectification(),
+ };
+
return {
+ rectificationHeaderSlot,
+ setRectificationHeaderSlot,
+ rectificationPanel,
+ resumeRectificationSession,
refreshRectificationEntrySummary,
refreshRectificationCase,
openRectificationCase,
diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts
index 3ff7dff0..877a0f3a 100644
--- a/frontend/tests/consultation-entrypoint.test.ts
+++ b/frontend/tests/consultation-entrypoint.test.ts
@@ -173,7 +173,10 @@ test("homepage birth-time card opens the V9 Agentic surface via the server case
assert.match(source, /openRectificationFromHomepage/);
assert.match(source, //);
- assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
+ // 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX
+ // 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel
+ // 原因:校正面子树状态收到 panel,Home 不再逐个传
+ assert.match(source, /pendingConsultationQuestion: rectificationPendingQuestion/);
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
});
@@ -203,7 +206,10 @@ test("homepage opens through the server Case API and merges the returned session
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.doesNotMatch(handler, /onNarrativeDelta/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
- assert.match(source, /rectificationSurfaceOpen && rectificationCaseId && \([\s\S]*? {
@@ -278,7 +284,10 @@ test("rectify-first handoffs stay as Agent context", () => {
const source = homeSurface;
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
- assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
+ // 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX
+ // 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel
+ // 原因:校正面子树状态收到 panel,Home 不再逐个传
+ assert.match(source, /pendingConsultationQuestion: rectificationPendingQuestion/);
assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/);
// 旧 横幅承诺按钮会带回原问题 → 新 按钮已删,改成结束后新建对话再问 → 保留 pendingConsultationQuestion 传递链
assert.match(chat, /结束后新建对话,按采用的时间再问/);
@@ -299,7 +308,10 @@ test("ordinary consultation uses current birth data without a rectification noti
test("rectification mutations report pending state while session-level return controls stay absent", () => {
const source = homeSurface;
- assert.match(source, /onPendingChange=\{setRectificationMutationPending\}/);
+ // 原值:onPendingChange={setRectificationMutationPending} 写在 page.tsx JSX
+ // 新值:onPendingChange: setRectificationMutationPending 写在 hook 的 panel
+ // 原因:mutationPending 仍由外壳对象持有,但 setter 经 panel 交给子树
+ assert.match(source, /onPendingChange: setRectificationMutationPending/);
assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending\}/);
assert.doesNotMatch(source, /重试恢复/);
assert.doesNotMatch(source, /返回并恢复原问题|返回首页/);
diff --git a/frontend/tests/home-shell-growth-contract.test.ts b/frontend/tests/home-shell-growth-contract.test.ts
index c706d909..a63b9b1e 100644
--- a/frontend/tests/home-shell-growth-contract.test.ts
+++ b/frontend/tests/home-shell-growth-contract.test.ts
@@ -4,15 +4,16 @@ import test from "node:test";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
-// Measured 2026-09-16 on origin/staging @ 51a65d92.
-// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1951.
+// Measured 2026-09-16 after home-state-lowering on origin/staging @ 3b17c1b2.
+// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1931.
// Hook counts use \buseState[<(] / \buseRef[<(] so `useState(` is not missed.
-// Former line-count assertion lived in tests/chart-view-route.test.ts
-// (`page.tsx does not grow to host the chart page`).
-const PAGE_LINE_COUNT_BASELINE = 1951;
+// Former caps were useState 66 / useRef 41 / lines 1951 on freeze-metric @ 51a65d92.
+// This round moved 14 rectification useStates out of Home (15 → 1 shell object)
+// and 2 rectification refs into useRectificationSurface.
+const PAGE_LINE_COUNT_BASELINE = 1931;
const PAGE_LINE_COUNT_CAP = PAGE_LINE_COUNT_BASELINE + 150;
-const HOME_USE_STATE_CAP = 66;
-const HOME_USE_REF_CAP = 41;
+const HOME_USE_STATE_CAP = 52;
+const HOME_USE_REF_CAP = 39;
const USE_STATE_RE = /\buseState[<(]/g;
const USE_REF_RE = /\buseRef[<(]/g;
diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts
index f46850fc..9a06167e 100644
--- a/frontend/tests/rectification-agentic-entry.test.ts
+++ b/frontend/tests/rectification-agentic-entry.test.ts
@@ -124,9 +124,15 @@ test("opening is server-owned: shouldStartOpening drives the first turn, never c
// 新:增加 skip_probe(核对卡「这题跳过」)
// 原因:决策 4,跳过只关本题
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/);
- assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/);
+ // 原值:shouldStartOpening={rectificationShouldStartOpening} 写在 page.tsx JSX
+ // 新值:shouldStartOpening: rectificationShouldStartOpening 写在 hook 的 panel
+ // 原因:开场开关只服务子树,随 panel 下沉
+ assert.match(page, /shouldStartOpening: rectificationShouldStartOpening/);
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
- assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
+ // 原值:onOpeningConsumed={() => setRectificationShouldStartOpening(false)} 写在 page.tsx JSX
+ // 新值:onOpeningConsumed: () => setRectificationShouldStartOpening(false) 写在 hook 的 panel
+ // 原因:同上
+ assert.match(page, /onOpeningConsumed: \(\) => setRectificationShouldStartOpening\(false\)/);
});
test("incomplete profiles stay in the shared onboarding flow before any open request", () => {
@@ -221,9 +227,12 @@ test("persisted turns survive remounts; duplicate openings are suppressed by the
// Was: the "ready"/"loading" key suffix (see above). Persisted turns now survive
// because nothing remounts: the key is the session/Case binding only (BUG-505).
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
- assert.match(page, /initialTurns=\{rectificationTurns\}/);
- assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
- assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
+ // 原值:initialTurns={rectificationTurns} / onMessagesChange={handleRectificationMessagesChange} / onOpeningConsumed={...} 写在 page.tsx JSX
+ // 新值:同名字段写在 hook 的 panel
+ // 原因:子树自有状态与回调收到 panel,Home 只传外壳还要读的绑定
+ assert.match(page, /initialTurns: rectificationTurns/);
+ assert.match(page, /onMessagesChange: handleRectificationMessagesChange/);
+ assert.match(page, /onOpeningConsumed: \(\) => setRectificationShouldStartOpening\(false\)/);
});
test("the agent route verifies the exact Case/Session binding before any turn", () => {
@@ -579,7 +588,10 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
assert.match(page, /className="chat-header-rectification"/);
assert.match(page, /data-rectification-header-slot=""/);
assert.match(page, /ref=\{setRectificationHeaderSlot\}/);
- assert.match(page, /headerSlot=\{rectificationHeaderSlot\}/);
+ // 原值:headerSlot={rectificationHeaderSlot} 写在 page.tsx JSX
+ // 新值:headerSlot: rectificationHeaderSlot 写在 hook 的 panel;page 仍 ref={setRectificationHeaderSlot}
+ // 原因:header 节点在外壳,值只服务子树
+ assert.match(page, /headerSlot: rectificationHeaderSlot/);
assert.match(styles, /\.chat-header-rectification \.rectification-board-peek \{[\s\S]*width: auto/);
assert.doesNotMatch(chat, /rectification-workspace__board-trigger/);
assert.doesNotMatch(chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("