Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
16 KiB
任务书 · 生时校正会话面:消除空白假死与交互摩擦(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):
- 追加一条 thinking 行「正在记录本次选择…」(好)。
fetch返回 →await loadCaseSnapshot()→willContinue分支把这条 thinking 行删掉,置choiceContinuationPending,finally setPending(false)。- 下一次 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 附近的通用兜底)。
硬红线
- 不改服务端语义、不改 SQL。
should_start_opening、choice.applied的返回、awaitTurnExitBeforeResponse都不动。死角修复用已有的send("opening")(服务端已抑制重复 opening,rectification-agentic-entry.test.ts:190 锁着这条性质)。 - 只用上一轮统一好的那一套活动 UI:live 行 =
ConsultationRunTimeline的 queued/live 行(InlineSpinner+ shimmer 文案),不得新造第二种 spinner、骨架屏或呼吸动画。§9 等待词汇表不扩表。 - 不得手写
useCallback/useMemo;rectification-agentic-chat.tsx既有的不删不加。 - 不得修改既有测试断言,除非它锁的正是缺陷本身(本轮明确允许:
rectification-agentic-entry.test.ts:79/:190 的-ready/-loadingkey 锁、任何锁「等待服务端更新」文案的断言);改时在断言上方注释原值与错因,PROGRESS 单列。 ./node_modules/.bin/tsc --noEmit通过(不要npx tsc);next build通过;测试数不低于基线、失败清单逐条比对无新增。- 浅色/深色/
prefers-reduced-motion三套都验。 - 不改
.gitea/workflows/**;不在脏工作树切分支;不自行把 staging 提升到 main。 - 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 命中时:
- 先自动重拉快照:最多 3 次、间隔 2s(
useVisibilityAwarePoll已有,复用),期间问题槽显示 live 行「正在准备下一个问题…」。 - 3 次后仍命中:显示「没有拿到下一个问题。」+ 一个 44px 次级按钮「重新加载」(调
loadCaseSnapshot)。 - 两条旧文案从源码删除。
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 编号、未做与原因。