Files
Jyotisha/TASK-rectification-ux-20260902.md
T

16 KiB
Raw Blame History

任务书 · 生时校正会话面:消除空白假死与交互摩擦(2026-09-02)

基线:codex/streaming-ux-20260901HEAD c846c44a)合入后的 origin/staging。本轮改的文件与那条分支高度重叠(rectification-agentic-chat.tsxpage.tsxglobals.cssrectification-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.tsxrectificationSurfaceOpen = activeRectificationSession && activeSession.id === rectificationSessionId——在 open 返回前是 false,于是这一帧渲染的是普通 ChatTranscript(用镜像的 session.messages)。open 返回后 setRectificationTurns([])、面板以 key=…-loading 挂载,initialTurns=[]整块空白(没有任何 loading 文案),直到 refreshRectificationCase 拉回 turnskey 翻成 -ready整个面板卸载重挂。三段画面:普通对话 → 空白 → 校正面板。

空白 3 · 新建校正:第一轮回答结束时整个面板重挂一次

同一个 key:新 case 挂载时 turns 为空(-loading),开场轮 run.completedonCompletedrefreshRectificationCase → turns > 0 → key 变 -ready重挂。用户刚读完第一条引导,画面闪一下、滚动归零、时间线开合状态丢失、消息从内存态换成持久化态(trace 只剩回执)。如果用户在这一拍已经开始输入第二轮,重挂会丢掉那次流(runAbort 不在卸载时中止,setMessages 落到已卸载的实例)。tests/rectification-agentic-entry.test.ts :79、:190 两处正则锁的正是这个 key 写法

空白 4 · 恢复会话后,问题槽在快照回来前什么都不显示

rectification-agentic-chat.tsxcaseSnapshotLoaded 为 false 时 showLiveChoiceCard / showMissingQuestion / showUnavailableQuestion 全为 false.rectification-question-slot 是空的。用户看到历史消息但没有可做的事,不知道要等。

空白 5 · 选择题点下去之后有两段缝、一次闪卡

submitStructuredChoice:760:860):

  1. 追加一条 thinking 行「正在记录本次选择…」(好)。
  2. fetch 返回 → await loadCaseSnapshot()willContinue 分支把这条 thinking 行删掉,置 choiceContinuationPendingfinally setPending(false)
  3. 下一次 effect 才 send("read_only") → 再追加一条新的 thinking 行「正在处理…」并 setPending(true)

2→3 之间至少有一帧:没有任何 live 行,且 busy=false + 快照刚装进的新 choiceCardshowLiveChoiceCard 为 true → 下一题的卡片闪现一帧又消失。然后 read_only 的回答流完 → run.completedawait loadCaseSnapshot()(这次在 finally 之前,busy 仍为 true,没缝)→ 卡片出现。

空白 6 · 采用候选时间:只有按钮文案变了

acceptCandidatesetAcceptingCandidateId + setPending(true)transcript 里不出现任何 live 行;POST 完成 → await loadCaseSnapshot()choiceContinuationPending → 再走空白 5 的 effect 路径。用户盯着一个变灰的按钮「正在采用…」等好几秒,页面其它部分静止。

死角 · 空 turns 的已有会话永远空白

服务端 open_agentic_rectification_case_v2resumed 一律返回 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"的用户气泡;服务端已经返回 userMessageapplied.userDisplay)但客户端没用;持久化 turn 里的结构化选择又被 isStructuredChoiceUserText 过滤掉。刷新前后都看不到自己答了什么。
  • 右侧盘面首态是一整块空面板:桌面端 minmax(18rem, 22.5rem) 的面板,第一阶段只有一句「补充经历后,这里会显示当前本命宫位和换升时刻。」;移动端 peek 是「当前盘面 · 补充经历后会在这里更新」。用户填过出生时间,面板却像没数据。
  • 开场 live 行文案是通用的「正在处理…」,第一次进入的用户不知道系统在做什么。
  • 停止sendcatch 已区分 AbortError,但请核对 aborted 分支落地的文案不是「生时校正暂时不可用,请稍后再试。」(当前 :700 附近的通用兜底)。

硬红线

  1. 不改服务端语义、不改 SQL。 should_start_openingchoice.applied 的返回、awaitTurnExitBeforeResponse 都不动。死角修复用已有的 send("opening")(服务端已抑制重复 openingrectification-agentic-entry.test.ts :190 锁着这条性质)。
  2. 只用上一轮统一好的那一套活动 UIlive 行 = ConsultationRunTimeline 的 queued/live 行(InlineSpinner + shimmer 文案),不得新造第二种 spinner、骨架屏或呼吸动画。§9 等待词汇表不扩表。
  3. 不得手写 useCallback / useMemorectification-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 最大 472streaming 分支占 473478本轮从 479 起,仍需现场确认)。

让步顺序:功能与测试不回归 > 可验证的修复 > 视觉一致 > 代码整洁。


任务 0P0)· 六段空白与死角

0.1 入口卡片有反馈

starter-home.tsxrectificationLoading 时卡片 aria-busy="true"footer 的 action 文案换成「正在打开…」并在前面放 InlineSpinner size={14};卡片整体 cursor: progress。文案在 rectificationCardLabel 的派生处加一个 loading 分支,不要在组件里硬编码两份。

0.2 校正会话面板挂一次、不重挂、有恢复态

  • page.tsxrectificationSurfaceOpen 改为 activeRectificationSession(只要活动会话是校正类型就挂校正面板),面板 props 增加 caseId: string | nullcaseId 为 nullopen 尚未返回)或 turns 尚未加载时,面板内部渲染恢复态.message-list 里一条 queued 行「正在恢复校正记录…」(用 ConsultationRunTimelineQUEUED_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.tsselectSession 顺序不变;因为面板现在立即挂载,空白 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 选择题点击后不留缝、不闪卡

submitStructuredChoicewillContinue 分支:不删 thinking 行、不经 effect 中转。把 continuation 收进同一个 async 流程:fetch 成功 → await loadCaseSnapshot() → 直接 await send("read_only", ""),并让 send 接受一个可选参数 reuseAssistantRenderKey,用已存在的那条 thinking 行(同一个 renderKey)承接后续事件,label 从「正在记录本次选择…」自然过渡到「正在处理…」/tool 文案。busy 全程为 truesetPending(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.tsxchoiceContinuationPending、无「等待服务端更新」;存在「正在恢复校正记录」「正在恢复校正进度」「正在准备下一个问题」「这段校正还没有开始」;send 签名含 reuseAssistantRenderKey;卸载 cleanup 调 abort
  • 纯函数:新增 rectification-surface-state.ts(把"恢复中 / 空态 / 问题槽四态"的判定抽成纯函数)并测全部分支。
  • 手工清单追加到 docs/testing/staging-manual-walkthrough-20260901.md:① 首页点卡片看到「正在打开…」;② 侧栏切校正会话不闪普通对话、看到「正在恢复」;③ 新建校正第一轮结束不闪、滚动不归零;④ 连点两道选择题中间无空帧无闪卡;⑤ 采用候选看到 live 行;⑥ 一个开场失败的旧会话进来有「开始提问」。

建档

BUG-479(校正会话面挂载/重挂造成三段空白)、BUG-480(选择题与采用候选之间的缝与闪卡)、BUG-481(空 turns 会话无起点)。


任务 1P1)· 交互摩擦

1.1 选择题卡有确认感

rectification-choice-card.tsx + CSS:选中项显示 Check 图标与「已选择」;pending 时卡片顶部一行 InlineSpinner size={12} + 「正在记录…」(同 timeline live 行的排版,不另造);未选项在 pending 时降到 .48 透明度(已有)。所有选项按钮 min-height: 44px(核对 touch-target-contract)。

1.2 用户选择回显为用户气泡

选择成功后,用服务端返回的 userMessageapplied.userDisplay)在 transcript 追加一条 用户气泡role: "user"),紧跟在答过的卡片之后、thinking 行之前。持久化侧:isStructuredChoiceUserText 过滤要改成保留这类 turn 并原样显示(否则刷新后回显消失)。choiceAttachment 贴卡逻辑保留(卡本身仍显示所选项)。tests/rectification-answer-choice.test.ts 若锁了过滤行为,按红线 4。

1.3 盘面首态不空

  • rectification-board.tsxresult 为空时,header 时钟位显示 profile 的填报时间(面板 props 增加 declaredTime: string | null,由 page.tsx 从 profile 传入),正文改为两行:「填报出生时间 HH:MM」「回答几个问题后,这里会显示宫位随时间的变化。」;rectificationBoardPeekCopy 同步为「当前盘面 · 填报 HH:MM」。
  • 若快照 API 已提供填报时间对应的宫位表(先 grep natal/declared 字段确认),则直接渲染那张表作为首态;没有就不要造数据,只做文案。
  • CSSresult 为空时 .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 源码含 CheckisStructuredChoiceUserText 不再用于过滤渲染;board 源码含 declaredTimesend 源码含开场文案。
  • 手工:一轮完整校正(开场 → 3 道选择题 → 候选 → 采用)录屏,浅色一份。

建档

BUG-482(选择不回显、无确认感)、BUG-483(盘面首态空)。


任务 2P2)· DESIGN.md

§5 新增「Rectification surface」条目:

  • Statesopening(首轮引导流中)、resuming(恢复记录/进度)、empty(无 turns 有起点)、waiting-question(准备下一题,含自动重试与手动重载)、choice-livechoice-pendingcandidatesacceptedconfirmedreadonlyfailed。每态写明问题槽、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 编号、未做与原因。