Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
11 KiB
PROGRESS · 生时校正会话面:消除空白假死与交互摩擦(2026-09-02)
工作树:.worktrees/rectification-ux-20260902
分支:codex/rectification-ux-20260902
基线:origin/staging @ 4dc0c8c7(codex/streaming-ux-20260901 的 merge commit;任务书前置条件满足)。
执行时 TASK-rectification-walkthrough-polish-20260902.md 与 TASK-unified-loading-20260902.md 均未落地(远端无对应分支):B.2/D.2 按任务书由本轮承担,没有 question.ready 事件,0.4 走"完成后立即拉一次 + 定时重试"的分支。
未改服务端与 SQL。未改 .gitea/workflows/**。未合并 staging,未碰 main。未手写 useCallback / useMemo(rectification-agentic-chat.tsx 既有的原样保留,新增的 refetchQuestion / reloadQuestion / startOpeningManually 是普通函数)。BUG 编号开工时确认远端最大号为 478,本轮 479–483。
| 任务 | 状态 | commit | BUG |
|---|---|---|---|
| 0.1 入口有反馈不转圈 | 完成 | 91b8b33a |
BUG-479 |
| 0.2 一次揭幕、面板不重挂 | 完成 | 91b8b33a |
BUG-479 |
| 0.3 问题槽只有生成中态、在对话流内 | 完成 | 91b8b33a |
BUG-479 |
| 0.4 「等待服务端更新」→ live 行 + 重试 + 重新加载 | 完成 | 91b8b33a |
BUG-479 |
| 0.5 选择题/采用不留缝不闪卡 | 完成 | 91b8b33a |
BUG-480 |
| 0.6 空 turns 会话的起点 | 完成 | 91b8b33a |
BUG-481 |
| 0.7 停止后的文案 | 完成(现状不足,已补) | 91b8b33a |
— |
| 1.1 选择卡确认感 | 完成 | ef11f6e3 |
BUG-482 |
| 1.2 用户选择回显 | 完成(在 91b8b33a,与 send 重写同一处) |
91b8b33a |
BUG-482 |
| 1.3 盘面首态 | 完成(文案 + 收窄;宫位表 API 不提供,未造数据) | ef11f6e3 |
BUG-483 |
| 1.4 开场 live 文案 | 完成(在 91b8b33a) |
91b8b33a |
— |
| 1.5 402 跳转前提示 | 完成(在 91b8b33a) |
91b8b33a |
— |
| 2 DESIGN.md | 完成 | 本 docs commit | — |
1.2 / 1.4 / 1.5 的改动都落在 send 与 submitStructuredChoice 的重写里,无法从任务 0 的 commit 里干净拆出,所以随 91b8b33a 提交。
六段空白与死角:各自的消除证据
| 空白 | 消除方式 | 证据(契约 / 纯函数用例) |
|---|---|---|
| 1 首页卡片无反馈 | rectificationCardLabel 在 rectificationLoading 时为「正在打开…」;卡片 aria-busy + data-opening + cursor: progress;侧栏目标行「打开中」 |
rectification-surface-contract → "entry feedback is static…" |
| 2 侧栏进入:普通对话 → 空白 → 重挂 | selectSession 对未打开的校正会话不再先 setActiveSessionId/写 URL;hook 在 /cases/open 后 hydrateRectificationCase(一次 Case 读取取 turns + 快照,4 秒上限)再一次性切换;面板 key 去掉 -ready/-loading |
同上 → "…revealed once…";rectification-surface-state → "hydration reads turns and snapshot from one Case read" / "…resolves incomplete…" |
| 3 第一轮结束整面重挂 | key 只剩 ${sessionId}-${caseId};后到的 turns 经 prop 更新只填空 transcript(渲染中按上一 prop 调整) |
同上(doesNotMatch(page, /"ready" : "loading"/)、seededTurns 锁) |
| 4 快照回来前问题槽空 | 快照随揭幕到位(initialSnapshot 初始化 state,caseSnapshotLoaded 初值 initialSnapshot !== null);面板不再自拉 |
同上(useState(initialSnapshot !== null)、无挂载 fetch) |
| 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…" |
| 文案 「等待服务端更新」 | rectificationQuestionSlotState 四态:preparing(时间线 live 行「正在准备下一个问题…」+ 2s 定时重拉 ≤2 次)→ unavailable(「没有拿到下一个问题。」+ 44px「重新加载」) |
rectification-surface-state → "question slot: a gap is preparing…";契约 → "…never tells the reader to wait for the server" |
改动文件
- 新建:
frontend/src/lib/rectification-surface-state.ts(常量、hydration、turn 解析、两个纯状态函数、板空态文案、填报时间派生)、frontend/tests/rectification-surface-state.test.ts(9 用例)、frontend/tests/rectification-surface-contract.test.ts(8 用例)。 - 改动:
use-rectification-surface.ts、use-session-management.ts、page.tsx(state 2 处、hook 参数、卡片文案、面板 key/props、侧栏 prop、import)、app-sidebar.tsx、sidebar-session-row.tsx、starter-home.tsx、conversational-birth-time-rectification.tsx(props)、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 节)、BLOCKED.md。
1.3 核实
frontend/src/app/api/rectification/cases/[caseId]/route.ts 的 dossierResponse 只带 accepted_time / confirmed_time / candidate_range,grep -n 'natal\|declared\|birth_time\|profile' 无命中 → 快照 API 不提供填报时间的宫位表。按任务书只做文案与收窄,declaredTime 由 page.tsx 从 profile(reportedTime || time,须为 H:MM)派生。
与任务书的书面偏差(均登记 BLOCKED.md)
- hydration 是一次
GET /api/rectification/cases/:id(turns 与快照同一端点)套 4 秒Promise.race,不是Promise.allSettled([turns, snapshot]);语义不变。契约锁改锁hydrateRectificationCase+RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS。unified-loading 落地时应把它的 4 秒常量与RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS合一。 - turns 后到的填充与重试计数复位不用
useEffect(npm run lint的react-hooks/set-state-in-effect拦):前者是渲染中按上一 prop 调整 state,后者在applyCaseSnapshot(有问题到达)与setPending(true)里复位。 - 选择卡 pending 行用
InlineSpinner+ shimmer 文案而不是嵌一个ConsultationTimelineLiveRow:卡片内是<p role="status">,与时间线 live 行同一形态(同 spinner、同agent-activity-status__text)。
被触碰的既有断言(红线 4,每处源码上方均注明原值与错因)
| 文件 | 原断言 | 现在 | 理由 |
|---|---|---|---|
tests/rectification-agentic-entry.test.ts :85、:194 |
key={…-${rectificationTurns.length > 0 ? "ready" : "loading"}} |
key 只含 session/Case;doesNotMatch /"ready" : "loading"/;initialSnapshot={rectificationSnapshot} |
锁的正是重挂缺陷(BUG-479) |
| 同上 :93 | page 含 turn 解析的 methods: Array.isArray(…) |
改读 rectification-surface-state.ts,page 锁 parsePersistedRectificationTurns(payload?.turns) |
解析器搬家,形状不变(BUG-479) |
| 同上 :221–:224 | await loadCaseSnapshot(); … choiceContinuationPending.current = true;、effect 里 void send("read_only", "")、doesNotMatch 同链 await send |
锁 await loadCaseSnapshot(); … await send("read_only", "", { reuseAssistantRenderKey…, label: adoptingLabel }),doesNotMatch /choiceContinuationPending/ |
锁的是 effect 中转本身(BUG-480) |
| 同上 :504 | workspace className 以 is-board-open 结尾 |
追加 ${candidateResult ? "" : " is-board-empty"} |
空板收窄(BUG-483) |
| 同上 :557 | match(chat, /isStructuredChoiceUserText/) |
doesNotMatch + userVisibleChoiceLine(answeredCard, optionId) |
过滤掉的正是用户回显(BUG-482) |
tests/rectification-answer-choice.test.ts :1123 |
send\("read_only", ""\) |
await send("read_only", "", { reuseAssistantRenderKey…, label: recordingLabel }) |
续接参数(BUG-480) |
tests/rectification-spoken-collect.test.ts :134–:138 |
showMissingQuestion = Boolean(caseSnapshotLoaded…currentQuestion === null + 「当前没有可回答的问题,正在等待服务端更新」 |
锁 rectificationQuestionSlotState({… snapshotLoaded: caseSnapshotLoaded, … resumableCase,;doesNotMatch /等待服务端更新/ |
锁的正是开发者文案(BUG-479) |
未改任何其它既有断言;rectification-answer-choice :583 的 isStructuredChoiceUserText 纯函数用例原样保留(函数仍存在,只是不再用于过滤渲染)。
命令输出
./node_modules/.bin/tsc --noEmit → 0(任务 0、任务 1 后各一次)。
./node_modules/.bin/eslint 触及 13 个文件 → 0 error,42 warning 全在 page.tsx(与 origin/staging 同文件既有 react-hooks warning;本轮在 page.tsx 只加了 2 个 state、1 个派生值、4 行 props,未新增 warning)。开发中一度因 effect 内 setState 报 2 个 error,已改(见偏差 2)。
Python quick gate 4 个文件(test_daily_and_rectification_entrypoints / test_session_management_entrypoints / test_birth_time_journey_contract / test_supabase_user_data_contract)→ 24 passed。
./node_modules/.bin/tsx --test tests/*.test.ts(基线在同一 worktree 的 4dc0c8c7 干净状态上跑):
基线 4dc0c8c7 |
现在 | |
|---|---|---|
| tests | 2486 | 2503 |
| pass | 2452 | 2469 |
| fail | 24 | 24 |
| skipped | 10 | 10 |
失败清单与基线逐条比对:comm -3 差集为空,同一集合。24 条失败全是本机无 Docker / PostgreSQL / 部署夹具的数据库、Better Auth、staging workflow、live sync 类既有缺口(与前两轮 PROGRESS 同一组)。
聚焦 26 套件(本轮 2 个新文件 + 校正/会话/composer/滚动/样式契约)→ 248 / 248。
./node_modules/.bin/next build → 0。✓ Compiled successfully in 49s、Finished TypeScript in 49s、Generating static pages (62/62),/ 仍是 ○ (Static)。5 条既有 skill-package-registry.ts filesystem-tracing warning,未失败。
未做与原因
见 BLOCKED.md 顶部:浏览器级手工验收(六条手工项 + 整轮录屏)无登录态无 Chrome,已写进 docs/testing/staging-manual-walkthrough-20260901.md 第 8 节;1.3 的宫位表 API 不提供;两条书面偏差。