# 任务书 · 生时校正会话面:消除空白假死与交互摩擦(2026-09-02) 基线:**`codex/streaming-ux-20260901`(HEAD `c846c44a`)合入后的 `origin/staging`**。本轮改的文件与那条分支高度重叠(`rectification-agentic-chat.tsx`、`page.tsx`、`globals.css`、`rectification-agentic-entry.test.ts`),**必须在它合入之后开工**。 ## 2026-09-03 重启说明(覆盖上面的基线与部分条目) 第一次执行(分支 `codex/rectification-ux-20260902` @ `fec000f7`,基于 `4dc0c8c7`)已完成全部条目并经验收,但 staging 随后被其它会话推进 46 个提交(校正问题内嵌、选择卡、采用流、collect 退出等一批 `fix(rectification)`),`rectification-agentic-chat.tsx` 被重写 600+ 行,老分支与之有 5 个文件的内容冲突,**不再 rebase,改为在新基线上重做**。老分支只作参考(`git show fec000f7:` 读,`git diff 4dc0c8c7..fec000f7` 看思路),不要 checkout 它。 - **新基线**:`origin/staging` @ `8e214e29` 或更新;新分支 `codex/rectification-ux-20260903`。 - **BUG 编号从 505 起**(staging 已到 504,开工再确认)。 - **已被 staging 解决、本轮删除的条目**:0.3 的"问题槽在对话流内"——`d9404976` 已把每道题内嵌进 assistant 消息,`.rectification-question-slot` 已不存在;不要复活问题槽。0.3 只剩"没有等待快照这一态"(随 0.2 自然成立)。 - **部分被解决、只补差额**:1.1 选择卡——`35688015` 已把选中态收进按钮、`d9404976` 加了 `variant="embedded"`;本轮只补 pending 时卡内的 `InlineSpinner` + shimmer「正在记录…」一行,以及选中项的 `Check` 图标(若 staging 已有则跳过并写明)。 - **接口变了、按新结构重做**:`send` 现在经 `beginLiveRun(label)` / `rememberLiveActivity(label, tool)` 管 live 行文案;0.5 的 `continuation`(复用同一条 live 行、跳过 busy 守卫)要接进这两个助手,而不是照抄老分支的 `rectificationInitialLiveLabel`。`loadCaseSnapshot` 现在**返回** snapshot(`submitStructuredChoice` 用返回值判断 `willContinue`),保留这个返回。`choiceContinuationPending` + effect 在 staging 仍在,照任务书删除。 - **已删除、不得复活**:`onStartConsultation` / consult handoff 按钮(`e8c98c37` 产品决定删除)。0.6 空态的按钮只有「开始提问」。 - **仍在 staging 上、本轮照做**:`page.tsx` 的 `-ready/-loading` key;`rectificationCardLabel` 无 loading 分支;「等待服务端更新」两条文案(`showMissingQuestion` / `showUnavailableQuestion` 现在锚在内嵌问题上,四态判定改到那里);盘面首态文案与收窄;开场 live 文案;402 提示;1.2 用户选择回显(核对 staging 现在怎么渲染持久化的结构化选择 turn,若已回显则跳过并写明)。 - **深链 / 刷新 / popstate 对齐**(并入 0.2):staging 已有两阶段揭幕(`bootstrapPhase: "account" → "prepare"`,`page.tsx` 约 :529–:663)。启动时选中的会话若是校正会话,把 `openRectificationSession` 的 hydration 并入 `prepare` 阶段、揭幕前完成;popstate 回到校正会话时,hydration 完成前不切 `activeSessionId`。4 秒上限常量若 unified-loading 已导出则复用,只留一个。 - **老分支里可以直接搬的**(预计无冲突,逐个 `git show fec000f7:` 对照):`src/lib/rectification-surface-state.ts` 与其 `tests/rectification-surface-state.test.ts`(hydration、四态纯函数、文案常量)、`use-session-management.ts` 的 deferred switch、`starter-home.tsx` / `sidebar-session-row.tsx` 的打开中反馈、`rectification-board.tsx` 首态、DESIGN.md 「Rectification surface」条目(按新状态集合修订)、walkthrough 第 8 节。`tests/rectification-surface-contract.test.ts` 的源码锁要按新结构重写。 ## 与同日其它任务书的关系(先读) | 任务书 | 关系 | 结论 | | --- | --- | --- | | `TASK-unified-loading-20260902.md` | 产品裁决:揭幕后不得再出现 spinner / 骨架 / "正在加载"文案,流式生成中除外;且"与其它改 `page.tsx` 的轮次不得并行" | 本轮**遵守同一裁决**:进入校正面的等待全部提前到切换之前(并行拉完再一次揭幕,见 0.2),面板内不设加载态;剩余等待都是生成中(timeline live 行)。两轮都改 `page.tsx`,**串行执行**:streaming-ux 合入 → 本轮 → unified-loading(本轮对 `page.tsx` 只有两处小改,先做冲突面小)。 | | `TASK-rectification-walkthrough-polish-20260902.md` | 服务端抛光。其 **B.2**(流结束后前端立即刷新快照)与本轮 0.4 重复;其 **D.2**(问题槽必须在对话流内)与本轮问题槽改动同文件 | B.2 由本轮 0.4 承担,polish 执行方只做服务端 emit(若选 `question.ready` 事件,本轮 0.4 直接消费它);D.2 的 UI 部分并入本轮 0.3。两轮同改 `rectification-agentic-chat.tsx`,polish 以服务端为主,**polish 先合入**,本轮 rebase。 | 用户反馈原话:"动画加载的过程中还有一段时间是空白状态,也没有加载也没有状态,导致用户以为页面卡了;交互也不是很友好。"下面每一条空白都对着代码找到了成因。**先读完「硬红线」再动手。** --- ## 事故实证:六段空白 + 一个死角 行号基于 `c846c44a`,按符号定位。 ### 空白 1 · 首页卡片点下去没有任何反馈 `starter-home.tsx` 生时校正卡:`rectificationLoading` 期间只把按钮 `disabled`,文案、图标、光标都不变。`use-rectification-surface.ts` `openRectificationCase` 要等 `/api/rectification/cases/open`(鉴权 + RPC `open_agentic_rectification_case_v2` + profile 加载 + 时区解析)返回才切会话。2 vCPU 的生产机上这一步以秒计,用户看到的是"点了没反应"。 ### 空白 2 · 从侧栏点开已有校正会话:先闪普通对话、再整块空白、再重挂 `use-session-management.ts` `selectSession`:先 `setActiveSessionId`,再 `void openRectificationSession(id)`。`page.tsx` 的 `rectificationSurfaceOpen = activeRectificationSession && activeSession.id === rectificationSessionId`——在 open 返回前是 **false**,于是这一帧渲染的是普通 `ChatTranscript`(用镜像的 `session.messages`)。open 返回后 `setRectificationTurns([])`、面板以 `key=…-loading` 挂载,`initialTurns=[]` → **整块空白**(没有任何 loading 文案),直到 `refreshRectificationCase` 拉回 turns,key 翻成 `-ready` → **整个面板卸载重挂**。三段画面:普通对话 → 空白 → 校正面板。 ### 空白 3 · 新建校正:第一轮回答结束时整个面板重挂一次 同一个 key:新 case 挂载时 turns 为空(`-loading`),开场轮 `run.completed` → `onCompleted` → `refreshRectificationCase` → turns > 0 → key 变 `-ready` → **重挂**。用户刚读完第一条引导,画面闪一下、滚动归零、时间线开合状态丢失、消息从内存态换成持久化态(trace 只剩回执)。如果用户在这一拍已经开始输入第二轮,重挂会丢掉那次流(`runAbort` 不在卸载时中止,`setMessages` 落到已卸载的实例)。`tests/rectification-agentic-entry.test.ts` :79、:190 两处正则**锁的正是这个 key 写法**。 ### 空白 4 · 恢复会话后,问题槽在快照回来前什么都不显示 `rectification-agentic-chat.tsx`:`caseSnapshotLoaded` 为 false 时 `showLiveChoiceCard` / `showMissingQuestion` / `showUnavailableQuestion` 全为 false,`.rectification-question-slot` 是空的。用户看到历史消息但没有可做的事,不知道要等。 ### 空白 5 · 选择题点下去之后有两段缝、一次闪卡 `submitStructuredChoice`(:760–:860): 1. 追加一条 thinking 行「正在记录本次选择…」(好)。 2. `fetch` 返回 → `await loadCaseSnapshot()` → **`willContinue` 分支把这条 thinking 行删掉**,置 `choiceContinuationPending`,`finally setPending(false)`。 3. 下一次 effect 才 `send("read_only")` → 再追加一条新的 thinking 行「正在处理…」并 `setPending(true)`。 2→3 之间至少有一帧:没有任何 live 行,且 `busy=false` + 快照刚装进的新 `choiceCard` → `showLiveChoiceCard` 为 true → **下一题的卡片闪现一帧又消失**。然后 read_only 的回答流完 → `run.completed` → `await loadCaseSnapshot()`(这次在 `finally` 之前,busy 仍为 true,没缝)→ 卡片出现。 ### 空白 6 · 采用候选时间:只有按钮文案变了 `acceptCandidate`:`setAcceptingCandidateId` + `setPending(true)`,transcript 里不出现任何 live 行;POST 完成 → `await loadCaseSnapshot()` → `choiceContinuationPending` → 再走空白 5 的 effect 路径。用户盯着一个变灰的按钮「正在采用…」等好几秒,页面其它部分静止。 ### 死角 · 空 turns 的已有会话永远空白 服务端 `open_agentic_rectification_case_v2` 对 `resumed` 一律返回 `should_start_opening=false`(迁移 `20260812010000_agentic_rectification_v9_runtime.sql` :427/:459/:480)。若一个 case 的开场轮当时失败或未持久化(turns 为空),从侧栏再进来:`initialTurns=[]`、`!shouldStartOpening` → 面板挂载后**什么都不发生、什么都不显示**,composer 可用但用户不知道要先说什么。没有任何 CTA。 ### 交互摩擦(不是空白,但用户说"不友好"的来源) - **两条开发者文案**:「当前没有可回答的问题,正在等待服务端更新。」「当前问题暂时无法显示,请等待服务端更新。」——没有动作、没有时限、用户不知道等多久。 - **选择题选中无确认感**:`.rectification-choice-card.is-pending` 只改 `cursor: wait`;选中项没有对勾,卡片里没有进度。 - **用户的选择不回显**:答过的卡贴在 assistant 消息下(`choiceAttachment`),transcript 里没有一条"我选了 B"的用户气泡;服务端已经返回 `userMessage`(`applied.userDisplay`)但客户端没用;持久化 turn 里的结构化选择又被 `isStructuredChoiceUserText` 过滤掉。刷新前后都看不到自己答了什么。 - **右侧盘面首态是一整块空面板**:桌面端 `minmax(18rem, 22.5rem)` 的面板,第一阶段只有一句「补充经历后,这里会显示当前本命宫位和换升时刻。」;移动端 peek 是「当前盘面 · 补充经历后会在这里更新」。用户填过出生时间,面板却像没数据。 - **开场 live 行文案是通用的「正在处理…」**,第一次进入的用户不知道系统在做什么。 - **停止**:`send` 的 `catch` 已区分 `AbortError`,但请核对 aborted 分支落地的文案不是「生时校正暂时不可用,请稍后再试。」(当前 :700 附近的通用兜底)。 --- ## 硬红线 1. **不改服务端语义、不改 SQL。** `should_start_opening`、`choice.applied` 的返回、`awaitTurnExitBeforeResponse` 都不动。死角修复用已有的 `send("opening")`(服务端已抑制重复 opening,`rectification-agentic-entry.test.ts` :190 锁着这条性质)。 2. **只用上一轮统一好的那一套活动 UI**:live 行 = `ConsultationRunTimeline` 的 queued/live 行(`InlineSpinner` + shimmer 文案),不得新造第二种 spinner、骨架屏或呼吸动画。§9 等待词汇表不扩表。 3. **不得手写 `useCallback` / `useMemo`**;`rectification-agentic-chat.tsx` 既有的不删不加。 4. **不得修改既有测试断言**,除非它锁的正是缺陷本身(本轮明确允许:`rectification-agentic-entry.test.ts` :79/:190 的 `-ready/-loading` key 锁、任何锁「等待服务端更新」文案的断言);改时在断言上方注释原值与错因,PROGRESS 单列。 5. `./node_modules/.bin/tsc --noEmit` 通过(不要 `npx tsc`);`next build` 通过;测试数不低于基线、失败清单逐条比对无新增。 6. 浅色/深色/`prefers-reduced-motion` 三套都验。 7. 不改 `.gitea/workflows/**`;不在脏工作树切分支;不自行把 staging 提升到 main。 8. BUG 编号开工时先看远端最大号(写本任务书时 staging 最大 472,streaming 分支占 473–478,**本轮从 479 起,仍需现场确认**)。 让步顺序:功能与测试不回归 > 可验证的修复 > 视觉一致 > 代码整洁。 --- ## 任务 0(P0)· 六段空白与死角 ### 0.1 入口有反馈,但不转圈 `starter-home.tsx`:`rectificationLoading` 时卡片 `aria-busy="true"`、`data-opening="true"`,footer 的 action 文案换成「正在打开…」(静态文案,**不加 spinner**,遵守 unified-loading 裁决),卡片 `cursor: progress`。文案在 `rectificationCardLabel` 的派生处加 loading 分支。侧栏校正会话行在 `rectificationLoading && 目标是该行` 时同样只加 `aria-busy` 与静态「打开中」尾注,不转圈。 ### 0.2 一次揭幕:open + 记录 + 快照并行拉完再切面板,面板挂一次不重挂 - `use-rectification-surface.ts` `openRectificationCase`:`/cases/open` 返回后**不立刻**切会话;改为 `Promise.allSettled([refreshRectificationCase(caseId, sessionId), fetch 案例快照])` 并行拉 turns 与快照(上限 4 秒,与 unified-loading 同一常量),全部落地后再一次性 `setRectificationTurns / setRectificationSnapshot / setRectificationSessionId / setActiveSessionId`。超时或失败:turns 用空数组、快照用 null,仍然切换(面板会走 0.6 空态或 0.4 的重试路径),并 composer notice「校正记录没有完全加载,可以继续」。 - `use-session-management.ts` `selectSession`:对校正会话**不再先 `setActiveSessionId`**,改为只调 `openRectificationSession(id)`,由上一条在数据齐了以后切换;期间旧画面保持不动(这就是"先闪普通对话"的消除)。URL 写入时机随之后移到切换那一刻。 - `page.tsx`:面板 key 去掉 `-ready/-loading` 后缀,只保留 `${sessionId}-${caseId}`;props 增加 `initialSnapshot`。面板内 `useState(() => messagesFromTurns(initialTurns))` 与 `useState(() => initialSnapshot)` 初始化,`caseSnapshotLoaded` 初值 = `initialSnapshot !== null`;挂载后**不再**自己拉一次快照(0.4 的重试路径除外)。 - turns 的后续到达(`onCompleted` → `refreshRectificationCase`)改为 **prop 更新**:面板内 `useEffect([initialTurns])`——本地 `messages` 为空且 `initialTurns.length > 0` 时用 `messagesFromTurns` 填充;本地已有消息时忽略,不覆盖、不重挂。 - 卸载时 `runAbort.current?.abort()`(cleanup effect)。 ### 0.3 问题槽只有生成中态,且始终在对话流内 - 快照随揭幕一起到位后,问题槽没有"等待快照"这一态;仅当 0.4 的重试在跑时显示 live 行。 - **承接 polish D.2**:问题槽(live 选择卡 / spoken prompt / 状态行)渲染为 transcript 的**最后一条内容**——放在候选卡之后、`rectification-saved` 之前,用 `.message-entry` 的同一缩进与间距(`--assistant-content-inset`),不得悬在卡片外。 ### 0.4 两条"等待服务端更新"文案改为有动作的状态 `showMissingQuestion` / `showUnavailableQuestion` 命中时: 1. 先自动重拉快照:若 polish 轮落地了 `question.ready` 公开事件,则收到即拉;否则在 `run.completed` 后立即拉一次,再最多 2 次、间隔 2s(`useVisibilityAwarePoll` 已有,复用)。期间问题槽显示 live 行「正在准备下一个问题…」——这是生成中等待,符合裁决。 2. 3 次后仍命中:显示「没有拿到下一个问题。」+ 一个 44px 次级按钮「重新加载」(调 `loadCaseSnapshot`)。 3. 两条旧文案从源码删除。 ### 0.5 选择题点击后不留缝、不闪卡 `submitStructuredChoice` 的 `willContinue` 分支:**不删 thinking 行、不经 effect 中转**。把 continuation 收进同一个 async 流程:`fetch` 成功 → `await loadCaseSnapshot()` → 直接 `await send("read_only", "")`,并让 `send` 接受一个可选参数 `reuseAssistantRenderKey`,用已存在的那条 thinking 行(同一个 renderKey)承接后续事件,label 从「正在记录本次选择…」自然过渡到「正在处理…」/tool 文案。`busy` 全程为 true(`setPending(false)` 只在整条链的最后)。`choiceContinuationPending` 这条 ref + 对应 effect 删除。 `acceptCandidate` 同样:点击即在 transcript 末尾追加 thinking 行「正在采用 HH:MM…」,POST → 快照 → `send("read_only")` 复用该行。 ### 0.6 空 turns 的已有会话给出起点 面板挂载且 turns 已加载为空、`!shouldStartOpening`、`!readonly` → `.message-list` 显示空态:「这段校正还没有开始。」+ 主按钮「开始提问」(调 `send("opening", "")`)。服务端幂等由 :190 锁定,客户端只需 `openingStarted` 守卫。 ### 0.7 停止后的文案 核对 `catch` 的 aborted 分支:已有内容时行内保留,composer notice 为「已停止,已生成的内容保留;本次不会扣点。」;无内容时移除该行、不报错。若现状已如此,只补一条源码锁。 ### 验收(任务 0) - 契约测试(源码锁 + 纯函数):`page.tsx` 无 `"ready" : "loading"`;`use-rectification-surface.ts` 含 `allSettled` 与 4 秒常量;`selectSession` 对校正会话不直接 `setActiveSessionId`;面板源码含 `initialSnapshot`;揭幕后 `rectification-agentic-chat.tsx` / `starter-home.tsx` 无非生成中的 `InlineSpinner`;`rectification-agentic-chat.tsx` 无 `choiceContinuationPending`、无「等待服务端更新」;存在「正在打开…」「正在准备下一个问题」「这段校正还没有开始」;`send` 签名含 `reuseAssistantRenderKey`;卸载 cleanup 调 `abort`。 - 纯函数:新增 `rectification-surface-state.ts`(把"恢复中 / 空态 / 问题槽四态"的判定抽成纯函数)并测全部分支。 - 手工清单追加到 `docs/testing/staging-manual-walkthrough-20260901.md`:① 首页点卡片看到「正在打开…」,随后一次性出现完整面板(消息 + 问题 + 盘面);② 侧栏切校正会话:旧画面保持到数据齐、不闪普通对话、不出现空白;③ 新建校正第一轮结束不闪、滚动不归零;④ 连点两道选择题中间无空帧无闪卡;⑤ 采用候选看到 live 行;⑥ 一个开场失败的旧会话进来有「开始提问」。 ### 建档 BUG-479(校正会话面挂载/重挂造成三段空白)、BUG-480(选择题与采用候选之间的缝与闪卡)、BUG-481(空 turns 会话无起点)。 --- ## 任务 1(P1)· 交互摩擦 ### 1.1 选择题卡有确认感 `rectification-choice-card.tsx` + CSS:选中项显示 `Check` 图标与「已选择」;`pending` 时卡片顶部一行 `InlineSpinner size={12}` + 「正在记录…」(同 timeline live 行的排版,不另造);未选项在 pending 时降到 `.48` 透明度(已有)。所有选项按钮 `min-height: 44px`(核对 `touch-target-contract`)。 ### 1.2 用户选择回显为用户气泡 选择成功后,用服务端返回的 `userMessage`(`applied.userDisplay`)在 transcript 追加一条 **用户气泡**(`role: "user"`),紧跟在答过的卡片之后、thinking 行之前。持久化侧:`isStructuredChoiceUserText` 过滤要改成**保留**这类 turn 并原样显示(否则刷新后回显消失)。`choiceAttachment` 贴卡逻辑保留(卡本身仍显示所选项)。`tests/rectification-answer-choice.test.ts` 若锁了过滤行为,按红线 4。 ### 1.3 盘面首态不空 - `rectification-board.tsx`:`result` 为空时,header 时钟位显示 profile 的填报时间(面板 props 增加 `declaredTime: string | null`,由 `page.tsx` 从 profile 传入),正文改为两行:「填报出生时间 HH:MM」「回答几个问题后,这里会显示宫位随时间的变化。」;`rectificationBoardPeekCopy` 同步为「当前盘面 · 填报 HH:MM」。 - 若快照 API 已提供填报时间对应的宫位表(先 grep `natal`/`declared` 字段确认),则直接渲染那张表作为首态;**没有就不要造数据**,只做文案。 - CSS:`result` 为空时 `.rectification-workspace` 的板列收为 `minmax(16rem, 18rem)`(加 `is-board-empty` 修饰类),有结果后恢复。 ### 1.4 开场 live 行文案 `send("opening")` 的初始 live 行 label 用「正在读取你的出生资料,准备第一个问题…」;`send("message")` 用「正在处理…」;`read_only` 沿用上一步传入的 label。通过 `send` 的 action 分支决定,不要在渲染层判断。 ### 1.5 402 跳转前先给提示 `window.location.assign(membershipHref("rectification"))` 前先 `setError("校正点数不足,正在前往兑换…")`,并延迟 600ms 再跳,避免页面无预警消失。 ### 验收(任务 1) - 契约:choice card 源码含 `Check`;`isStructuredChoiceUserText` 不再用于过滤渲染;board 源码含 `declaredTime`;`send` 源码含开场文案。 - 手工:一轮完整校正(开场 → 3 道选择题 → 候选 → 采用)录屏,浅色一份。 ### 建档 BUG-482(选择不回显、无确认感)、BUG-483(盘面首态空)。 --- ## 任务 2(P2)· DESIGN.md §5 新增「Rectification surface」条目: - **States**:`opening`(首轮引导流中)、`resuming`(恢复记录/进度)、`empty`(无 turns 有起点)、`waiting-question`(准备下一题,含自动重试与手动重载)、`choice-live`、`choice-pending`、`candidates`、`accepted`、`confirmed`、`readonly`、`failed`。每态写明问题槽、transcript 末尾 live 行、composer 三者各显示什么。 - **Rule**:面板一个会话只挂载一次;turns 与快照都是 prop/state 更新,不是 remount。任何"等待"都必须是 timeline live 行或问题槽 live 行,禁止裸文案等待、禁止「等待服务端更新」类措辞。 - **Board**:首态显示填报时间;空态收窄;有结果后展开。 - §9 表不新增行;写一句"校正面所有等待复用行内等待"。 --- ## 执行顺序 0.2 先做(它改变挂载模型,其余都建立在"不重挂"上)→ 0.1/0.3/0.4/0.6/0.7 → 0.5 → 任务 1 → 任务 2。每个任务单独 commit。 ## PROGRESS 要求 `PROGRESS-rectification-ux-20260902.md`:每任务改动文件、被触碰断言(原值/新值/理由)、六段空白各自消除的证据(契约名或纯函数用例名)、tsc/build/测试数字与基线比对、BUG 编号、未做与原因。