Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
9.6 KiB
TASK · 账户弹窗被 inert 罩住点不动 + 校正快照失败两端静默(2026-09-18 第八轮)
基线:
origin/staging@1061514f(代码部分 = 已部署的877128ce)。 触发:产品真机反馈「弹窗都没法点击,我都没法点击退出登录」;同一会话里生时校正在「已记录。这题先不计分,换一件事问。」之后再无下文,右侧盘面与时间轴候选也没有。刷新后弹窗仍点不动。 BUG 编号起点:当前最大号 BUG-965;TASK-secondary-page-entry-20260918.md已占 966~967,本单占 BUG-968 ~ BUG-969。
0. 事故实证
0.1 弹窗点不动(BUG-968,P0,代码级确认)
| 位置 | 事实 |
|---|---|
src/app/(app)/layout.tsx AppShell |
<SidebarInset inert={registration?.insetInert}>{children}</SidebarInset>,整个页面正文(children)都在这个 section 里 |
src/hooks/use-home-shell-registration.ts registerShellControls |
insetInert: modalOpen |
src/app/(app)/page.tsx modalOpen |
activeAccountDialog !== null || onboardingPaywallOpen |
src/app/(app)/page.tsx 渲染 <AccountDialogOverlay> |
在页面组件 JSX 里,也就是在 SidebarInset 之内 |
src/components/account-dialog-overlay.tsx |
纯 <div className="account-modal-overlay"> + <section role="dialog">,没有 createPortal |
src/components/ui/sidebar.tsx SidebarInset |
inert={Boolean(inert) || (isMobile && openMobile)} |
链条:点侧栏「个人资料 / 图库 / 设置 / 点数 / 退出登录」→ activeAccountDialog 非空 → modalOpen=true → SidebarInset 整块 inert → 弹窗自己也在 inert 子树里 → 关闭按钮、表单、「确认退出」全部不可点、不可聚焦。正文区同样全部死掉。window 级的 Esc 监听(page.tsx closeOnEscape)还能关掉弹窗,所以用户按 Esc 能脱困,但退出登录做不了。
回归点:e4e73f56(2026-09-17,「四个页面共用一份会话列表」)把 SidebarInset 从 page.tsx 搬进 (app)/layout.tsx。搬之前 page.tsx 是 <SidebarInset inert={modalOpen}>…</SidebarInset> 之后才渲染 <AccountDialogOverlay>,弹窗在 inert 之外;搬之后弹窗变成 children 的一部分,落进了 inert 里。877128ce 含这个提交,staging 上 100% 复现,与标签页新旧无关(BUG-965 那条是另一个问题)。
0.2 校正快照拿不到,用户被留在开场状态(BUG-969,P1,investigating)
真实 Case(staging,2026-09-18 17:41–17:49)服务端链路逐段核过:
- 开场采集题 → 两轮口述经历(各 3 步,末步
rectification-set-focus;第一轮已把采集焦点resolved,并挂出服务端财务探针 2024-08,绑在该轮回复上)。 - 「想不到了」走
route.ts的 focus+choice 分支:分类器判answer_current_focus / unsure→ 映射 D →applyRectificationChoice确定性回复「已记录。这题先不计分,换一件事问。」→persistNextInterviewAfterChoice同一瞬间落库财务探针 2025-09(active,asked_turn_id绑该回复轮)。模型没跑,日志零告警。 - 用 psql 取
get_agentic_rectification_case_dossier/list_agentic_rectification_conversation_focuses/get_agentic_rectification_turn_receipt真实行,在本地用同版本代码跑dossierResponse:current_question、choice_card(A–D 四项)、turns[].question(7 条轮次)、latest_result(17 个候选)、question_source=focus全部完整;三个 RPC 合计 < 70ms,投影 ~45ms。 - 客户端解析函数(
isRectificationCaseSnapshotPayload/parsePersistedRectificationTurns/parseRectificationChoiceCard/parseRectificationCandidateResult/currentQuestionFromSnapshot)对这份载荷全部通过。 - 产品截图(17:49,未刷新):无卡;右侧「补充经历后,这里会显示…」占位;时间轴只有 14:00–15:00 无候选点;输入框占位是
collect_waiting才会出现的「再说一件带年月的事」。即客户端仍停在开场时的快照(collect_evidence、无结果、无当前题),17:46 之后三次loadCaseSnapshot都没有生效。
查不下去的原因(这就是本单要修的):
GET /api/rectification/cases/[caseId]任何异常都返回 503,不写日志;case_session_mismatch409 也不写。- 客户端
loadCaseSnapshot与hydrateRectificationCase把非 2xx / 抛错一律吞掉,页面不显示任何提示,停在旧状态。 - Caddy 与 Next 都不记这条 GET 的访问日志;PG
track_functions=none、log_statement=none。事后无法证明请求到没到服务端。 - 确定性回复只流 ack,题干靠
asked_turn_id经 GET 挂上(BUG-525 设计)。快照一失败,用户看到的就是「换一件事问」后空白。
补充观察(产品口径,本单不做):「想不到了」在语义上是在答「想到几件说几件」(采集题已被服务端悄悄 resolved),却被绑到用户没在看的财务探针上判成 D,然后又出一道同家族财务题。是否允许"刚记完经历的下一句"优先按采集语义解释,需要产品拍板后另立单。
1. 决策记录
- BUG-968 是
e4e73f56引入的回归,不推翻任何既有红线;修法是让弹窗回到 inert 子树之外,不是去掉 inert(inert 是 BUG-744~746 侧栏统一时定下的焦点合同,保留)。 - BUG-969 本轮只做"可观测 + 不静默 + 题干进正文"三件事,不改分类器、不改采集/探针状态机;产品口径另立单。
- 「刷新即恢复」不再作为任何一条的关闭依据:这次刷新后弹窗依旧点不动。
2. 硬红线
- 揭幕后不得出现 spinner / 骨架 / 「正在加载」;快照失败的提示用一句状态文案 + 重试入口,对照
frontend/docs/VOICE.md。 - 不得为救 BUG-968 去掉
SidebarInset的inert或改成pointer-events糊弄;移动端抽屉的isMobile && openMobile分支不动。 - 不得动
.gitea/workflows/**、DNS、数据库结构;不顺手升级依赖。 tsc --noEmit0 错、npm run lint0 error、npm test失败数不超过基线 31 条且清单一致;测试总数不低于开工实测。- 改 UI 的同一提交更新
frontend/DESIGN.md。
3. 任务分解
3.1 BUG-968(P0)弹窗移出 inert 子树
- 把
AccountDialogOverlay(及同类:onboarding 付费墙)渲染到SidebarInset之外。两种做法二选一,进度记录里写明选了哪个和原因:- (a)
createPortal(…, document.body),保留现有 DOM 结构与样式; - (b) 通过
registerShellControls把弹窗节点交给AppShell,由 layout 在SidebarInset的兄弟位置渲染。
- (a)
- 验收:
- 合同测试:
modalOpen=true时,role="dialog"元素不在带inert的祖先之内(用renderToString或 DOM 断言均可);inert仍落在SidebarInset上。 - 现有
tests/account-dialog-overlay.test.ts、tests/sidebar-contract.test.ts、tests/chat-navigation-a11y-contract.test.ts全绿;焦点圈禁(keepFocusWithin)与 Esc 关闭行为不变。 - 真机清单(
docs/testing/):五个账户弹窗每个都能点关闭、能提交、「退出登录」能确认退出;弹窗开着时正文区仍不可点。
- 合同测试:
3.2 BUG-969(P1)快照失败不再静默
GET /api/rectification/cases/[caseId]:每一个非 200 分支console.warn一条 JSON(event、case_id、status、code、reason=safeToolErrorCode(error),不含用户资料),包括 401、400、409、404、503。- 客户端
loadCaseSnapshot/hydrateRectificationCase/refreshRectificationCase:非 2xx 或抛错时把结果带回调用方(状态码或network),页面在校正面显示一句状态文案 + 「重新读取」按钮,不得继续装作无事;已有的questionGap === "unavailable"分支可复用文案。 - 确定性回复(
persistApplied与persistCollectDenialTurn的assistantMessage)在下一题是可渲染选择题时,正文追加一句题干(沿用composeCollectSpokenAssistantText的口径),流给客户端的文本也带上;卡片仍由 GET 挂上,stripQuestionSentences负责去重,不得出现题干两遍(BUG-559/609 的去重合同要过)。 - 验收:
- 合同测试:GET 路由 503 分支产生 warn;客户端 fetch mock 返回 503 时校正面出现状态文案与按钮;确定性回复文本含题干且
attachQuestionsToTurns后题干只出现一次。 - 真机清单:在校正会话里连发两轮经历后,卡片、右侧盘面、时间轴候选点必须在回复落定后出现;拔网线/断网再发一轮,页面要出现状态文案而不是空白。
- 若真机仍复现"快照拿不到",凭第 1 条的服务端日志把 BUG-969 转成 confirmed 并补根因;拿不到日志不得写 resolved。
- 合同测试:GET 路由 503 分支产生 warn;客户端 fetch mock 返回 503 时校正面出现状态文案与按钮;确定性回复文本含题干且
4. 让步顺序
3.1 必须做完。3.2 按 1 → 2 → 3 的顺序,做不完的写进 BLOCKED.md 与进度记录,不得砍 1。
5. 开工前置
git fetch origin --prune
git worktree add -b codex/account-dialog-inert-20260918 \
.worktrees/account-dialog-inert-20260918 origin/staging
cd .worktrees/account-dialog-inert-20260918/frontend
npm test 2>&1 | grep -E "^# (tests|pass|fail)" # 记下开工基线
与 TASK-secondary-page-entry-20260918.md 同时改 (app)/layout.tsx 时,本单先合,对方 rebase。
收工:docs/tasks/PROGRESS-account-dialog-inert-20260918.md + docs/BUG_HISTORY.md(BUG-968 转 resolved、BUG-969 按证据定状态)+ frontend/DESIGN.md + docs/testing/account-dialog-inert-20260918.md,与代码同一批推 staging,并把 docs/tasks/README.md 状态板那一行改掉。