diff --git a/BLOCKED.md b/BLOCKED.md index 2c76efb2..9633cf2b 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,13 @@ # BLOCKED +## 生时校正会话面:消除空白假死与交互摩擦(2026-09-03,分支 `codex/rectification-ux-20260903`,重做) + +- **浏览器级手工验收未做:执行环境无登录态、无 Chrome。** 任务书任务 0 的手工项与任务 1 的整轮录屏(开场 → 3 道选择题 → 候选 → 采用)都做不了;已按任务书把它们写进 `docs/testing/staging-manual-walkthrough-20260901.md` 第 8 节(含深链/刷新与缺口重试两条),交给有真实会话的人。本轮的替代证据:`tests/rectification-surface-state.test.ts`(纯函数会话态/缺口态、hydration 超时/失败、turn 解析)与 `tests/rectification-surface-contract.test.ts`(源码锁:一次揭幕、prepare 阶段 hydration、无重挂、续接同一行、空态、板首态)。 +- **1.3「若快照 API 提供填报时间宫位表则直接渲染」未做:API 不提供。** `frontend/src/app/api/rectification/cases/[caseId]/route.ts` 的 dossier 响应只带 `accepted_time` / `confirmed_time` / `candidate_range`,无 natal/declared 宫位表;按任务书只做文案与收窄,不造数据。 +- **书面偏差:hydration 不是 `Promise.allSettled([turns, snapshot])`,而是一次请求套 4 秒 `Promise.race`。** turns 与快照来自同一个 `GET /api/rectification/cases/:id`,两个并行请求会重复;语义(拉完再切换、超时仍揭幕)不变。上限常量直接等于 `BOOTSTRAP_PREPARE_TIMEOUT_MS`,只有一个。 +- **书面偏差:turns 后到的填充与重试计数复位不用 `useEffect`。** `npm run lint` 的 `react-hooks` 规则拦截 effect 内同步 setState;改为 React 文档的"渲染中按上一 prop 调整 state"模式与事件处理器内复位,行为等价。 +- **书面偏差:首页卡片不带 `aria-busy`。** `tests/home-bootstrap-reveal.test.ts` 锁死 `starter-home.tsx` 不得出现 `aria-busy={`(unified-loading 裁决);卡片只用 `data-opening` + 静态文案 + `cursor: progress`,侧栏行仍带 `aria-busy`。 + ## Agent 聊天流式体验与双会话面统一(2026-09-01,分支 `codex/streaming-ux-20260901`) - **~~任务 3 未做,等待第三批拆页合入~~ 已解除并完成(2026-09-02)。** `origin/staging` 合入 `bf6989ec`(第三批)、`124d3990`、`058e5db9` 后,本分支 rebase 到其上,任务 3 按任务书原文完成:`page.tsx` 的滚动 effect 与内联「跳到最新」按钮删除,跟随并入 `useConversationScrollAnchor`,校正面接入同一 hook 与 `JumpToLatestButton`,复用 `ChatComposer`(`value` 受控、500 字上限),`rectification-sticky-scroll.ts` 删除,720px 覆写删除。对应 DESIGN.md 四条与 BUG-477/476 一并落地。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 9b6426c8..e6630895 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7777,3 +7777,82 @@ - 复发自:无 - 修复版本:待发布 +## BUG-505 | 生时校正面进入时先闪普通对话、再空白、再整面重挂;问题槽用「等待服务端更新」裸文案 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-03 +- 影响面:`use-rectification-surface`、`use-session-management`、`page.tsx`(面板 key、prepare 阶段)、`rectification-agentic-chat`、`starter-home`、`sidebar-session-row` +- 用户现象:点首页卡片没有任何反馈;从侧栏进已有校正会话先闪一帧普通对话、再是一块空白面板、记录到了再整面重挂;新建校正第一轮结束时画面闪一下、滚动归零;恢复会话后快照没回来前问题区空着;答完题后 composer 上方是「当前没有可回答的问题,正在等待服务端更新。」之类没有动作、没有时限的文案。 +- 触发条件:任何进入校正面的路径(卡片、侧栏、深链/刷新);任何一轮结束到下一题到达之间。 +- 根因:`selectSession` 先 `setActiveSessionId` 再 open,`rectificationSurfaceOpen` 在 open 返回前为 false;面板 key 带 `rectificationTurns.length > 0 ? "ready" : "loading"`,turns 到达即重挂;面板挂载后自拉快照;问题缺口只有裸文案。 +- 修复:`/cases/open` 后用 `hydrateRectificationCase` 一次读取 turns + 快照(与 `BOOTSTRAP_PREPARE_TIMEOUT_MS` 同一 4 秒上限)再切会话、写 URL;`selectSession` 对未打开的校正会话不先切;启动时选中的校正会话在 prepare 阶段完成 hydration 再揭幕;key 只剩 session/Case 绑定,后到 turns 经渲染期 prop 调整只填空 transcript;卸载中止流与快照读取。入口卡片脚注静态「正在打开…」、侧栏行「打开中」,不转圈。缺口改为 `rectificationQuestionGapState`:`preparing`(时间线 live 行「正在准备下一个问题…」+ 2s 定时重拉 ≤2 次)→ `unavailable`(「没有拿到下一个问题。」+ 44px「重新加载」);hydration 超时也走同一缺口。 +- 验证:`rectification-surface-contract`(一次揭幕、无重挂、prepare 阶段 hydration、静态入口反馈、缺口四态、无「等待服务端更新」);`rectification-surface-state`(hydration 超时/失败、缺口纯函数、turn 解析);`rectification-agentic-entry` / `rectification-question-in-message` / `rectification-spoken-collect` 的旧锁按红线改注。 +- 防复发:面板一个会话只挂载一次,turns 与快照都是 prop/state 更新;任何等待必须是时间线 live 行或带动作的状态,不得出现「等待服务端更新」类文案;揭幕后不得出现非生成中的 spinner。 +- 相关记录:BUG-479(首页两阶段揭幕)、BUG-473(校正面接入共享时间线) +- 复发自:无 +- 修复版本:待发布 + +## BUG-506 | 选择题点击后有空帧并闪下一题卡片;采用候选只有按钮变灰 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-03 +- 影响面:`rectification-agentic-chat`(`send`、`submitStructuredChoice`、`acceptCandidate`) +- 用户现象:点完选项,「正在记录本次选择…」那行消失一帧,下一题卡片闪现又消失,再出现「正在处理…」;点「采用此时间」只看到按钮变灰,页面其它部分静止好几秒。 +- 触发条件:选择题 `willContinue`;采用候选。 +- 根因:`willContinue` 分支删掉 thinking 行、置 `choiceContinuationPending`、`finally setPending(false)`,下一次 effect 才 `send("read_only")` 再追加新行;中间那一帧 `busy=false` 且快照刚装进新 `choiceCard`。采用流程不追加任何 live 行。 +- 修复:`send(action, text, continuation)`,continuation 复用已有 live 行并跳过 busy 守卫;选择与采用在同一个 async 链里 `await send("read_only", …, { reuseAssistantRenderKey })`;`busy` 全程为 true;采用点击即追加「正在采用 HH:MM…」行;`choiceContinuationPending` 与 effect 删除。 +- 验证:`rectification-surface-contract`「一条 live 行贯穿」;`rectification-agentic-entry` / `rectification-answer-choice` 的旧锁按红线改注。 +- 防复发:后续轮不得经 effect 中转;一条链里 `busy` 不得掉回 false。 +- 相关记录:BUG-505 +- 复发自:无 +- 修复版本:待发布 + +## BUG-507 | 记录为空的已有校正会话进入后永远空白 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-03 +- 影响面:`rectification-agentic-chat`、`rectification-surface-state` +- 用户现象:某个开场当时失败/未持久化的校正会话,从侧栏再进来什么都不显示,composer 可用但不知道先说什么。 +- 触发条件:`initialTurns=[]` 且服务端对 resumed case 一律 `should_start_opening=false`。 +- 根因:面板只在 `shouldStartOpening` 时自动开场,空 turns 无任何 CTA。 +- 修复:`rectificationConversationState` 判 `empty` → 「这段校正还没有开始。」+「开始提问」(`send("opening")`,`openingStarted` 守卫;服务端幂等抑制重复 opening,不改服务端)。 +- 验证:`rectification-surface-state` 会话态纯函数;`rectification-surface-contract` 空态源码锁。 +- 防复发:无 turns 且不自动开场时必须给起点。 +- 相关记录:BUG-505 +- 复发自:无 +- 修复版本:待发布 + +## BUG-508 | 选择题选中无确认感、记录中只有沙漏光标 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-03 +- 影响面:`rectification-choice-card` +- 用户现象:点了选项只看到底色变化,卡里没有进度,不知道有没有点上。 +- 触发条件:任何选择题点击。 +- 根因:`is-pending` 只改 `cursor: wait`;选中项无对勾。 +- 修复:选中项加 `Check` + 「已选择」;pending 时卡顶一行 `InlineSpinner` + shimmer「正在记录…」(与时间线 live 行同形)。`35688015` 已把选中态收进按钮、`d9404976` 已内嵌进消息,本轮只补差额;用户选择的回显由内嵌卡的 `answer_option` 承担(刷新前后一致),不再另加用户气泡。 +- 验证:`rectification-surface-contract`。 +- 防复发:卡内等待只能是时间线 live 行同形。 +- 相关记录:BUG-506 +- 复发自:无 +- 修复版本:待发布 + +## BUG-509 | 校正盘面首态是一整块空面板 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-03 +- 影响面:`rectification-board`、`rectification-board-model`、`page.tsx`、`globals.css` +- 用户现象:桌面端右侧 18–22.5rem 的面板第一阶段只有一句「补充经历后…」,移动端 peek 同样;用户明明填过出生时间。 +- 触发条件:`candidateResult` 为空。 +- 根因:首态不用 profile 的填报时间。 +- 修复:`declaredTime` 由 `page.tsx` 从 profile 派生(`reportedTime || time`,须为 `H:MM`)传入;头部时钟位显示填报时间,正文「填报出生时间 HH:MM」+「回答几个问题后…」;peek「填报 HH:MM」;`is-board-empty` 收窄到 `minmax(16rem, 18rem)`。快照 API 不提供填报时间的宫位表,不造数据。 +- 验证:`rectification-surface-state` 板文案纯函数;`rectification-surface-contract`。 +- 防复发:首态必须显示已知的填报时间;不得为填报时间伪造宫位表。 +- 相关记录:BUG-505 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-ux-20260903.md b/docs/tasks/PROGRESS-rectification-ux-20260903.md new file mode 100644 index 00000000..102ac7e8 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-ux-20260903.md @@ -0,0 +1,93 @@ +# PROGRESS · 生时校正会话面:消除空白假死与交互摩擦(重做,2026-09-03) + +工作树:`.worktrees/rectification-ux-20260903` +分支:`codex/rectification-ux-20260903` +基线:`origin/staging` @ `af411f59`(任务书重启说明所在),收尾时 rebase 到 `0cefaea6`(两次均为文档提交:任务书搬家、任务状态板;无代码变化)。 +任务书:`docs/tasks/TASK-rectification-ux-20260902.md`,以其「2026-09-03 重启说明」为准。第一次执行的分支 `codex/rectification-ux-20260902` @ `fec000f7` 只作参考,未 cherry-pick。 + +未改服务端与 SQL。未改 `.gitea/workflows/**`。未合并 staging,未碰 main。未手写 `useCallback` / `useMemo`(`rectification-agentic-chat.tsx` 既有的原样保留;新增的 `refetchQuestion` / `reloadQuestion` / `startOpeningManually` 是普通函数)。BUG 编号:开工确认远端最大号 504,本轮 505–509。未复活问题槽、未复活 consult handoff。 + +| 任务 | 状态 | commit | BUG | +| --- | --- | --- | --- | +| 0.2 一次揭幕、面板不重挂、深链/popstate 并入 prepare 阶段 | 完成 | `53017d41` | BUG-505 | +| 0.1 入口有反馈不转圈 | 完成(同 commit) | `53017d41` | BUG-505 | +| 0.3 问题槽只有生成中态、在对话流内 | 前半由 staging `d9404976` 完成(题目内嵌消息,槽已删);后半"没有等待快照这一态"随 0.2 成立 | — | — | +| 0.4 「等待服务端更新」→ live 行 + 重试 + 重新加载 | 完成 | `360989ca` | BUG-505 | +| 0.6 空 turns 会话的起点 | 完成 | `360989ca` | BUG-507 | +| 0.7 停止后的文案 | 完成(现状是静默返回,已补通知) | `360989ca` | — | +| 1.4 开场 live 文案 | 完成 | `360989ca` | — | +| 1.5 402 跳转前提示 | 完成 | `360989ca` | — | +| 0.5 选择题/采用不留缝不闪卡 | 完成(接 `beginLiveRun` / `rememberLiveActivity`) | `1e50007c` | BUG-506 | +| 1.1 选择卡确认感 | 补差额完成(选中态进按钮由 `35688015`、内嵌变体由 `d9404976` 已做;本轮补对勾「已选择」与卡顶「正在记录…」行) | `3c33e598` | BUG-508 | +| 1.2 用户选择回显 | **跳过**:`d9404976` 已把题目内嵌进 assistant 消息,答过的卡以 `question.answer_option` 标出所选项,持久化 turn 的 `question` 也带 `answer_option`,刷新前后都能看到自己选了什么;再加一条用户气泡会重复 | — | — | +| 1.3 盘面首态 | 完成(文案 + 收窄 + peek;宫位表 API 不提供,未造数据) | `3c33e598` | BUG-509 | +| 2 DESIGN.md | 完成 | 本 docs commit | — | + +## 六段空白与死角:各自的消除证据 + +| 空白 | 消除方式 | 证据 | +| --- | --- | --- | +| 1 首页卡片无反馈 | `rectificationCardLabel` 在 `rectificationLoading` 时为「正在打开…」;卡片 `data-opening` + `cursor: progress`;侧栏目标行「打开中」+ `aria-busy` | `rectification-surface-contract` → "entry feedback is static…" | +| 2 侧栏进入:普通对话 → 空白 → 重挂 | `selectSession` 对未打开的校正会话不先 `setActiveSessionId`/写 URL;hook 在 `/cases/open` 后 `hydrateRectificationCase`(一次 Case 读取 turns + 快照,`BOOTSTRAP_PREPARE_TIMEOUT_MS` 上限)再一次性切换;面板 key 去掉 `-ready/-loading` | 同上 → "…revealed once…";`rectification-surface-state` → hydration 两条 | +| 2′ 深链 / 刷新 / popstate | page 的 resume effect 从"揭幕后"改为"prepare 阶段起";`bootstrapPrepareSettled` 新增 `rectificationApplicable/Settled`,校正会话 hydration 完成(或 open 报错 / 资料不全)才揭幕;popstate 走 `selectSession`,同样延后切换 | `rectification-surface-contract` → "…hydrated before the reveal, and popstate defers…";`home-bootstrap-reveal` 既有用例不变(新字段可选) | +| 3 第一轮结束整面重挂 | key 只剩 `${sessionId}-${caseId}`;后到的 turns 经渲染期按上一 prop 调整只填空 transcript;卸载 `abort()` | 同上(`doesNotMatch(page, /"ready" : "loading"/)`、`seededTurns` 锁) | +| 4 快照回来前问题区空 | 快照随揭幕到位(`initialSnapshot` 初始化 state,`caseSnapshotLoaded` 初值 `initialSnapshot !== null`);面板不再挂载自拉;hydration 超时时走 0.4 的缺口重试 | 同上(`useState(initialSnapshot !== null)`、无挂载 fetch);`rectification-surface-state` → "a snapshot that never arrived is a gap…" | +| 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…" | +| 文案 「等待服务端更新」 | `rectificationQuestionGapState`:`preparing`(transcript 末尾一条时间线 live 行「正在准备下一个问题…」+ 2s 定时重拉 ≤2 次)→ `unavailable`(「没有拿到下一个问题。」+ 44px「重新加载」);`question_source: "unavailable"` 直接 `unavailable`;候选卡待采用时不算缺口 | `rectification-surface-state` → 缺口三条;契约 → "…never tells the reader to wait for the server" | + +## 改动文件 + +- 新建:`frontend/src/lib/rectification-surface-state.ts`(常量、hydration、turn 解析含 `question`/`offer_result_id`/`tool_activities`、会话态与缺口态纯函数、板文案、填报时间派生)、`frontend/tests/rectification-surface-state.test.ts`(10 用例)、`frontend/tests/rectification-surface-contract.test.ts`(8 用例)。 +- 改动:`use-rectification-surface.ts`、`use-session-management.ts`、`home-bootstrap.ts`(可选字段)、`page.tsx`(2 个 state、hook 参数、卡片文案、resume effect 门、prepare 判定、面板 key/props、侧栏 prop)、`app-sidebar.tsx`、`sidebar-session-row.tsx`、`starter-home.tsx`、`conversational-birth-time-rectification.tsx`(props 与 turn 类型)、`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 节)、`docs/tasks/README.md`(索引)、`BLOCKED.md`。 + +## 与任务书的书面偏差(均登记 BLOCKED.md) + +1. hydration 是一次 `GET /api/rectification/cases/:id` 套 4 秒 `Promise.race`,不是 `Promise.allSettled([turns, snapshot])`(同一端点,两次请求会重复);上限常量直接等于 `BOOTSTRAP_PREPARE_TIMEOUT_MS`,只有一个。 +2. turns 后到的填充与重试计数复位不用 `useEffect`(`react-hooks/set-state-in-effect`):前者渲染期按上一 prop 调整,后者在 `applyCaseSnapshot`(有问题到达)与 `setPending(true)` 里复位。 +3. 首页卡片不带 `aria-busy`:`tests/home-bootstrap-reveal.test.ts` 锁死 `starter-home.tsx` 不得出现 `aria-busy={`。卡片只用 `data-opening` + 静态文案 + `cursor: progress`;侧栏行仍带 `aria-busy`。 +4. 缺口的 live 行放在 transcript 末尾(`.rectification-message-wrap.rectification-question-gap`),不是 staging 原来的 composer 上方状态行;`.rectification-composer-status` 样式删除。 + +## 1.3 核实 + +`frontend/src/app/api/rectification/cases/[caseId]/route.ts` 的 dossier 响应只带 `accepted_time` / `confirmed_time` / `candidate_range`,无 natal/declared 宫位表;按任务书只做文案与收窄,`declaredTime` 由 `page.tsx` 从 profile(`reportedTime || time`,须为 `H:MM`)派生。 + +## 被触碰的既有断言(红线 4,每处源码上方均注明原值与错因) + +| 文件 | 原断言 | 现在 | 理由 | +| --- | --- | --- | --- | +| `tests/rectification-agentic-entry.test.ts` :85、:194 附近 | `key={…-${rectificationTurns.length > 0 ? "ready" : "loading"}}` | key 只含 session/Case;`doesNotMatch /"ready" : "loading"/` | 锁的正是重挂缺陷(BUG-505) | +| 同上 :87 | hook 内联 `methods: Array.isArray((turn.receipt as { methods?: unknown }).methods)` | `parsePersistedRectificationTurns(payload?.turns)` + lib 里 `methods: stringList(receipt.methods)` | 解析器搬到 lib 供 hydration 与刷新共用(BUG-505) | +| 同上 :233–:236 | `await loadCaseSnapshot(); choiceContinuationPending.current = true;`、effect 内 `void send("read_only", "")` | `await loadCaseSnapshot(); … await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel })`;`doesNotMatch /choiceContinuationPending/`;原 `doesNotMatch` 裸调用锁保留 | 锁的是 effect 中转(BUG-506) | +| 同上 workspace class | 无 `is-board-empty` 的类模板 | 含 `${candidateResult ? "" : " is-board-empty"}` | 板收窄(BUG-509) | +| `tests/rectification-question-in-message.test.ts` :136–137 | `/当前没有可回答的问题,正在等待服务端更新/`、`/题目加载失败,请刷新/` | `doesNotMatch` 两句 + 锁 `RECTIFICATION_QUESTION_PREPARING_LABEL` / `_UNAVAILABLE_COPY` | 锁的是裸等待文案(BUG-505) | +| `tests/rectification-spoken-collect.test.ts` "missing current_question…" | `showMissingQuestion = Boolean(…)` 与同两句文案 | `rectificationQuestionGapState({ … questionMissing: currentQuestion === null … resumableCase })`;`doesNotMatch` 两句 | 同上;仍锁 resumable 门 | +| `tests/rectification-answer-choice.test.ts` :1201 | `/send\("read_only", ""\)/` | `send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel })` | 后续轮带续接行(BUG-506) | + +新增测试:`tests/rectification-surface-state.test.ts`(10)、`tests/rectification-surface-contract.test.ts`(8)。 + +## 命令输出 + +`./node_modules/.bin/tsc --noEmit` → **0**(每个 commit 前各一次,rebase 后再一次)。 + +`npm run lint` → **0 error,73 warning**;触及文件里唯一的 warning 是 `rectification-agentic-chat.tsx` `submitStructuredChoice` 的既有 `exhaustive-deps`(staging 同在,依赖表原本就缺 `busy` / `actionIdForChoice`,本轮未动);新建的三个文件 0 warning。 + +`./node_modules/.bin/tsx --test tests/*.test.ts`(基线在干净 `af411f59` 上跑): + +| | 基线 `af411f59` | 现在 | +| --- | ---: | ---: | +| tests | 2584 | 2602 | +| pass | 2550 | 2568 | +| fail | 24 | 24 | +| skipped | 10 | 10 | + +24 条失败与基线逐条比对**完全同一集合**(`comm -3` 差集为空):全部是本机无 Docker / PostgreSQL / 部署夹具的既有缺口。10 条 skipped 同基线。rebase 到 `8db71aaf` / `0cefaea6` 只改了 md 文件,src/tests 逐字节相同,全量数字沿用;rebase 后复跑 tsc 与校正相关聚焦套件(见下)。 + +聚焦套件(rebase 后,15 个文件):`rectification-surface-state`、`rectification-surface-contract`、`rectification-agentic-entry`、`rectification-question-in-message`、`rectification-spoken-collect`、`rectification-answer-choice`、`rectification-board-model`、`rectification-varga-style-copy`、`home-bootstrap-reveal`、`chat-session-url`、`chat-notice-and-scroll-contract`、`starter-questions`、`class-name-definition-contract`、`touch-target-contract`、`dark-theme-contract` → **173 / 173**。 + +`./node_modules/.bin/next build` → **0**。`✓ Compiled successfully in 17.8s`、`Finished TypeScript in 41s`、`Generating static pages (62/62)`,`/` 仍是 `○ (Static)`。 + +## 未做与原因 + +见 `BLOCKED.md` 顶部:浏览器级手工验收(六条手工项 + 深链/刷新 + 缺口重试 + 整轮录屏)无登录态无 Chrome,已写进 `docs/testing/staging-manual-walkthrough-20260901.md` 第 8 节;1.3 的宫位表 API 不提供;四条书面偏差。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index d51e17f6..6aa26827 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -44,7 +44,7 @@ | `TASK-rectification-adopt-flow-20260902.md` | — | 采用流程整治 | 已验收 | `35e5781e`(BUG-497~500) | | `TASK-rectification-adopt-flow-fix-20260903.md` | — | 采用流程修复单(含删「用这个时间看盘」) | 已验收 | `e8c98c37`(BUG-501/502) | | `TASK-rectification-uncertainty-stop-20260903.md` | — | 不确定度停止规则加样本下限 | 已验收 | `0c0df426`(BUG-503) | -| `TASK-rectification-ux-20260902.md` | — | 会话面空白假死与交互摩擦 | 执行中 | 分支 `codex/rectification-ux-20260903`(09-03 在新基线重做,BUG-505 起) | +| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 待合并 | 分支 `codex/rectification-ux-20260903`(09-03 在新基线重做,BUG-505–509,已推送待验收) | ### 聊天主链路与首页 diff --git a/docs/testing/staging-manual-walkthrough-20260901.md b/docs/testing/staging-manual-walkthrough-20260901.md index 7ab78c6a..986a92e0 100644 --- a/docs/testing/staging-manual-walkthrough-20260901.md +++ b/docs/testing/staging-manual-walkthrough-20260901.md @@ -53,6 +53,29 @@ - 找一条消息很多的老对话点开:侧栏应立即出现,消息稍后加载(有加载态),不卡整页。 - 星盘库增删改、发起合盘、点每日星语、进出生时校正:应与改造前一致——这四块代码整体搬过家,重点看有没有点了没反应或样式塌掉。 +## 8. 生时校正面:一次揭幕与无缝续接(BUG-505–509) + +1. 首页点「生时校正」卡片。 +- ✅ 预期:卡片脚注立即变成「正在打开…」(不转圈),随后**一次性**出现完整面板:历史消息、当前问题、右侧盘面同时就位;不会先出现一个空面板再填内容。 +2. 在普通对话里,从侧栏点一个已有的校正会话。 +- ✅ 预期:旧画面保持不动,侧栏该行短暂显示「打开中」,然后直接切到校正面板;**不闪**一帧普通对话,**不出现**空白面板。 +3. 直接用带 `?c=` 的校正会话链接打开页面,或在校正会话里刷新。 +- ✅ 预期:加载屏结束后直接是校正面板,不会先出现普通对话再换成校正面板。 +4. 新建一段校正,等第一条引导说完。 +- ✅ 预期:进行中行先写「正在读取你的出生资料,准备第一个问题…」;第一轮结束的瞬间画面不闪、滚动不归零、时间线开合状态保留。 +5. 连续答两道选择题。 +- ✅ 预期:点完选项立刻在卡上看到对勾「已选择」、卡顶一行「正在记录…」,下面一条「正在记录本次选择…」的进行中行**一直在**,直到下一题出现在新的回复里;中间没有空帧,下一题卡片不会闪现又消失。 +6. 出现候选时间后点「采用此时间」。 +- ✅ 预期:transcript 末尾出现「正在采用 HH:MM…」进行中行,同一条行一直到后续回复出现。 +7. 找一个当初开场失败的旧校正会话(记录为空)进入。 +- ✅ 预期:看到「这段校正还没有开始。」和「开始提问」按钮,点后正常开场。 +8. 右侧盘面在没有候选前。 +- ✅ 预期:头部时钟位显示你填报的出生时间,正文是「填报出生时间 HH:MM」两行;面板比有结果时窄一些。 +9. 流式回答中点停止。 +- ✅ 预期:已生成内容保留,提示「已停止,已生成的内容保留;本次不会扣点。」,不是「暂时不可用」。 +10. 一轮结束后下一题迟迟不来(可用 DevTools 把 `/api/rectification/cases/*` 节流或断网)。 +- ✅ 预期:末尾是一条「正在准备下一个问题…」的进行中行,约 4 秒后变成「没有拿到下一个问题。」和「重新加载」按钮;任何时候都不出现「等待服务端更新」。 + --- 四轮改造:BUG-464(消息权威化)、BUG-465(会话 URL)、BUG-466(云端唯一真相)、拆页两批。自动化侧每轮均已验收(tsc / 2400+ 条测试 / 构建 / SQL 幂等),此清单覆盖的是自动化够不着的真浏览器行为。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index fc669d75..23e3722b 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -200,6 +200,31 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows. - **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative. +### Rectification surface + +The birth-time rectification session is the consultation transcript plus a house board; questions live inside the assistant message that asked them. It shares every waiting vocabulary with the consultation surface; nothing here spins or says "loading" after the reveal. + +- **Reveal:** the surface mounts once per session/Case binding. Opening a Case (homepage card, sidebar row, deep link or refresh) reads the turns and the snapshot in one Case request before the switch, under the same 4-second budget as the home reveal (`RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS` is `BOOTSTRAP_PREPARE_TIMEOUT_MS`); a session selected at bootstrap is hydrated during the prepare phase. The entry shows a static note meanwhile (card footer “正在打开…”, sidebar row “打开中”, `cursor: progress`) and the previous view stays put. Turns and snapshot are initial state; anything that arrives later is a prop or state update, never a remount. A late or failed read still reveals, with the composer notice “校正记录没有完全加载,可以继续”. +- **States** — what the transcript's trailing entry and the composer show: + +| State | Trailing entry | Composer | +|---|---|---| +| `opening` | live row “正在读取你的出生资料,准备第一个问题…”, then tool labels | disabled, stop visible | +| `empty` | “这段校正还没有开始。” and one primary action “开始提问” | enabled | +| `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card | +| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled | +| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled | +| `choice-pending` | the answered card (`Check` + “已选择”, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | disabled, stop visible | +| `candidates` | the offer cards under the message that offered them | enabled | +| `adopting` | “正在采用 HH:MM…” through the follow-up turn | disabled during the chain | +| `confirmed` | “已确认校正时间:HH:MM” | enabled | +| `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled | +| stopped | the row settles with what streamed; notice “已停止,已生成的内容保留;本次不会扣点。” | enabled | + +- **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a reload button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves. +- **Board:** before any candidate exists the header clock shows the declared birth minute and the body reads “填报出生时间 HH:MM” / “回答几个问题后,这里会显示宫位随时间的变化。”; the column narrows to `minmax(16rem, 18rem)` (`is-board-empty`) and widens once a result arrives. No house table is invented for the declared time; the snapshot API does not provide one. +- **Accessibility:** the sidebar opening note sets `aria-busy`; the live row is the timeline row (`role="status"` shimmer label); the reload and start actions are real 44px buttons. + ### Birth time intake - **Structure:** birth date, five radio choice rows for time knowledge, then only the time, uncertainty, period, or clue field required by the selected source. @@ -407,6 +432,8 @@ or user IDs. Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。 +校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。 + 首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。 Admin 的 antd `` 是独立设计系统,不在此表。 diff --git a/frontend/tests/rectification-surface-contract.test.ts b/frontend/tests/rectification-surface-contract.test.ts new file mode 100644 index 00000000..57cf5c92 --- /dev/null +++ b/frontend/tests/rectification-surface-contract.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { homeSurface as page } from "./home-surface.ts"; + +const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8"); +const chat = read("../src/components/rectification-agentic-chat.tsx"); +const hook = read("../src/hooks/use-rectification-surface.ts"); +const sessions = read("../src/hooks/use-session-management.ts"); +const bootstrap = read("../src/lib/home-bootstrap.ts"); +const starter = read("../src/components/starter-home.tsx"); +const sidebarRow = read("../src/components/sidebar-session-row.tsx"); +const board = read("../src/components/rectification-board.tsx"); +const choiceCard = read("../src/components/rectification-choice-card.tsx"); +const wrapper = read("../src/components/conversational-birth-time-rectification.tsx"); +const timeline = read("../src/components/consultation-run-timeline.tsx"); +const styles = read("../src/app/globals.css"); + +test("the rectification surface is revealed once: Case hydration precedes the switch and the key never remounts", () => { + // BUG-505: open → hydrate (turns + snapshot, one Case read, deadline) → switch, in that order. + assert.match(hook, /const hydration = await hydrateRectificationCase\(opened\.caseId, opened\.sessionId, \{\s*timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,/); + const openBody = hook.slice(hook.indexOf("const hydration = await hydrateRectificationCase"), hook.indexOf("void refreshRectificationEntrySummary();")); + assert.match(openBody, /setRectificationTurns\(hydration\.turns\);[\s\S]*setRectificationSnapshot\(hydration\.snapshot\);[\s\S]*setActiveSessionId\(opened\.sessionId\);/); + assert.match(openBody, /if \(!hydration\.complete\) setComposerNotice\(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE\);/); + assert.doesNotMatch(hook, /void refreshRectificationCase\(opened\.caseId/); + // The selection source is read before any await; selectSession resets it synchronously. + assert.match(hook, /const selectionSource = sessionSelectionSource\.current;[\s\S]*await fetch\("\/api\/rectification\/cases\/open"/); + assert.match(hook, /selectionSource === "user"\) \{\s*writeSessionUrl\(opened\.sessionId, "push"\)/); + // selectSession does not switch to a rectification session before its Case is hydrated. + assert.match(sessions, /const deferredRectificationSwitch = nextSession\?\.sessionType === "birth_time_rectification"\s*&& nextSession\.id !== rectificationSessionId;/); + assert.match(sessions, /if \(!deferredRectificationSwitch\) setActiveSessionId\(sessionId\);/); + assert.match(sessions, /if \(!deferredRectificationSwitch && !uiPreview\.current && sessionSelectionSource\.current === "user"\) \{\s*writeSessionUrl\(sessionId, "push"\);/); + // Panel: one key per binding, snapshot and turns as initial state, later turns as a prop update. + assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/); + assert.doesNotMatch(page, /"ready" : "loading"/); + assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/); + assert.match(page, /declaredTime=\{rectificationDeclaredTime\}/); + assert.match(wrapper, /initialSnapshot: RectificationCaseSnapshotPayload \| null;/); + assert.match(chat, /useState\(initialSnapshot !== null\)/); + // Later turns fill an empty transcript by render-time adjustment from the previous prop, not an effect. + assert.match(chat, /const \[seededTurns, setSeededTurns\] = useState\(initialTurns\);\s*if \(seededTurns !== initialTurns\) \{\s*setSeededTurns\(initialTurns\);\s*if \(messages\.length === 0 && initialTurns\.length > 0\) setMessages\(messagesFromTurns\(initialTurns\)\);/); + // No self-fetch on mount; unmount ends the stream and any snapshot read. + assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*const controller = new AbortController\(\);\s*void fetch\(/); + assert.match(chat, /useEffect\(\(\) => \(\) => \{\s*runAbort\.current\?\.abort\(\);\s*snapshotAbort\.current\?\.abort\(\);\s*\}, \[\]\);/); +}); + +test("a rectification session selected at bootstrap is hydrated before the reveal, and popstate defers the same way", () => { + // The resume effect runs during the prepare phase, not only after the reveal. + assert.match(page, /if \(\(!hydrated && bootstrapPhase === "account"\)\s*\|\| !account/); + assert.match(bootstrap, /if \(state\.rectificationApplicable && !state\.rectificationSettled\) return false;/); + assert.match(page, /rectificationApplicable: activeSession\?\.sessionType === "birth_time_rectification",\s*rectificationSettled: activeSession\?\.id === rectificationSessionId\s*\|\| rectificationError !== ""\s*\|\| !profileComplete,/); + // One deadline constant for hydration and the home reveal. + assert.match(read("../src/lib/rectification-surface-state.ts"), /export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;/); + // History navigation goes through selectSession, which defers the switch (locked above). + assert.match(sessions, /sessionSelectionSource\.current = "history";\s*selectSession\(requestedId\);/); +}); + +test("entry feedback is static: card and sidebar row say 正在打开, and nothing spins after the reveal", () => { + assert.match(page, /const rectificationCardLabel = rectificationLoading\s*\? RECTIFICATION_OPENING_LABEL/); + assert.match(starter, /data-opening=\{rectificationLoading \? "true" : undefined\}/); + assert.doesNotMatch(starter, /InlineSpinner/); + assert.match(sidebarRow, /aria-busy=\{opening \? true : undefined\}/); + assert.match(sidebarRow, /RECTIFICATION_SIDEBAR_OPENING_NOTE/); + assert.match(page, /openingSessionId=\{rectificationOpeningSessionId\}/); + assert.match(styles, /\.product-entrypoint-card\[data-opening="true"\][^{]*\{ cursor: progress; \}/); + assert.match(styles, /\.session-opening-note \{/); + // The only spinner inside the chat is a timeline live row (generation), never a loading state. + assert.equal((chat.match(/InlineSpinner/g) ?? []).length, 0); + assert.match(timeline, /export function ConsultationTimelineLiveRow/); + assert.match(chat, //); +}); + +test("the question gap is a live row with retries, then a reload; it never tells the reader to wait for the server", () => { + assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新|rectification-composer-status/); + assert.doesNotMatch(chat, /showMissingQuestion|showUnavailableQuestion|showQuestionLoadFailed/); + assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{/); + assert.match(chat, /questionGap === "preparing" && \(/); + assert.match(chat, /questionGap === "unavailable" && \(/); + assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/); + assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/); + assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/); + // Attempts reset in handlers (a question arriving, a turn starting), never in an effect. + assert.match(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/); + assert.match(chat, /if \(value\) setQuestionRetryAttempts\(0\);/); + assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(currentQuestion !== null/); + // In flow: the gap is the last entry of the transcript, after the message loop, before the saved-time line. + const gapIndex = chat.indexOf('{questionGap === "preparing" && ('); + assert.ok(gapIndex > chat.indexOf("{messages.map((message) => {")); + assert.ok(gapIndex < chat.indexOf("{savedTime && savedStatus === \"confirmed\"")); + assert.match(styles, /\.rectification-question-gap \{/); +}); + +test("a tapped choice or an adopted candidate keeps one live row through the follow-up turn", () => { + // BUG-506: no effect hop, no removed row, busy held across the chain. + assert.doesNotMatch(chat, /choiceContinuationPending/); + assert.match(chat, /const send = useCallback\(async \(\s*action: "opening" \| "message" \| "read_only",\s*messageText: string,\s*continuation\?: Readonly<\{ reuseAssistantRenderKey: string; label: string \}>,/); + assert.match(chat, /if \(!continuation && busy\) return;/); + assert.match(chat, /const initialLabel = rectificationInitialLiveLabel\(action, continuation\?\.label\);/); + assert.match(chat, /beginLiveRun\(initialLabel\);/); + assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel \}\);/); + assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/); + assert.match(chat, /rectificationAdoptingLabel\(candidateTime\)/); + assert.doesNotMatch(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\),\s*turns,\s*\)\);/); +}); + +test("an empty Case with no automatic opening offers a way to start", () => { + // BUG-507 + assert.match(chat, /conversationState === "empty" && \(/); + assert.match(chat, /RECTIFICATION_EMPTY_COPY/); + assert.match(chat, /onClick=\{startOpeningManually\}>\{RECTIFICATION_EMPTY_ACTION_LABEL\}/); + assert.match(chat, /function startOpeningManually\(\) \{\s*if \(readonly \|\| busy \|\| openingStarted\.current\) return;\s*openingStarted\.current = true;/); + assert.match(styles, /\.rectification-empty-state \{/); +}); + +test("stopping keeps what streamed and says so; a 402 explains itself before leaving", () => { + assert.match(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\);/); + assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*window\.setTimeout\(\(\) => \{\s*window\.location\.assign\(membershipHref\("rectification"\)\);\s*\}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS\);/); +}); + +test("the choice card confirms the tap and the board's first state shows the declared minute", () => { + // BUG-508 + assert.match(choiceCard, /