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/src/app/globals.css b/frontend/src/app/globals.css index 5d804d09..f94c7f73 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -757,6 +757,14 @@ button:disabled { cursor: default; opacity: .45; } .session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: 0; width: 2px; background: var(--sidebar-ring); } .session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-caption); font-weight: 500; } .session-title > svg { width: 14px; height: 14px; flex: 0 0 auto; color: currentColor; } +/* A rectification session whose Case is being opened: a static note, no spinner after the reveal. */ +.session-opening-note { + flex: 0 0 auto; + color: var(--color-ink-tertiary); + font-size: var(--type-overline); + font-weight: 500; + letter-spacing: .02em; +} .session-main small { color: var(--color-ink-tertiary); line-height: 1.3; font-size: var(--type-overline); } .session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; justify-self: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out; } .session-menu-trigger > svg { width: 18px; height: 18px; } @@ -995,6 +1003,9 @@ button:disabled { cursor: default; opacity: .45; } .product-entrypoint-hitarea { position: absolute; z-index: 2; inset: 0; width: 100%; min-height: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } .product-entrypoint-card > :not(.product-entrypoint-hitarea) { position: relative; z-index: 1; pointer-events: none; } +/* The card is opening a Case: static copy and a progress cursor, no spinner (unified-loading ruling). */ +.product-entrypoint-card[data-opening="true"], +.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; } .product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(0); } .product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; } @media (hover: hover) { @@ -2624,6 +2635,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem); background: var(--color-canvas); } +/* No candidate yet: the board only carries the declared time, so it takes less + width until there is a house table to show. */ +.rectification-workspace.is-board-empty { + grid-template-columns: minmax(0, 1fr) minmax(16rem, 18rem); +} .rectification-workspace.is-compact { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); @@ -2920,11 +2936,60 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-weight: 600; line-height: 1.55; } -.rectification-composer-status { - margin: 0 0 var(--space-2); - color: var(--color-ink-secondary); - font-size: var(--type-caption); +/* The gap between a settled turn and its next question, as the last entry of the + transcript: a live timeline row while refetching, then copy and a reload. */ +.rectification-question-gap { + display: grid; + gap: var(--space-2); + justify-items: start; + margin: var(--space-2) 0 var(--space-3); + margin-inline-start: var(--assistant-content-inset); } +.rectification-question-gap__copy { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + line-height: 1.55; +} +/* The standalone live row has no summary above it. */ +.consultation-run-timeline__list.is-standalone { margin-top: 0; } +/* A Case with nothing to show and no automatic opening: one line and one way to start. */ +.rectification-empty-state { + display: grid; + gap: var(--space-3); + justify-items: start; + margin: var(--space-4) 0; + margin-inline-start: var(--assistant-content-inset); + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + line-height: 1.55; +} +.rectification-empty-state p { margin: 0; } +/* The tap is being recorded: same shape as a timeline live row. */ +.rectification-choice-card__pending { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 24px; + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + line-height: 1.5; +} +.rectification-choice-card__selected { + display: inline-flex; + align-items: center; + gap: 4px; + margin-inline-start: var(--space-2); + color: var(--color-action); + font-size: var(--type-caption); + font-weight: 600; + vertical-align: middle; +} +.rectification-choice-card__selected svg { width: 14px; height: 14px; } +.rectification-board__clock.is-declared { color: var(--color-ink-tertiary); } +.rectification-board__empty { display: grid; gap: var(--space-1); } +.rectification-board__empty p { margin: 0; } .rectification-snapshot { display: grid; gap: var(--space-3); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 56429b65..bb349f94 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -24,6 +24,11 @@ import { type RectificationEntrySummary, } from "@/lib/rectification-entry"; import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification"; +import { + declaredBirthTime, + RECTIFICATION_OPENING_LABEL, + type RectificationCaseSnapshotPayload, +} from "@/lib/rectification-surface-state"; import { toggleChatMessageFeedback, type ChatMessageFeedback, @@ -295,6 +300,8 @@ export default function Home() { const [rectificationReadonly, setRectificationReadonly] = useState(false); const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false); const [rectificationTurns, setRectificationTurns] = useState([]); + const [rectificationSnapshot, setRectificationSnapshot] = useState(null); + const [rectificationOpeningSessionId, setRectificationOpeningSessionId] = useState(null); const [rectificationEntrySummary, setRectificationEntrySummary] = useState(null); const [hydrated, setHydrated] = useState(false); const [bootstrapPhase, setBootstrapPhase] = useState("account"); @@ -415,7 +422,10 @@ export default function Home() { latestTerminal: null, }, ); - const rectificationCardLabel = rectificationEntryLabels[rectificationCardAction]; + const rectificationCardLabel = rectificationLoading + ? RECTIFICATION_OPENING_LABEL + : rectificationEntryLabels[rectificationCardAction]; + const rectificationDeclaredTime = declaredBirthTime(profile); const rectificationErrorMessage = rectificationError === "profile_incomplete" ? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。" : rectificationError; @@ -482,7 +492,8 @@ export default function Home() { setDraftTheme, setOnboardingStep, setProfileNotice, setRectificationCaseId, setRectificationEntrySummary, setRectificationError, setRectificationLoading, setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId, - setRectificationShouldStartOpening, setRectificationTurns, setSessions, uiPreview, + setRectificationShouldStartOpening, setRectificationSnapshot, setRectificationOpeningSessionId, + setRectificationTurns, setSessions, uiPreview, updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef, }); @@ -502,8 +513,13 @@ export default function Home() { return () => window.removeEventListener("popstate", onPopState); }, [hydrated]); + // A rectification session selected at bootstrap (deep link, refresh, the + // most recent session) is opened and hydrated during the prepare phase, so + // the reveal shows the surface itself rather than a plain transcript that is + // swapped out a moment later. After the reveal the same effect serves + // history navigation. useEffect(() => { - if (!hydrated + if ((!hydrated && bootstrapPhase === "account") || !account || !modelCatalog || activeSession?.sessionType !== "birth_time_rectification" @@ -516,6 +532,7 @@ export default function Home() { }, [ account, activeSession, + bootstrapPhase, creatingSession, hydrated, modelCatalog, @@ -647,6 +664,10 @@ export default function Home() { dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable, dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending", entrySummarySettled: rectificationEntrySummarySettled, + rectificationApplicable: activeSession?.sessionType === "birth_time_rectification", + rectificationSettled: activeSession?.id === rectificationSessionId + || rectificationError !== "" + || !profileComplete, }); useEffect(() => { @@ -1718,6 +1739,7 @@ export default function Home() { sessions={sidebarSessions} charts={sidebarCharts} activeSessionId={activeSession?.id ?? null} + openingSessionId={rectificationOpeningSessionId} account={sidebarAccount} accountMenuOpen={accountMenuOpen} accountTriggerRef={accountTrigger} @@ -1928,12 +1950,14 @@ export default function Home() { {rectificationSurfaceOpen && rectificationCaseId && ( 0 ? "ready" : "loading"}`} + key={`${rectificationSessionId}-${rectificationCaseId}`} caseId={rectificationCaseId} sessionId={rectificationSessionId ?? ""} readonly={rectificationReadonly} shouldStartOpening={rectificationShouldStartOpening} initialTurns={rectificationTurns} + initialSnapshot={rectificationSnapshot} + declaredTime={rectificationDeclaredTime} models={modelCatalog?.models ?? []} selectedModelId={activeSession?.modelId ?? ""} onSelectModel={(modelId) => void selectSessionModel(modelId)} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 104e7c06..008d981e 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -59,6 +59,8 @@ export type AppSidebarProps = { sessions: readonly SidebarSession[]; charts: readonly SidebarChart[]; activeSessionId: string | null; + /** A rectification session whose Case is being opened and hydrated; its row says so, statically. */ + openingSessionId?: string | null; account: SidebarAccount; accountMenuOpen: boolean; accountTriggerRef: Ref; @@ -82,6 +84,7 @@ export function AppSidebar({ sessions, charts, activeSessionId, + openingSessionId = null, account, accountMenuOpen, accountTriggerRef, @@ -141,6 +144,7 @@ export function AppSidebar({ ref={index === 0 ? firstSessionRef : undefined} session={session} active={session.id === activeSessionId} + opening={session.id === openingSessionId} disabled={sessionControls.disabled} menuOpen={sessionControls.menuSessionId === session.id} onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)} diff --git a/frontend/src/components/consultation-run-timeline.tsx b/frontend/src/components/consultation-run-timeline.tsx index 633ef3ba..26c52900 100644 --- a/frontend/src/components/consultation-run-timeline.tsx +++ b/frontend/src/components/consultation-run-timeline.tsx @@ -83,6 +83,20 @@ export function ConsultationRunTimeline({ ); } +/** + * One live step outside a reply's timeline — the rectification surface uses it + * for the gap between a settled turn and its next question. It is the same + * row as inside the timeline (marker, spinner, shimmer label), not a second + * waiting vocabulary. + */ +export function ConsultationTimelineLiveRow({ label, id = "live" }: Readonly<{ label: string; id?: string }>) { + return ( +
    + +
+ ); +} + function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) { const KindIcon = KIND_ICONS[row.kind]; const expandable = Boolean( diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 6a75aec4..26dbffb3 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -2,6 +2,7 @@ import type { PublicLanguageModel } from "../lib/public-models.ts"; import type { ChatMessage } from "../lib/chat-message-view.ts"; +import type { RectificationCaseSnapshotPayload } from "../lib/rectification-surface-state.ts"; import { RectificationAgenticChat } from "./rectification-agentic-chat.tsx"; export type PersistedRectificationTurn = Readonly<{ @@ -10,9 +11,18 @@ export type PersistedRectificationTurn = Readonly<{ text: string | null; status: string; question?: unknown; + offer_result_id?: string | null; receipt?: Readonly<{ status: string; phases: readonly string[]; + tool_activities?: readonly Readonly<{ + tool: string; + status: string; + methods?: readonly string[]; + started_at?: string | null; + elapsed_ms?: number | null; + detail?: Readonly> | null; + }>[]; tools: readonly string[]; methods?: readonly string[]; skill_name?: string; @@ -26,6 +36,10 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{ readonly: boolean; shouldStartOpening: boolean; initialTurns: readonly PersistedRectificationTurn[]; + /** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */ + initialSnapshot: RectificationCaseSnapshotPayload | null; + /** The declared birth minute from the profile, shown on the board before any candidate exists. */ + declaredTime: string | null; models: readonly PublicLanguageModel[]; selectedModelId: string; onSelectModel: (modelId: string) => void; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 0c657c1d..c8611ffe 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -43,6 +43,23 @@ import { } from "@/lib/rectification-board-model"; import { membershipHref } from "@/lib/membership"; import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter"; +import { + rectificationAdoptingLabel, + RECTIFICATION_EMPTY_ACTION_LABEL, + RECTIFICATION_EMPTY_COPY, + RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE, + RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS, + RECTIFICATION_QUESTION_PREPARING_LABEL, + RECTIFICATION_QUESTION_RELOAD_LABEL, + RECTIFICATION_QUESTION_RETRY_INTERVAL_MS, + RECTIFICATION_QUESTION_RETRY_LIMIT, + RECTIFICATION_QUESTION_UNAVAILABLE_COPY, + RECTIFICATION_STOPPED_NOTICE, + rectificationConversationState, + rectificationInitialLiveLabel, + rectificationQuestionGapState, + type RectificationCaseSnapshotPayload, +} from "@/lib/rectification-surface-state"; import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence"; import { isPublicRectificationActivity, @@ -62,9 +79,11 @@ import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseSta import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; import type { PublicLanguageModel } from "@/lib/public-models"; import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor"; +import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll"; import { CharacterRemaining } from "./character-remaining"; import { ChatComposer } from "./chat-composer"; import { ChatMessageRow } from "./chat-message-row"; +import { ConsultationTimelineLiveRow } from "./consultation-run-timeline"; import { JumpToLatestButton } from "./jump-to-latest-button"; import { ChatMessageActions, @@ -196,6 +215,10 @@ type RectificationAgenticChatProps = Readonly<{ readonly: boolean; shouldStartOpening: boolean; initialTurns: readonly PersistedTurn[]; + /** The Case snapshot read together with the turns before mount; null when hydration failed or timed out. */ + initialSnapshot: RectificationCaseSnapshotPayload | null; + /** The declared birth minute from the profile, for the board before any candidate exists. */ + declaredTime: string | null; models: readonly PublicLanguageModel[]; selectedModelId: string; onSelectModel: (modelId: string) => void; @@ -386,6 +409,32 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag }); } +type CaseSnapshotState = Readonly<{ + candidate: CandidateResult; + question: CurrentQuestionModel | null; + questionSource: "focus" | "unavailable" | null; + choice: ChoiceCardModel | null; + caseStatus: RectificationCaseStatus | null; + savedTime: string | null; + savedStatus: "accepted" | "confirmed" | null; +}>; + +/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */ +function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null { + if (!payload) return null; + const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; + const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null; + return { + candidate: parseRectificationCandidateResult(payload.latest_result), + question: currentQuestionFromSnapshot(payload.current_question), + questionSource: questionSourceFromSnapshot(payload.question_source), + choice: parseRectificationChoiceCard(payload.choice_card), + caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null, + savedTime: confirmedTime ?? acceptedTime, + savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null, + }; +} + export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const { caseId, @@ -393,6 +442,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { readonly, shouldStartOpening, initialTurns, + initialSnapshot, + declaredTime, models, selectedModelId, onSelectModel, @@ -411,20 +462,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - const [savedTime, setSavedTime] = useState(null); - const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null); - const [candidateResult, setCandidateResult] = useState(null); - const [choiceCard, setChoiceCard] = useState(null); - const [currentQuestion, setCurrentQuestion] = useState(null); - const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(null); - const [caseStatus, setCaseStatus] = useState(null); - const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false); + const [savedTime, setSavedTime] = useState(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null); + const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null); + const [candidateResult, setCandidateResult] = useState(() => caseSnapshotState(initialSnapshot)?.candidate ?? null); + const [choiceCard, setChoiceCard] = useState(() => caseSnapshotState(initialSnapshot)?.choice ?? null); + const [currentQuestion, setCurrentQuestion] = useState(() => caseSnapshotState(initialSnapshot)?.question ?? null); + const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null); + const [caseStatus, setCaseStatus] = useState(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null); + const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null); + const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0); + const [openingRequested, setOpeningRequested] = useState(false); const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); const [choiceNonce, setChoiceNonce] = useState(0); - const choiceContinuationPending = useRef(false); const conversation = useRef(null); const workspace = useRef(null); const composer = useRef(null); @@ -432,6 +484,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const openingStarted = useRef(false); const previousBoardResult = useRef(null); const runAbort = useRef(null); + const snapshotAbort = useRef(null); const choiceActionIds = useRef(new Map()); const currentQuestionRef = useRef(currentQuestion); const offerSectionRef = useRef(null); @@ -492,6 +545,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const setPending = useCallback((value: boolean) => { setBusy(value); + // A turn starting is a fresh chance for the next question to arrive. + if (value) setQuestionRetryAttempts(0); onPendingChange?.(value); }, [onPendingChange]); @@ -589,6 +644,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; setCandidateResult(nextCandidate); setCurrentQuestion(nextQuestion); + if (nextQuestion !== null) setQuestionRetryAttempts(0); setQuestionSource(questionSourceFromSnapshot(payload.question_source)); setChoiceCard(nextChoice); setCaseStatus(nextCaseStatus); @@ -606,13 +662,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { question: CurrentQuestionModel | null; turns: readonly unknown[]; } | null | undefined> => { + snapshotAbort.current?.abort(); + const controller = new AbortController(); + snapshotAbort.current = controller; try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, - { cache: "no-store" }, + { cache: "no-store", signal: controller.signal }, ); if (!response.ok) return undefined; const payload = await response.json().catch(() => null); + if (controller.signal.aborted) return undefined; applyCaseSnapshot(payload); return { question: currentQuestionFromSnapshot(payload?.current_question), @@ -621,53 +681,74 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } catch { // Snapshot refresh is best-effort; the durable Case remains on the server. return undefined; + } finally { + if (snapshotAbort.current === controller) snapshotAbort.current = null; } }, [applyCaseSnapshot, caseId, sessionId]); - useEffect(() => { - const controller = new AbortController(); - void fetch( - `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, - { cache: "no-store", signal: controller.signal }, - ) - .then((response) => (response.ok ? response.json() : null)) - .then((payload) => { - if (controller.signal.aborted) return; - applyCaseSnapshot(payload); - }) - .catch(() => { - // Snapshot refresh is best-effort; the durable Case remains on the server. - }); - return () => controller.abort(); - }, [applyCaseSnapshot, caseId, sessionId]); + // Turns that arrive after mount (the parent refreshes the Case after each + // completed turn) only ever fill an empty transcript; a live or finished + // conversation is never overwritten or remounted. Adjusted during render + // from the previous prop, the way React documents it, not in an effect. + const [seededTurns, setSeededTurns] = useState(initialTurns); + if (seededTurns !== initialTurns) { + setSeededTurns(initialTurns); + if (messages.length === 0 && initialTurns.length > 0) setMessages(messagesFromTurns(initialTurns)); + } - const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => { + // Leaving the surface mid-turn ends the stream and any snapshot read + // instead of letting them write into an unmounted component. + useEffect(() => () => { + runAbort.current?.abort(); + snapshotAbort.current?.abort(); + }, []); + + /** + * Run one agent turn. A `continuation` reuses the live row an earlier step + * (a tapped choice, an adopted candidate) already put at the end of the + * transcript, so the reader sees one uninterrupted working row from the tap + * to the next question instead of a gap and a re-appended row. The caller + * of a continuation already holds `busy`. + */ + const send = useCallback(async ( + action: "opening" | "message" | "read_only", + messageText: string, + continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>, + ) => { const trimmed = action === "message" ? messageText.trim() : ""; - if ((action === "message" && !trimmed) || busy || readonly) return; + if ((action === "message" && !trimmed) || readonly) return; + if (!continuation && busy) return; setError(""); setPending(true); - beginLiveRun("正在处理…"); + const initialLabel = rectificationInitialLiveLabel(action, continuation?.label); + beginLiveRun(initialLabel); keyCounter.current += 1; const requestId = globalThis.crypto.randomUUID(); const turnKey = keyCounter.current; const userRenderKey = `v9-user-${turnKey}`; - const assistantRenderKey = `v9-assistant-${turnKey}`; + const assistantRenderKey = continuation?.reuseAssistantRenderKey ?? `v9-assistant-${turnKey}`; const pendingFocusId = currentQuestionRef.current?.focus_id; - setMessages((current) => [ - ...(action === "message" && pendingFocusId - ? markQuestionAnswered(current, pendingFocusId, "typed") - : current), - ...(action === "message" - ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] - : []), - { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: { - phase: "evidence-validation", - label: "正在处理…", - startedAt: Date.now(), - } }, - ]); + const liveRow: RenderMessage = { + role: "assistant", + text: "", + renderKey: assistantRenderKey, + state: "thinking", + activityTrace: emptyActivityTrace(), + activity: { phase: "evidence-validation", label: initialLabel, startedAt: Date.now() }, + }; + setMessages((current) => (continuation + ? current.map((message) => (message.renderKey === assistantRenderKey ? { ...liveRow, activity: message.activity ?? liveRow.activity } : message)) + : [ + ...(action === "message" && pendingFocusId + ? markQuestionAnswered(current, pendingFocusId, "typed") + : current), + ...(action === "message" + ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] + : []), + liveRow, + ])); setDraft(""); let raw = ""; @@ -677,7 +758,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { let completedTurnId: string | undefined; let currentActivity: AgentActivityView | undefined = { phase: "evidence-validation", - label: "正在处理…", + label: initialLabel, startedAt: Date.now(), }; // Every stream event lands in `frames`; it commits at most once per animation @@ -726,7 +807,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { return; } if (response.status === 402) { - window.location.assign(membershipHref("rectification")); + // Say why before the page leaves, instead of vanishing mid-chat. + setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE); + window.setTimeout(() => { + window.location.assign(membershipHref("rectification")); + }, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS); return; } if (response.status === 401) setError("请先登录。"); @@ -931,6 +1016,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } return []; })); + // The reader pressed stop: what streamed stays, and the notice says so + // rather than blaming the service. + if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE); return; } setError("生时校正暂时不可用,请稍后再试。"); @@ -987,7 +1075,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { keyCounter.current += 1; const turnKey = keyCounter.current; const assistantRenderKey = `v9-choice-assistant-${turnKey}`; - beginLiveRun(RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer); + const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer; + beginLiveRun(recordingLabel); setMessages((current) => [ ...markQuestionAnswered(current, focusId, optionId), { @@ -998,7 +1087,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { activityTrace: emptyActivityTrace(), activity: { phase: "evidence-validation", - label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer, + label: recordingLabel, startedAt: Date.now(), }, }, @@ -1039,11 +1128,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const snapshot = await loadCaseSnapshot(); const turns = snapshot?.turns ?? []; if (willContinue) { - setMessages((current) => mergeTurnQuestions( - current.filter((message) => message.renderKey !== assistantRenderKey), - turns, - )); - choiceContinuationPending.current = true; + // The follow-up turn continues on the row already in place: no removed + // row, no effect hop, and `busy` never drops in between (so the next + // card cannot flash before the turn hides it). + setMessages((current) => mergeTurnQuestions(current, turns)); + await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel }); } else { const narration = typeof payload?.narration === "string" && payload.narration.trim() ? payload.narration.trim() @@ -1086,16 +1175,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onMessagesChange, onProfileIncomplete, readonly, + send, sessionId, setPending, ]); - useEffect(() => { - if (!choiceContinuationPending.current || busy || readonly) return; - choiceContinuationPending.current = false; - void send("read_only", ""); - }, [busy, readonly, send]); - useEffect(() => { if (initialTurns.length > 0) { if (shouldStartOpening) onOpeningConsumed?.(); @@ -1112,6 +1196,24 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setError(""); setAcceptingCandidateId(candidateId); setPending(true); + // Adoption is a server round trip followed by an agent turn; the reader sees + // one live row for the whole of it instead of a greyed button. + const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? ""; + const adoptingLabel = rectificationAdoptingLabel(candidateTime); + keyCounter.current += 1; + const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`; + beginLiveRun(adoptingLabel); + setMessages((current) => [ + ...current, + { + role: "assistant", + text: "", + renderKey: assistantRenderKey, + state: "thinking", + activityTrace: emptyActivityTrace(), + activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() }, + }, + ]); try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`, @@ -1159,14 +1261,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onSaved?.(payload.saved_time, "accepted"); onCompleted?.(); await loadCaseSnapshot(); - choiceContinuationPending.current = true; + await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel }); } catch (caught) { + setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); } finally { setAcceptingCandidateId(null); setPending(false); } - }, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, sessionId, setPending]); + }, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]); async function copyMessage(message: RenderMessage) { try { @@ -1289,33 +1392,63 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { && message.question.focus_id === currentQuestion.focus_id && !questionIsAnswered(message.question) )); - const questionLoadFailed = questionSource === "unavailable"; - const showMissingQuestion = Boolean( - caseSnapshotLoaded - && resumableCase - && !readonly - && !busy - && currentQuestion === null - && !questionLoadFailed, - ); - const showUnavailableQuestion = Boolean( - caseSnapshotLoaded - && resumableCase - && !readonly - && !busy - && currentQuestion !== null - && !liveQuestionOnMessages - && !questionLoadFailed, - ); - const showQuestionLoadFailed = Boolean( - caseSnapshotLoaded - && resumableCase - && !readonly - && !busy - && questionLoadFailed, - ); + // The gap between a settled turn and its next question has two visible + // states: `preparing` (one live timeline row, timed refetches) and + // `unavailable` (copy and a reload button) — never bare copy telling the + // reader to wait for the server. + const questionGap = rectificationQuestionGapState({ + liveQuestionVisible: liveQuestionOnMessages, + questionMissing: currentQuestion === null, + questionLoadFailed: questionSource === "unavailable", + offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime, + busy, + readonly, + regenerating: regeneratingMessageKey !== null, + snapshotLoaded: caseSnapshotLoaded, + resumableCase, + retryAttempts: questionRetryAttempts, + retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT, + }); + const conversationState = rectificationConversationState({ + messageCount: messages.length, + busy, + readonly, + shouldStartOpening, + openingStarted: openingRequested, + }); const canSend = !busy && !readonly && !regeneratingMessageKey; + // Question recovery: the turn already refetched once on completion; while + // the gap is `preparing`, refetch on a timer up to the retry limit, then + // hand the reader a reload button. Attempts reset once a question arrives + // or another turn starts (both in handlers, never in an effect). + useVisibilityAwarePoll({ + enabled: questionGap === "preparing", + intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS, + refreshOnVisible: false, + onPoll: () => { + setQuestionRetryAttempts((current) => current + 1); + void refetchQuestion(); + }, + }); + async function refetchQuestion() { + await loadCaseSnapshot(); + } + function reloadQuestion() { + setQuestionRetryAttempts(0); + void refetchQuestion(); + } + + // A Case with no turns and no automatic first turn (the server never starts + // an opening for a resumed Case) needs a way to begin; the server suppresses + // a duplicate opening, so this is safe to press twice. + function startOpeningManually() { + if (readonly || busy || openingStarted.current) return; + openingStarted.current = true; + setOpeningRequested(true); + void send("opening", ""); + } + function submitChoice(key: ChoiceKey) { if (!choiceCard) return; void submitStructuredChoice(CHOICE_ACTION, key); @@ -1339,6 +1472,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const boardPeek = compactBoard && !boardOpen ? ( {headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
@@ -1364,6 +1498,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { 先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}”

)} + {conversationState === "empty" && ( +
+

{RECTIFICATION_EMPTY_COPY}

+ +
+ )} {messages.map((message) => { const showActions = message.role === "assistant" && message.state === "settled" @@ -1488,6 +1628,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
); })} + {questionGap === "preparing" && ( +
+ +
+ )} + {questionGap === "unavailable" && ( +
+

{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}

+ +
+ )} {savedTime && savedStatus === "confirmed" && (

已确认校正时间:{savedTime} @@ -1525,15 +1678,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { 之后新建对话即按此时间排盘。 )} - {(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && ( -

- {showQuestionLoadFailed - ? "题目加载失败,请刷新。" - : showMissingQuestion - ? "当前没有可回答的问题,正在等待服务端更新。" - : "当前问题暂时无法显示,请等待服务端更新。"} -

- )} function RectificationBoardBody({ result, + declaredTime, savedStatus, diff, compact, @@ -42,6 +44,7 @@ function RectificationBoardBody({ onClose, }: Readonly<{ result: RectificationCandidateResult | null; + declaredTime: string | null; savedStatus: "accepted" | "confirmed" | null; diff: RectificationBoardDiff; compact: boolean; @@ -50,6 +53,7 @@ function RectificationBoardBody({ }>) { const table = result ? workingRectificationHouseTable(result) : null; const workingTime = workingRectificationTime(result); + const emptyCopy = rectificationBoardEmptyCopy(declaredTime); const grouped = result ? groupWindowTransitions(result.windowTransitions) : []; const scoringMinutes = grouped.filter((row) => row.scoringLayers.length > 0); const displayMinutes = grouped.filter((row) => row.displayLayers.length > 0); @@ -69,7 +73,11 @@ function RectificationBoardBody({

当前盘面

- {workingTime ? {workingTime} : null} + {workingTime + ? {workingTime} + : emptyCopy.clock + ? {emptyCopy.clock} + : null} {compact && ( ))}
diff --git a/frontend/src/components/sidebar-session-row.tsx b/frontend/src/components/sidebar-session-row.tsx index 7f419420..9cdcc49e 100644 --- a/frontend/src/components/sidebar-session-row.tsx +++ b/frontend/src/components/sidebar-session-row.tsx @@ -12,6 +12,7 @@ import { Trash2, } from "lucide-react"; import { forwardRef } from "react"; +import { RECTIFICATION_SIDEBAR_OPENING_NOTE } from "@/lib/rectification-surface-state"; import { SidebarMenuButton } from "@/components/ui/sidebar"; import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence"; @@ -39,6 +40,8 @@ export type SidebarSessionControls = { type SidebarSessionRowProps = { readonly session: SidebarSession; readonly active: boolean; + /** Its Case is being opened: a static note, no spinner. */ + readonly opening?: boolean; readonly disabled: boolean; readonly menuOpen: boolean; readonly onMenuOpenChange: (open: boolean) => void; @@ -53,6 +56,7 @@ type SidebarSessionRowProps = { export const SidebarSessionRow = forwardRef(function SidebarSessionRow({ session, active, + opening = false, disabled, menuOpen, onMenuOpenChange, @@ -83,12 +87,14 @@ export const SidebarSessionRow = forwardRef {session.pinned ? : null} {session.title} + {opening ? {RECTIFICATION_SIDEBAR_OPENING_NOTE} : null}