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

19 KiB
Raw Blame History

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

基线:codex/streaming-ux-20260901HEAD c846c44a)合入后的 origin/staging。本轮改的文件与那条分支高度重叠(rectification-agentic-chat.tsxpage.tsxglobals.cssrectification-agentic-entry.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.tsxpolish 以服务端为主,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.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"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 的后续到达(onCompletedrefreshRectificationCase)改为 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 选择题点击后不留缝、不闪卡

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"use-rectification-surface.tsallSettled 与 4 秒常量;selectSession 对校正会话不直接 setActiveSessionId;面板源码含 initialSnapshot;揭幕后 rectification-agentic-chat.tsx / starter-home.tsx 无非生成中的 InlineSpinnerrectification-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 编号、未做与原因。