docs(rectification): record the surface redo — BUG-505..509, DESIGN.md rectification surface, walkthrough section 8

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-03 07:06:38 +00:00
parent 3c33e59814
commit 99a91a97f4
7 changed files with 369 additions and 1 deletions
+8
View File
@@ -1,5 +1,13 @@
# BLOCKED
## 生时校正会话面:消除空白假死与交互摩擦(2026-09-03,分支 `codex/rectification-ux-20260903`,重做)
- **浏览器级手工验收未做:执行环境无登录态、无 Chrome。** 任务书任务 0 的手工项与任务 1 的整轮录屏(开场 → 3 道选择题 → 候选 → 采用)都做不了;已按任务书把它们写进 `docs/testing/staging-manual-walkthrough-20260901.md` 第 8 节(含深链/刷新与缺口重试两条),交给有真实会话的人。本轮的替代证据:`tests/rectification-surface-state.test.ts`(纯函数会话态/缺口态、hydration 超时/失败、turn 解析)与 `tests/rectification-surface-contract.test.ts`(源码锁:一次揭幕、prepare 阶段 hydration、无重挂、续接同一行、空态、板首态)。
- **1.3「若快照 API 提供填报时间宫位表则直接渲染」未做:API 不提供。** `frontend/src/app/api/rectification/cases/[caseId]/route.ts` 的 dossier 响应只带 `accepted_time` / `confirmed_time` / `candidate_range`,无 natal/declared 宫位表;按任务书只做文案与收窄,不造数据。
- **书面偏差:hydration 不是 `Promise.allSettled([turns, snapshot])`,而是一次请求套 4 秒 `Promise.race`。** turns 与快照来自同一个 `GET /api/rectification/cases/:id`,两个并行请求会重复;语义(拉完再切换、超时仍揭幕)不变。上限常量直接等于 `BOOTSTRAP_PREPARE_TIMEOUT_MS`,只有一个。
- **书面偏差:turns 后到的填充与重试计数复位不用 `useEffect`。** `npm run lint``react-hooks` 规则拦截 effect 内同步 setState;改为 React 文档的"渲染中按上一 prop 调整 state"模式与事件处理器内复位,行为等价。
- **书面偏差:首页卡片不带 `aria-busy`。** `tests/home-bootstrap-reveal.test.ts` 锁死 `starter-home.tsx` 不得出现 `aria-busy={`unified-loading 裁决);卡片只用 `data-opening` + 静态文案 + `cursor: progress`,侧栏行仍带 `aria-busy`
## Agent 聊天流式体验与双会话面统一(2026-09-01,分支 `codex/streaming-ux-20260901`
- **~~任务 3 未做,等待第三批拆页合入~~ 已解除并完成(2026-09-02)。** `origin/staging` 合入 `bf6989ec`(第三批)、`124d3990``058e5db9` 后,本分支 rebase 到其上,任务 3 按任务书原文完成:`page.tsx` 的滚动 effect 与内联「跳到最新」按钮删除,跟随并入 `useConversationScrollAnchor`,校正面接入同一 hook 与 `JumpToLatestButton`,复用 `ChatComposer``value` 受控、500 字上限),`rectification-sticky-scroll.ts` 删除,720px 覆写删除。对应 DESIGN.md 四条与 BUG-477/476 一并落地。
+79
View File
@@ -7777,3 +7777,82 @@
- 复发自:无
- 修复版本:待发布
## BUG-505 | 生时校正面进入时先闪普通对话、再空白、再整面重挂;问题槽用「等待服务端更新」裸文案
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-03
- 影响面:`use-rectification-surface``use-session-management``page.tsx`(面板 key、prepare 阶段)、`rectification-agentic-chat``starter-home``sidebar-session-row`
- 用户现象:点首页卡片没有任何反馈;从侧栏进已有校正会话先闪一帧普通对话、再是一块空白面板、记录到了再整面重挂;新建校正第一轮结束时画面闪一下、滚动归零;恢复会话后快照没回来前问题区空着;答完题后 composer 上方是「当前没有可回答的问题,正在等待服务端更新。」之类没有动作、没有时限的文案。
- 触发条件:任何进入校正面的路径(卡片、侧栏、深链/刷新);任何一轮结束到下一题到达之间。
- 根因:`selectSession``setActiveSessionId` 再 open`rectificationSurfaceOpen` 在 open 返回前为 false;面板 key 带 `rectificationTurns.length > 0 ? "ready" : "loading"`,turns 到达即重挂;面板挂载后自拉快照;问题缺口只有裸文案。
- 修复:`/cases/open` 后用 `hydrateRectificationCase` 一次读取 turns + 快照(与 `BOOTSTRAP_PREPARE_TIMEOUT_MS` 同一 4 秒上限)再切会话、写 URL;`selectSession` 对未打开的校正会话不先切;启动时选中的校正会话在 prepare 阶段完成 hydration 再揭幕;key 只剩 session/Case 绑定,后到 turns 经渲染期 prop 调整只填空 transcript;卸载中止流与快照读取。入口卡片脚注静态「正在打开…」、侧栏行「打开中」,不转圈。缺口改为 `rectificationQuestionGapState``preparing`(时间线 live 行「正在准备下一个问题…」+ 2s 定时重拉 ≤2 次)→ `unavailable`(「没有拿到下一个问题。」+ 44px「重新加载」);hydration 超时也走同一缺口。
- 验证:`rectification-surface-contract`(一次揭幕、无重挂、prepare 阶段 hydration、静态入口反馈、缺口四态、无「等待服务端更新」);`rectification-surface-state`hydration 超时/失败、缺口纯函数、turn 解析);`rectification-agentic-entry` / `rectification-question-in-message` / `rectification-spoken-collect` 的旧锁按红线改注。
- 防复发:面板一个会话只挂载一次,turns 与快照都是 prop/state 更新;任何等待必须是时间线 live 行或带动作的状态,不得出现「等待服务端更新」类文案;揭幕后不得出现非生成中的 spinner。
- 相关记录:BUG-479(首页两阶段揭幕)、BUG-473(校正面接入共享时间线)
- 复发自:无
- 修复版本:待发布
## BUG-506 | 选择题点击后有空帧并闪下一题卡片;采用候选只有按钮变灰
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-03
- 影响面:`rectification-agentic-chat``send``submitStructuredChoice``acceptCandidate`
- 用户现象:点完选项,「正在记录本次选择…」那行消失一帧,下一题卡片闪现又消失,再出现「正在处理…」;点「采用此时间」只看到按钮变灰,页面其它部分静止好几秒。
- 触发条件:选择题 `willContinue`;采用候选。
- 根因:`willContinue` 分支删掉 thinking 行、置 `choiceContinuationPending``finally setPending(false)`,下一次 effect 才 `send("read_only")` 再追加新行;中间那一帧 `busy=false` 且快照刚装进新 `choiceCard`。采用流程不追加任何 live 行。
- 修复:`send(action, text, continuation)`continuation 复用已有 live 行并跳过 busy 守卫;选择与采用在同一个 async 链里 `await send("read_only", …, { reuseAssistantRenderKey })``busy` 全程为 true;采用点击即追加「正在采用 HH:MM…」行;`choiceContinuationPending` 与 effect 删除。
- 验证:`rectification-surface-contract`「一条 live 行贯穿」;`rectification-agentic-entry` / `rectification-answer-choice` 的旧锁按红线改注。
- 防复发:后续轮不得经 effect 中转;一条链里 `busy` 不得掉回 false。
- 相关记录:BUG-505
- 复发自:无
- 修复版本:待发布
## BUG-507 | 记录为空的已有校正会话进入后永远空白
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-03
- 影响面:`rectification-agentic-chat``rectification-surface-state`
- 用户现象:某个开场当时失败/未持久化的校正会话,从侧栏再进来什么都不显示,composer 可用但不知道先说什么。
- 触发条件:`initialTurns=[]` 且服务端对 resumed case 一律 `should_start_opening=false`
- 根因:面板只在 `shouldStartOpening` 时自动开场,空 turns 无任何 CTA。
- 修复:`rectificationConversationState``empty` → 「这段校正还没有开始。」+「开始提问」(`send("opening")``openingStarted` 守卫;服务端幂等抑制重复 opening,不改服务端)。
- 验证:`rectification-surface-state` 会话态纯函数;`rectification-surface-contract` 空态源码锁。
- 防复发:无 turns 且不自动开场时必须给起点。
- 相关记录:BUG-505
- 复发自:无
- 修复版本:待发布
## BUG-508 | 选择题选中无确认感、记录中只有沙漏光标
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-03
- 影响面:`rectification-choice-card`
- 用户现象:点了选项只看到底色变化,卡里没有进度,不知道有没有点上。
- 触发条件:任何选择题点击。
- 根因:`is-pending` 只改 `cursor: wait`;选中项无对勾。
- 修复:选中项加 `Check` + 「已选择」;pending 时卡顶一行 `InlineSpinner` + shimmer「正在记录…」(与时间线 live 行同形)。`35688015` 已把选中态收进按钮、`d9404976` 已内嵌进消息,本轮只补差额;用户选择的回显由内嵌卡的 `answer_option` 承担(刷新前后一致),不再另加用户气泡。
- 验证:`rectification-surface-contract`
- 防复发:卡内等待只能是时间线 live 行同形。
- 相关记录:BUG-506
- 复发自:无
- 修复版本:待发布
## BUG-509 | 校正盘面首态是一整块空面板
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-03
- 影响面:`rectification-board``rectification-board-model``page.tsx``globals.css`
- 用户现象:桌面端右侧 18–22.5rem 的面板第一阶段只有一句「补充经历后…」,移动端 peek 同样;用户明明填过出生时间。
- 触发条件:`candidateResult` 为空。
- 根因:首态不用 profile 的填报时间。
- 修复:`declaredTime``page.tsx` 从 profile 派生(`reportedTime || time`,须为 `H:MM`)传入;头部时钟位显示填报时间,正文「填报出生时间 HH:MM」+「回答几个问题后…」;peek「填报 HH:MM」;`is-board-empty` 收窄到 `minmax(16rem, 18rem)`。快照 API 不提供填报时间的宫位表,不造数据。
- 验证:`rectification-surface-state` 板文案纯函数;`rectification-surface-contract`
- 防复发:首态必须显示已知的填报时间;不得为填报时间伪造宫位表。
- 相关记录:BUG-505
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,93 @@
# PROGRESS · 生时校正会话面:消除空白假死与交互摩擦(重做,2026-09-03)
工作树:`.worktrees/rectification-ux-20260903`
分支:`codex/rectification-ux-20260903`
基线:`origin/staging` @ `af411f59`(任务书重启说明所在),收尾时 rebase 到 `0cefaea6`(两次均为文档提交:任务书搬家、任务状态板;无代码变化)。
任务书:`docs/tasks/TASK-rectification-ux-20260902.md`,以其「2026-09-03 重启说明」为准。第一次执行的分支 `codex/rectification-ux-20260902` @ `fec000f7` 只作参考,未 cherry-pick。
未改服务端与 SQL。未改 `.gitea/workflows/**`。未合并 staging,未碰 main。未手写 `useCallback` / `useMemo``rectification-agentic-chat.tsx` 既有的原样保留;新增的 `refetchQuestion` / `reloadQuestion` / `startOpeningManually` 是普通函数)。BUG 编号:开工确认远端最大号 504,本轮 505–509。未复活问题槽、未复活 consult handoff。
| 任务 | 状态 | commit | BUG |
| --- | --- | --- | --- |
| 0.2 一次揭幕、面板不重挂、深链/popstate 并入 prepare 阶段 | 完成 | `53017d41` | BUG-505 |
| 0.1 入口有反馈不转圈 | 完成(同 commit) | `53017d41` | BUG-505 |
| 0.3 问题槽只有生成中态、在对话流内 | 前半由 staging `d9404976` 完成(题目内嵌消息,槽已删);后半"没有等待快照这一态"随 0.2 成立 | — | — |
| 0.4 「等待服务端更新」→ live 行 + 重试 + 重新加载 | 完成 | `360989ca` | BUG-505 |
| 0.6 空 turns 会话的起点 | 完成 | `360989ca` | BUG-507 |
| 0.7 停止后的文案 | 完成(现状是静默返回,已补通知) | `360989ca` | — |
| 1.4 开场 live 文案 | 完成 | `360989ca` | — |
| 1.5 402 跳转前提示 | 完成 | `360989ca` | — |
| 0.5 选择题/采用不留缝不闪卡 | 完成(接 `beginLiveRun` / `rememberLiveActivity` | `1e50007c` | BUG-506 |
| 1.1 选择卡确认感 | 补差额完成(选中态进按钮由 `35688015`、内嵌变体由 `d9404976` 已做;本轮补对勾「已选择」与卡顶「正在记录…」行) | `3c33e598` | BUG-508 |
| 1.2 用户选择回显 | **跳过**`d9404976` 已把题目内嵌进 assistant 消息,答过的卡以 `question.answer_option` 标出所选项,持久化 turn 的 `question` 也带 `answer_option`,刷新前后都能看到自己选了什么;再加一条用户气泡会重复 | — | — |
| 1.3 盘面首态 | 完成(文案 + 收窄 + peek;宫位表 API 不提供,未造数据) | `3c33e598` | BUG-509 |
| 2 DESIGN.md | 完成 | 本 docs commit | — |
## 六段空白与死角:各自的消除证据
| 空白 | 消除方式 | 证据 |
| --- | --- | --- |
| 1 首页卡片无反馈 | `rectificationCardLabel``rectificationLoading` 时为「正在打开…」;卡片 `data-opening` + `cursor: progress`;侧栏目标行「打开中」+ `aria-busy` | `rectification-surface-contract` → "entry feedback is static…" |
| 2 侧栏进入:普通对话 → 空白 → 重挂 | `selectSession` 对未打开的校正会话不先 `setActiveSessionId`/写 URLhook 在 `/cases/open``hydrateRectificationCase`(一次 Case 读取 turns + 快照,`BOOTSTRAP_PREPARE_TIMEOUT_MS` 上限)再一次性切换;面板 key 去掉 `-ready/-loading` | 同上 → "…revealed once…"`rectification-surface-state` → hydration 两条 |
| 2 深链 / 刷新 / popstate | page 的 resume effect 从"揭幕后"改为"prepare 阶段起"`bootstrapPrepareSettled` 新增 `rectificationApplicable/Settled`,校正会话 hydration 完成(或 open 报错 / 资料不全)才揭幕;popstate 走 `selectSession`,同样延后切换 | `rectification-surface-contract` → "…hydrated before the reveal, and popstate defers…"`home-bootstrap-reveal` 既有用例不变(新字段可选) |
| 3 第一轮结束整面重挂 | key 只剩 `${sessionId}-${caseId}`;后到的 turns 经渲染期按上一 prop 调整只填空 transcript;卸载 `abort()` | 同上(`doesNotMatch(page, /"ready" : "loading"/)``seededTurns` 锁) |
| 4 快照回来前问题区空 | 快照随揭幕到位(`initialSnapshot` 初始化 state`caseSnapshotLoaded` 初值 `initialSnapshot !== null`);面板不再挂载自拉;hydration 超时时走 0.4 的缺口重试 | 同上(`useState(initialSnapshot !== null)`、无挂载 fetch);`rectification-surface-state` → "a snapshot that never arrived is a gap…" |
| 5 选择题两段缝 + 闪卡 | `send(action, text, continuation)` 续接同一条 live 行并跳过 busy 守卫;`willContinue` 在同一 async 链里 `await send("read_only", …)``choiceContinuationPending` 与 effect 删除;`busy` 全程 true | 同上 → "…keeps one live row through the follow-up turn" |
| 6 采用只有按钮变灰 | 点击即追加「正在采用 HH:MM…」行,POST → 快照 → 复用该行续接 read_only;失败移除该行 | 同上;`rectification-surface-state``rectificationAdoptingLabel` |
| 死角 空 turns 会话 | `rectificationConversationState``empty` → 「这段校正还没有开始。」+「开始提问」(`send("opening")``openingStarted` 守卫) | `rectification-surface-state` → "conversation state…";契约 → "an empty Case…" |
| 文案 「等待服务端更新」 | `rectificationQuestionGapState``preparing`transcript 末尾一条时间线 live 行「正在准备下一个问题…」+ 2s 定时重拉 ≤2 次)→ `unavailable`(「没有拿到下一个问题。」+ 44px「重新加载」);`question_source: "unavailable"` 直接 `unavailable`;候选卡待采用时不算缺口 | `rectification-surface-state` → 缺口三条;契约 → "…never tells the reader to wait for the server" |
## 改动文件
- 新建:`frontend/src/lib/rectification-surface-state.ts`(常量、hydration、turn 解析含 `question`/`offer_result_id`/`tool_activities`、会话态与缺口态纯函数、板文案、填报时间派生)、`frontend/tests/rectification-surface-state.test.ts`10 用例)、`frontend/tests/rectification-surface-contract.test.ts`8 用例)。
- 改动:`use-rectification-surface.ts``use-session-management.ts``home-bootstrap.ts`(可选字段)、`page.tsx`2 个 state、hook 参数、卡片文案、resume effect 门、prepare 判定、面板 key/props、侧栏 prop)、`app-sidebar.tsx``sidebar-session-row.tsx``starter-home.tsx``conversational-birth-time-rectification.tsx`props 与 turn 类型)、`rectification-agentic-chat.tsx``consultation-run-timeline.tsx`(导出 `ConsultationTimelineLiveRow`)、`rectification-choice-card.tsx``rectification-board.tsx``rectification-board-model.ts``rectificationBoardPeekCopy` 第二参可选)、`globals.css``DESIGN.md``docs/BUG_HISTORY.md``docs/testing/staging-manual-walkthrough-20260901.md`(第 8 节)、`docs/tasks/README.md`(索引)、`BLOCKED.md`
## 与任务书的书面偏差(均登记 BLOCKED.md
1. hydration 是一次 `GET /api/rectification/cases/:id` 套 4 秒 `Promise.race`,不是 `Promise.allSettled([turns, snapshot])`(同一端点,两次请求会重复);上限常量直接等于 `BOOTSTRAP_PREPARE_TIMEOUT_MS`,只有一个。
2. turns 后到的填充与重试计数复位不用 `useEffect``react-hooks/set-state-in-effect`):前者渲染期按上一 prop 调整,后者在 `applyCaseSnapshot`(有问题到达)与 `setPending(true)` 里复位。
3. 首页卡片不带 `aria-busy``tests/home-bootstrap-reveal.test.ts` 锁死 `starter-home.tsx` 不得出现 `aria-busy={`。卡片只用 `data-opening` + 静态文案 + `cursor: progress`;侧栏行仍带 `aria-busy`
4. 缺口的 live 行放在 transcript 末尾(`.rectification-message-wrap.rectification-question-gap`),不是 staging 原来的 composer 上方状态行;`.rectification-composer-status` 样式删除。
## 1.3 核实
`frontend/src/app/api/rectification/cases/[caseId]/route.ts` 的 dossier 响应只带 `accepted_time` / `confirmed_time` / `candidate_range`,无 natal/declared 宫位表;按任务书只做文案与收窄,`declaredTime``page.tsx` 从 profile`reportedTime || time`,须为 `H:MM`)派生。
## 被触碰的既有断言(红线 4,每处源码上方均注明原值与错因)
| 文件 | 原断言 | 现在 | 理由 |
| --- | --- | --- | --- |
| `tests/rectification-agentic-entry.test.ts` :85、:194 附近 | `key={…-${rectificationTurns.length > 0 ? "ready" : "loading"}}` | key 只含 session/Case`doesNotMatch /"ready" : "loading"/` | 锁的正是重挂缺陷(BUG-505) |
| 同上 :87 | hook 内联 `methods: Array.isArray((turn.receipt as { methods?: unknown }).methods)` | `parsePersistedRectificationTurns(payload?.turns)` + lib 里 `methods: stringList(receipt.methods)` | 解析器搬到 lib 供 hydration 与刷新共用(BUG-505 |
| 同上 :233:236 | `await loadCaseSnapshot(); choiceContinuationPending.current = true;`、effect 内 `void send("read_only", "")` | `await loadCaseSnapshot(); … await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel })``doesNotMatch /choiceContinuationPending/`;原 `doesNotMatch` 裸调用锁保留 | 锁的是 effect 中转(BUG-506 |
| 同上 workspace class | 无 `is-board-empty` 的类模板 | 含 `${candidateResult ? "" : " is-board-empty"}` | 板收窄(BUG-509 |
| `tests/rectification-question-in-message.test.ts` :136137 | `/当前没有可回答的问题,正在等待服务端更新/``/题目加载失败,请刷新/` | `doesNotMatch` 两句 + 锁 `RECTIFICATION_QUESTION_PREPARING_LABEL` / `_UNAVAILABLE_COPY` | 锁的是裸等待文案(BUG-505) |
| `tests/rectification-spoken-collect.test.ts` "missing current_question…" | `showMissingQuestion = Boolean(…)` 与同两句文案 | `rectificationQuestionGapState({ … questionMissing: currentQuestion === null … resumableCase })``doesNotMatch` 两句 | 同上;仍锁 resumable 门 |
| `tests/rectification-answer-choice.test.ts` :1201 | `/send\("read_only", ""\)/` | `send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel })` | 后续轮带续接行(BUG-506 |
新增测试:`tests/rectification-surface-state.test.ts`10)、`tests/rectification-surface-contract.test.ts`8)。
## 命令输出
`./node_modules/.bin/tsc --noEmit`**0**(每个 commit 前各一次,rebase 后再一次)。
`npm run lint`**0 error73 warning**;触及文件里唯一的 warning 是 `rectification-agentic-chat.tsx` `submitStructuredChoice` 的既有 `exhaustive-deps`(staging 同在,依赖表原本就缺 `busy` / `actionIdForChoice`,本轮未动);新建的三个文件 0 warning。
`./node_modules/.bin/tsx --test tests/*.test.ts`(基线在干净 `af411f59` 上跑):
| | 基线 `af411f59` | 现在 |
| --- | ---: | ---: |
| tests | 2584 | 2602 |
| pass | 2550 | 2568 |
| fail | 24 | 24 |
| skipped | 10 | 10 |
24 条失败与基线逐条比对**完全同一集合**(`comm -3` 差集为空):全部是本机无 Docker / PostgreSQL / 部署夹具的既有缺口。10 条 skipped 同基线。rebase 到 `8db71aaf` / `0cefaea6` 只改了 md 文件,src/tests 逐字节相同,全量数字沿用;rebase 后复跑 tsc 与校正相关聚焦套件(见下)。
聚焦套件(rebase 后,15 个文件):`rectification-surface-state``rectification-surface-contract``rectification-agentic-entry``rectification-question-in-message``rectification-spoken-collect``rectification-answer-choice``rectification-board-model``rectification-varga-style-copy``home-bootstrap-reveal``chat-session-url``chat-notice-and-scroll-contract``starter-questions``class-name-definition-contract``touch-target-contract``dark-theme-contract`**173 / 173**
`./node_modules/.bin/next build`**0**`✓ Compiled successfully in 17.8s``Finished TypeScript in 41s``Generating static pages (62/62)``/` 仍是 `○ (Static)`
## 未做与原因
`BLOCKED.md` 顶部:浏览器级手工验收(六条手工项 + 深链/刷新 + 缺口重试 + 整轮录屏)无登录态无 Chrome,已写进 `docs/testing/staging-manual-walkthrough-20260901.md` 第 8 节;1.3 的宫位表 API 不提供;四条书面偏差。
+1 -1
View File
@@ -44,7 +44,7 @@
| `TASK-rectification-adopt-flow-20260902.md` | — | 采用流程整治 | 已验收 | `35e5781e`BUG-497500 |
| `TASK-rectification-adopt-flow-fix-20260903.md` | — | 采用流程修复单(含删「用这个时间看盘」) | 已验收 | `e8c98c37`BUG-501/502 |
| `TASK-rectification-uncertainty-stop-20260903.md` | — | 不确定度停止规则加样本下限 | 已验收 | `0c0df426`BUG-503 |
| `TASK-rectification-ux-20260902.md` | | 会话面空白假死与交互摩擦 | 执行中 | 分支 `codex/rectification-ux-20260903`09-03 在新基线重做,BUG-505 |
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 待合并 | 分支 `codex/rectification-ux-20260903`09-03 在新基线重做,BUG-505509,已推送待验收 |
### 聊天主链路与首页
@@ -53,6 +53,29 @@
- 找一条消息很多的老对话点开:侧栏应立即出现,消息稍后加载(有加载态),不卡整页。
- 星盘库增删改、发起合盘、点每日星语、进出生时校正:应与改造前一致——这四块代码整体搬过家,重点看有没有点了没反应或样式塌掉。
## 8. 生时校正面:一次揭幕与无缝续接(BUG-505–509)
1. 首页点「生时校正」卡片。
- ✅ 预期:卡片脚注立即变成「正在打开…」(不转圈),随后**一次性**出现完整面板:历史消息、当前问题、右侧盘面同时就位;不会先出现一个空面板再填内容。
2. 在普通对话里,从侧栏点一个已有的校正会话。
- ✅ 预期:旧画面保持不动,侧栏该行短暂显示「打开中」,然后直接切到校正面板;**不闪**一帧普通对话,**不出现**空白面板。
3. 直接用带 `?c=` 的校正会话链接打开页面,或在校正会话里刷新。
- ✅ 预期:加载屏结束后直接是校正面板,不会先出现普通对话再换成校正面板。
4. 新建一段校正,等第一条引导说完。
- ✅ 预期:进行中行先写「正在读取你的出生资料,准备第一个问题…」;第一轮结束的瞬间画面不闪、滚动不归零、时间线开合状态保留。
5. 连续答两道选择题。
- ✅ 预期:点完选项立刻在卡上看到对勾「已选择」、卡顶一行「正在记录…」,下面一条「正在记录本次选择…」的进行中行**一直在**,直到下一题出现在新的回复里;中间没有空帧,下一题卡片不会闪现又消失。
6. 出现候选时间后点「采用此时间」。
- ✅ 预期:transcript 末尾出现「正在采用 HH:MM…」进行中行,同一条行一直到后续回复出现。
7. 找一个当初开场失败的旧校正会话(记录为空)进入。
- ✅ 预期:看到「这段校正还没有开始。」和「开始提问」按钮,点后正常开场。
8. 右侧盘面在没有候选前。
- ✅ 预期:头部时钟位显示你填报的出生时间,正文是「填报出生时间 HH:MM」两行;面板比有结果时窄一些。
9. 流式回答中点停止。
- ✅ 预期:已生成内容保留,提示「已停止,已生成的内容保留;本次不会扣点。」,不是「暂时不可用」。
10. 一轮结束后下一题迟迟不来(可用 DevTools 把 `/api/rectification/cases/*` 节流或断网)。
- ✅ 预期:末尾是一条「正在准备下一个问题…」的进行中行,约 4 秒后变成「没有拿到下一个问题。」和「重新加载」按钮;任何时候都不出现「等待服务端更新」。
---
四轮改造:BUG-464(消息权威化)、BUG-465(会话 URL)、BUG-466(云端唯一真相)、拆页两批。自动化侧每轮均已验收(tsc / 2400+ 条测试 / 构建 / SQL 幂等),此清单覆盖的是自动化够不着的真浏览器行为。
+27
View File
@@ -200,6 +200,31 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
- **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows.
- **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative.
### Rectification surface
The birth-time rectification session is the consultation transcript plus a house board; questions live inside the assistant message that asked them. It shares every waiting vocabulary with the consultation surface; nothing here spins or says "loading" after the reveal.
- **Reveal:** the surface mounts once per session/Case binding. Opening a Case (homepage card, sidebar row, deep link or refresh) reads the turns and the snapshot in one Case request before the switch, under the same 4-second budget as the home reveal (`RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS` is `BOOTSTRAP_PREPARE_TIMEOUT_MS`); a session selected at bootstrap is hydrated during the prepare phase. The entry shows a static note meanwhile (card footer “正在打开…”, sidebar row “打开中”, `cursor: progress`) and the previous view stays put. Turns and snapshot are initial state; anything that arrives later is a prop or state update, never a remount. A late or failed read still reveals, with the composer notice “校正记录没有完全加载,可以继续”.
- **States** — what the transcript's trailing entry and the composer show:
| State | Trailing entry | Composer |
|---|---|---|
| `opening` | live row “正在读取你的出生资料,准备第一个问题…”, then tool labels | disabled, stop visible |
| `empty` | “这段校正还没有开始。” and one primary action “开始提问” | enabled |
| `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card |
| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled |
| `choice-pending` | the answered card (`Check` + “已选择”, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | disabled, stop visible |
| `candidates` | the offer cards under the message that offered them | enabled |
| `adopting` | “正在采用 HH:MM…” through the follow-up turn | disabled during the chain |
| `confirmed` | “已确认校正时间:HH:MM” | enabled |
| `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled |
| stopped | the row settles with what streamed; notice “已停止,已生成的内容保留;本次不会扣点。” | enabled |
- **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a reload button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves.
- **Board:** before any candidate exists the header clock shows the declared birth minute and the body reads “填报出生时间 HH:MM” / “回答几个问题后,这里会显示宫位随时间的变化。”; the column narrows to `minmax(16rem, 18rem)` (`is-board-empty`) and widens once a result arrives. No house table is invented for the declared time; the snapshot API does not provide one.
- **Accessibility:** the sidebar opening note sets `aria-busy`; the live row is the timeline row (`role="status"` shimmer label); the reload and start actions are real 44px buttons.
### Birth time intake
- **Structure:** birth date, five radio choice rows for time knowledge, then only the time, uncertainty, period, or clue field required by the selected source.
@@ -407,6 +432,8 @@ or user IDs.
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { homeSurface as page } from "./home-surface.ts";
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const chat = read("../src/components/rectification-agentic-chat.tsx");
const hook = read("../src/hooks/use-rectification-surface.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const bootstrap = read("../src/lib/home-bootstrap.ts");
const starter = read("../src/components/starter-home.tsx");
const sidebarRow = read("../src/components/sidebar-session-row.tsx");
const board = read("../src/components/rectification-board.tsx");
const choiceCard = read("../src/components/rectification-choice-card.tsx");
const wrapper = read("../src/components/conversational-birth-time-rectification.tsx");
const timeline = read("../src/components/consultation-run-timeline.tsx");
const styles = read("../src/app/globals.css");
test("the rectification surface is revealed once: Case hydration precedes the switch and the key never remounts", () => {
// BUG-505: open → hydrate (turns + snapshot, one Case read, deadline) → switch, in that order.
assert.match(hook, /const hydration = await hydrateRectificationCase\(opened\.caseId, opened\.sessionId, \{\s*timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,/);
const openBody = hook.slice(hook.indexOf("const hydration = await hydrateRectificationCase"), hook.indexOf("void refreshRectificationEntrySummary();"));
assert.match(openBody, /setRectificationTurns\(hydration\.turns\);[\s\S]*setRectificationSnapshot\(hydration\.snapshot\);[\s\S]*setActiveSessionId\(opened\.sessionId\);/);
assert.match(openBody, /if \(!hydration\.complete\) setComposerNotice\(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE\);/);
assert.doesNotMatch(hook, /void refreshRectificationCase\(opened\.caseId/);
// The selection source is read before any await; selectSession resets it synchronously.
assert.match(hook, /const selectionSource = sessionSelectionSource\.current;[\s\S]*await fetch\("\/api\/rectification\/cases\/open"/);
assert.match(hook, /selectionSource === "user"\) \{\s*writeSessionUrl\(opened\.sessionId, "push"\)/);
// selectSession does not switch to a rectification session before its Case is hydrated.
assert.match(sessions, /const deferredRectificationSwitch = nextSession\?\.sessionType === "birth_time_rectification"\s*&& nextSession\.id !== rectificationSessionId;/);
assert.match(sessions, /if \(!deferredRectificationSwitch\) setActiveSessionId\(sessionId\);/);
assert.match(sessions, /if \(!deferredRectificationSwitch && !uiPreview\.current && sessionSelectionSource\.current === "user"\) \{\s*writeSessionUrl\(sessionId, "push"\);/);
// Panel: one key per binding, snapshot and turns as initial state, later turns as a prop update.
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.doesNotMatch(page, /"ready" : "loading"/);
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
assert.match(page, /declaredTime=\{rectificationDeclaredTime\}/);
assert.match(wrapper, /initialSnapshot: RectificationCaseSnapshotPayload \| null;/);
assert.match(chat, /useState\(initialSnapshot !== null\)/);
// Later turns fill an empty transcript by render-time adjustment from the previous prop, not an effect.
assert.match(chat, /const \[seededTurns, setSeededTurns\] = useState\(initialTurns\);\s*if \(seededTurns !== initialTurns\) \{\s*setSeededTurns\(initialTurns\);\s*if \(messages\.length === 0 && initialTurns\.length > 0\) setMessages\(messagesFromTurns\(initialTurns\)\);/);
// No self-fetch on mount; unmount ends the stream and any snapshot read.
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*const controller = new AbortController\(\);\s*void fetch\(/);
assert.match(chat, /useEffect\(\(\) => \(\) => \{\s*runAbort\.current\?\.abort\(\);\s*snapshotAbort\.current\?\.abort\(\);\s*\}, \[\]\);/);
});
test("a rectification session selected at bootstrap is hydrated before the reveal, and popstate defers the same way", () => {
// The resume effect runs during the prepare phase, not only after the reveal.
assert.match(page, /if \(\(!hydrated && bootstrapPhase === "account"\)\s*\|\| !account/);
assert.match(bootstrap, /if \(state\.rectificationApplicable && !state\.rectificationSettled\) return false;/);
assert.match(page, /rectificationApplicable: activeSession\?\.sessionType === "birth_time_rectification",\s*rectificationSettled: activeSession\?\.id === rectificationSessionId\s*\|\| rectificationError !== ""\s*\|\| !profileComplete,/);
// One deadline constant for hydration and the home reveal.
assert.match(read("../src/lib/rectification-surface-state.ts"), /export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;/);
// History navigation goes through selectSession, which defers the switch (locked above).
assert.match(sessions, /sessionSelectionSource\.current = "history";\s*selectSession\(requestedId\);/);
});
test("entry feedback is static: card and sidebar row say 正在打开, and nothing spins after the reveal", () => {
assert.match(page, /const rectificationCardLabel = rectificationLoading\s*\? RECTIFICATION_OPENING_LABEL/);
assert.match(starter, /data-opening=\{rectificationLoading \? "true" : undefined\}/);
assert.doesNotMatch(starter, /InlineSpinner/);
assert.match(sidebarRow, /aria-busy=\{opening \? true : undefined\}/);
assert.match(sidebarRow, /RECTIFICATION_SIDEBAR_OPENING_NOTE/);
assert.match(page, /openingSessionId=\{rectificationOpeningSessionId\}/);
assert.match(styles, /\.product-entrypoint-card\[data-opening="true"\][^{]*\{ cursor: progress; \}/);
assert.match(styles, /\.session-opening-note \{/);
// The only spinner inside the chat is a timeline live row (generation), never a loading state.
assert.equal((chat.match(/InlineSpinner/g) ?? []).length, 0);
assert.match(timeline, /export function ConsultationTimelineLiveRow/);
assert.match(chat, /<ConsultationTimelineLiveRow id="question-preparing" label=\{RECTIFICATION_QUESTION_PREPARING_LABEL\} \/>/);
});
test("the question gap is a live row with retries, then a reload; it never tells the reader to wait for the server", () => {
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新|rectification-composer-status/);
assert.doesNotMatch(chat, /showMissingQuestion|showUnavailableQuestion|showQuestionLoadFailed/);
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{/);
assert.match(chat, /questionGap === "preparing" && \(/);
assert.match(chat, /questionGap === "unavailable" && \(/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
// Attempts reset in handlers (a question arriving, a turn starting), never in an effect.
assert.match(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
assert.match(chat, /if \(value\) setQuestionRetryAttempts\(0\);/);
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(currentQuestion !== null/);
// In flow: the gap is the last entry of the transcript, after the message loop, before the saved-time line.
const gapIndex = chat.indexOf('{questionGap === "preparing" && (');
assert.ok(gapIndex > chat.indexOf("{messages.map((message) => {"));
assert.ok(gapIndex < chat.indexOf("{savedTime && savedStatus === \"confirmed\""));
assert.match(styles, /\.rectification-question-gap \{/);
});
test("a tapped choice or an adopted candidate keeps one live row through the follow-up turn", () => {
// BUG-506: no effect hop, no removed row, busy held across the chain.
assert.doesNotMatch(chat, /choiceContinuationPending/);
assert.match(chat, /const send = useCallback\(async \(\s*action: "opening" \| "message" \| "read_only",\s*messageText: string,\s*continuation\?: Readonly<\{ reuseAssistantRenderKey: string; label: string \}>,/);
assert.match(chat, /if \(!continuation && busy\) return;/);
assert.match(chat, /const initialLabel = rectificationInitialLiveLabel\(action, continuation\?\.label\);/);
assert.match(chat, /beginLiveRun\(initialLabel\);/);
assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel \}\);/);
assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
assert.match(chat, /rectificationAdoptingLabel\(candidateTime\)/);
assert.doesNotMatch(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\),\s*turns,\s*\)\);/);
});
test("an empty Case with no automatic opening offers a way to start", () => {
// BUG-507
assert.match(chat, /conversationState === "empty" && \(/);
assert.match(chat, /RECTIFICATION_EMPTY_COPY/);
assert.match(chat, /onClick=\{startOpeningManually\}>\{RECTIFICATION_EMPTY_ACTION_LABEL\}/);
assert.match(chat, /function startOpeningManually\(\) \{\s*if \(readonly \|\| busy \|\| openingStarted\.current\) return;\s*openingStarted\.current = true;/);
assert.match(styles, /\.rectification-empty-state \{/);
});
test("stopping keeps what streamed and says so; a 402 explains itself before leaving", () => {
assert.match(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\);/);
assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*window\.setTimeout\(\(\) => \{\s*window\.location\.assign\(membershipHref\("rectification"\)\);\s*\}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS\);/);
});
test("the choice card confirms the tap and the board's first state shows the declared minute", () => {
// BUG-508
assert.match(choiceCard, /<Check aria-hidden="true" \/>/);
assert.match(choiceCard, /已选择/);
assert.match(choiceCard, /rectification-choice-card__pending/);
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
assert.match(styles, /\.rectification-choice-card__selected \{/);
assert.match(styles, /\.rectification-choice-card__pending \{/);
// BUG-509
assert.match(board, /declaredTime: string \| null;/);
assert.match(board, /rectificationBoardEmptyCopy\(declaredTime\)/);
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime\)/);
assert.doesNotMatch(board, /补充经历后,这里会显示当前本命宫位和换升时刻/);
assert.match(chat, /\$\{candidateResult \? "" : " is-board-empty"\}/);
assert.match(styles, /\.rectification-workspace\.is-board-empty \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
// The empty modifier is declared before `.is-compact`, so the single-column layout still wins.
assert.ok(styles.indexOf(".rectification-workspace.is-board-empty {") < styles.indexOf(".rectification-workspace.is-compact {"));
});