docs(rectification): add blank-state and interaction-friction brief for the rectification surface
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
# 任务书 · 生时校正会话面:消除空白假死与交互摩擦(2026-09-02)
|
||||
|
||||
基线:**`codex/streaming-ux-20260901`(HEAD `c846c44a`)合入后的 `origin/staging`**。本轮改的文件与那条分支高度重叠(`rectification-agentic-chat.tsx`、`page.tsx`、`globals.css`、`rectification-agentic-entry.test.ts`),**必须在它合入之后开工**;若开工时尚未合入,则基于该分支开 `codex/rectification-ux-20260902`,并在 PROGRESS 里写明。
|
||||
|
||||
用户反馈原话:"动画加载的过程中还有一段时间是空白状态,也没有加载也没有状态,导致用户以为页面卡了;交互也不是很友好。"下面每一条空白都对着代码找到了成因。**先读完「硬红线」再动手。**
|
||||
|
||||
---
|
||||
|
||||
## 事故实证:六段空白 + 一个死角
|
||||
|
||||
行号基于 `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"`,footer 的 action 文案换成「正在打开…」并在前面放 `InlineSpinner size={14}`;卡片整体 `cursor: progress`。文案在 `rectificationCardLabel` 的派生处加一个 loading 分支,不要在组件里硬编码两份。
|
||||
|
||||
### 0.2 校正会话面板挂一次、不重挂、有恢复态
|
||||
|
||||
- `page.tsx`:`rectificationSurfaceOpen` 改为 `activeRectificationSession`(只要活动会话是校正类型就挂校正面板),面板 props 增加 `caseId: string | null`。`caseId` 为 null(open 尚未返回)或 turns 尚未加载时,面板内部渲染**恢复态**:`.message-list` 里一条 queued 行「正在恢复校正记录…」(用 `ConsultationRunTimeline` 的 `QUEUED_TIMELINE_ROW` 形态,或直接复用 `session-messages-loading` 的 spinner + sr-only 文案),composer 禁用、placeholder「正在恢复…」。
|
||||
- **key 去掉 `-ready/-loading` 后缀**,只保留 `${sessionId}-${caseId}`。turns 的到达改为 **prop 更新**:面板内 `useEffect([initialTurns])`——当本地 `messages` 为空且 `initialTurns.length > 0` 时用 `messagesFromTurns` 填充;本地已有消息(正在流或已流过)时**忽略**这次 turns,不覆盖。`refreshRectificationCase` 仍在 `onCompleted` 后调用,但不再引起重挂。
|
||||
- `use-session-management.ts` 的 `selectSession` 顺序不变;因为面板现在立即挂载,空白 2 的"先闪普通对话"自然消失。
|
||||
- 卸载时 `runAbort.current?.abort()`(cleanup effect),防止残余流写到已卸载实例。
|
||||
|
||||
### 0.3 快照未回来时问题槽显示恢复中
|
||||
|
||||
`caseSnapshotLoaded === false && !busy && !readonly` → 问题槽渲染 live 行「正在恢复校正进度…」。快照回来后按既有逻辑切换。
|
||||
|
||||
### 0.4 两条"等待服务端更新"文案改为有动作的状态
|
||||
|
||||
`showMissingQuestion` / `showUnavailableQuestion` 命中时:
|
||||
1. 先自动重拉快照:最多 3 次、间隔 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"`;`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 编号、未做与原因。
|
||||
Reference in New Issue
Block a user