From f8e607c29a1b5f06e9610741f7b2e855b3ee5076 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Wed, 16 Sep 2026 08:33:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor(home):=20=E6=A0=A1=E6=AD=A3=E9=9D=A2?= =?UTF-8?q?=2015=20=E4=B8=AA=E7=8A=B6=E6=80=81=E4=BB=8E=20Home=20=E4=B8=8B?= =?UTF-8?q?=E6=B2=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home() useState 66->52, useRef 41->39. Shell-facing fields merge into one object; subtree state lives in useRectificationSurface. Chat props 20->7. Growth contract rebaselined. Zero behavior/copy change. --- .../PROGRESS-home-state-lowering-20260915.md | 127 ++++++++++++++ docs/testing/home-state-lowering-20260915.md | 88 ++++++++++ frontend/src/app/page.tsx | 82 ++++----- ...onversational-birth-time-rectification.tsx | 41 +++-- .../src/hooks/use-rectification-surface.ts | 162 +++++++++++++----- .../tests/consultation-entrypoint.test.ts | 20 ++- .../tests/home-shell-growth-contract.test.ts | 15 +- .../tests/rectification-agentic-entry.test.ts | 24 ++- ...ectification-history-open-20260909.test.ts | 6 +- .../rectification-surface-contract.test.ts | 5 +- 10 files changed, 448 insertions(+), 122 deletions(-) create mode 100644 docs/tasks/PROGRESS-home-state-lowering-20260915.md create mode 100644 docs/testing/home-state-lowering-20260915.md 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("