Compare commits

...

3 Commits

Author SHA1 Message Date
Jesse_Chen fec000f79d docs(rectification): record the surface round — BUG-479..483, DESIGN.md rectification surface, walkthrough section 8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:56:38 +00:00
Jesse_Chen ef11f6e335 feat(rectification): confirm the tap on the choice card and show the declared minute on the empty board
The selected option carries a check and 已选择, and a card whose tap is
being recorded shows one inline-spinner row 正在记录… (the timeline row's
shape, not a new vocabulary). Before any candidate exists the board's clock
shows the profile's declared birth minute with two lines of copy, the peek
reads 当前盘面 · 填报 HH:MM, and the column narrows until a result arrives.
The snapshot API has no declared-time house table, so none is invented.

BUG-482, BUG-483

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:53:59 +00:00
Jesse_Chen 91b8b33aa6 fix(rectification): reveal the surface once, keep one live row across choice and adoption, and give an empty Case a start
Opening a Case now hydrates turns and snapshot in one read (4s budget)
before the session switches, so the sidebar no longer flashes a plain
transcript, the panel never mounts empty, and the first completed turn no
longer remounts the whole surface (the key is the session/Case binding
only). Entry feedback is static: the card says 正在打开…, the sidebar row
打开中. A tapped choice or an adopted candidate continues the follow-up turn
on the live row already in place, with busy held across the chain, so there
is no empty frame and no next card flashing in. The question slot has four
pure states — a gap is a timeline live row with timed refetches, then a
reload — and no copy asks the reader to wait for the server. A resumed Case
with no turns shows 这段校正还没有开始 and 开始提问. Stopping keeps what
streamed and says so; 402 explains before redirecting; the opening row names
what it is doing; the tap is echoed as the reader's own line.

BUG-479, BUG-480, BUG-481, BUG-482 (echo)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:53:59 +00:00
24 changed files with 1304 additions and 173 deletions
+7
View File
@@ -1,5 +1,12 @@
# BLOCKED
## 生时校正会话面:消除空白假死与交互摩擦(2026-09-02,分支 `codex/rectification-ux-20260902`
- **浏览器级手工验收未做:执行环境无登录态、无 Chrome。** 任务书任务 0 的六条手工项与任务 1 的整轮录屏(开场 → 3 道选择题 → 候选 → 采用)都做不了;已按任务书把它们写进 `docs/testing/staging-manual-walkthrough-20260901.md` 第 8 节,交给有真实会话的人。本轮的替代证据:`tests/rectification-surface-state.test.ts`(纯函数四态、hydration 超时/失败、文案)与 `tests/rectification-surface-contract.test.ts`(源码锁:一次揭幕、无重挂、续接同一行、空态、板首态)。
- **1.3「若快照 API 提供填报时间宫位表则直接渲染」未做:API 不提供。** `frontend/src/app/api/rectification/cases/[caseId]/route.ts``dossierResponse` 只带 `accepted_time` / `confirmed_time`grep `natal|declared` 无命中;按任务书只做文案与收窄,不造数据。
- **书面偏差:hydration 不是 `Promise.allSettled([turns, snapshot])`,而是一次请求。** turns 与快照来自同一个 `GET /api/rectification/cases/:id` 读取,两个并行请求会重复;`hydrateRectificationCase``Promise.race` 对一次读取套 4 秒上限,语义(并行拉完再切换、超时仍揭幕)不变,契约锁改锁 `hydrateRectificationCase` + `RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS`
- **书面偏差:turns 后到的填充与重试计数复位不用 `useEffect`。** `npm run lint``react-hooks` 规则拦截 effect 内同步 setState;改为 React 文档的"渲染中按上一 prop 调整 state"模式与事件处理器内复位,行为等价。
## 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 一并落地。
+95
View File
@@ -0,0 +1,95 @@
# PROGRESS · 生时校正会话面:消除空白假死与交互摩擦(2026-09-02)
工作树:`.worktrees/rectification-ux-20260902`
分支:`codex/rectification-ux-20260902`
基线:`origin/staging` @ `4dc0c8c7``codex/streaming-ux-20260901` 的 merge commit;任务书前置条件满足)。
执行时 `TASK-rectification-walkthrough-polish-20260902.md``TASK-unified-loading-20260902.md` 均未落地(远端无对应分支):B.2/D.2 按任务书由本轮承担,没有 `question.ready` 事件,0.4 走"完成后立即拉一次 + 定时重试"的分支。
未改服务端与 SQL。未改 `.gitea/workflows/**`。未合并 staging,未碰 main。未手写 `useCallback` / `useMemo``rectification-agentic-chat.tsx` 既有的原样保留,新增的 `refetchQuestion` / `reloadQuestion` / `startOpeningManually` 是普通函数)。BUG 编号开工时确认远端最大号为 478,本轮 479–483。
| 任务 | 状态 | commit | BUG |
| --- | --- | --- | --- |
| 0.1 入口有反馈不转圈 | 完成 | `91b8b33a` | BUG-479 |
| 0.2 一次揭幕、面板不重挂 | 完成 | `91b8b33a` | BUG-479 |
| 0.3 问题槽只有生成中态、在对话流内 | 完成 | `91b8b33a` | BUG-479 |
| 0.4 「等待服务端更新」→ live 行 + 重试 + 重新加载 | 完成 | `91b8b33a` | BUG-479 |
| 0.5 选择题/采用不留缝不闪卡 | 完成 | `91b8b33a` | BUG-480 |
| 0.6 空 turns 会话的起点 | 完成 | `91b8b33a` | BUG-481 |
| 0.7 停止后的文案 | 完成(现状不足,已补) | `91b8b33a` | — |
| 1.1 选择卡确认感 | 完成 | `ef11f6e3` | BUG-482 |
| 1.2 用户选择回显 | 完成(在 `91b8b33a`,与 `send` 重写同一处) | `91b8b33a` | BUG-482 |
| 1.3 盘面首态 | 完成(文案 + 收窄;宫位表 API 不提供,未造数据) | `ef11f6e3` | BUG-483 |
| 1.4 开场 live 文案 | 完成(在 `91b8b33a` | `91b8b33a` | — |
| 1.5 402 跳转前提示 | 完成(在 `91b8b33a` | `91b8b33a` | — |
| 2 DESIGN.md | 完成 | 本 docs commit | — |
1.2 / 1.4 / 1.5 的改动都落在 `send``submitStructuredChoice` 的重写里,无法从任务 0 的 commit 里干净拆出,所以随 `91b8b33a` 提交。
## 六段空白与死角:各自的消除证据
| 空白 | 消除方式 | 证据(契约 / 纯函数用例) |
| --- | --- | --- |
| 1 首页卡片无反馈 | `rectificationCardLabel``rectificationLoading` 时为「正在打开…」;卡片 `aria-busy` + `data-opening` + `cursor: progress`;侧栏目标行「打开中」 | `rectification-surface-contract` → "entry feedback is static…" |
| 2 侧栏进入:普通对话 → 空白 → 重挂 | `selectSession` 对未打开的校正会话不再先 `setActiveSessionId`/写 URLhook 在 `/cases/open``hydrateRectificationCase`(一次 Case 读取取 turns + 快照,4 秒上限)再一次性切换;面板 key 去掉 `-ready/-loading` | 同上 → "…revealed once…"`rectification-surface-state` → "hydration reads turns and snapshot from one Case read" / "…resolves incomplete…" |
| 3 第一轮结束整面重挂 | key 只剩 `${sessionId}-${caseId}`;后到的 turns 经 prop 更新只填空 transcript(渲染中按上一 prop 调整) | 同上(`doesNotMatch(page, /"ready" : "loading"/)``seededTurns` 锁) |
| 4 快照回来前问题槽空 | 快照随揭幕到位(`initialSnapshot` 初始化 state`caseSnapshotLoaded` 初值 `initialSnapshot !== null`);面板不再自拉 | 同上(`useState(initialSnapshot !== null)`、无挂载 fetch |
| 5 选择题两段缝 + 闪卡 | `send(action, text, continuation)` 续接同一条 live 行并跳过 busy 守卫;`willContinue` 在同一 async 链里 `await send("read_only", …)``choiceContinuationPending` 与 effect 删除;`busy` 全程 true | 同上 → "…keeps one live row through the follow-up turn" |
| 6 采用只有按钮变灰 | 点击即追加「正在采用 HH:MM…」行,POST → 快照 → 复用该行续接 read_only | 同上;`rectification-surface-state``rectificationAdoptingLabel` |
| 死角 空 turns 会话 | `rectificationConversationState``empty` → 「这段校正还没有开始。」+「开始提问」(`send("opening")``openingStarted` 守卫) | `rectification-surface-state` → "conversation state…";契约 → "an empty Case…" |
| 文案 「等待服务端更新」 | `rectificationQuestionSlotState` 四态:`preparing`(时间线 live 行「正在准备下一个问题…」+ 2s 定时重拉 ≤2 次)→ `unavailable`(「没有拿到下一个问题。」+ 44px「重新加载」) | `rectification-surface-state` → "question slot: a gap is preparing…";契约 → "…never tells the reader to wait for the server" |
## 改动文件
- 新建:`frontend/src/lib/rectification-surface-state.ts`(常量、hydration、turn 解析、两个纯状态函数、板空态文案、填报时间派生)、`frontend/tests/rectification-surface-state.test.ts`9 用例)、`frontend/tests/rectification-surface-contract.test.ts`8 用例)。
- 改动:`use-rectification-surface.ts``use-session-management.ts``page.tsx`state 2 处、hook 参数、卡片文案、面板 key/props、侧栏 prop、import)、`app-sidebar.tsx``sidebar-session-row.tsx``starter-home.tsx``conversational-birth-time-rectification.tsx`props)、`rectification-agentic-chat.tsx``consultation-run-timeline.tsx`(导出 `ConsultationTimelineLiveRow`,同一行形态)、`rectification-choice-card.tsx``rectification-board.tsx``rectification-board-model.ts``rectificationBoardPeekCopy` 第二参可选,旧调用不变)、`globals.css``DESIGN.md``docs/BUG_HISTORY.md``docs/testing/staging-manual-walkthrough-20260901.md`(第 8 节)、`BLOCKED.md`
## 1.3 核实
`frontend/src/app/api/rectification/cases/[caseId]/route.ts``dossierResponse` 只带 `accepted_time` / `confirmed_time` / `candidate_range``grep -n 'natal\|declared\|birth_time\|profile'` 无命中 → 快照 API 不提供填报时间的宫位表。按任务书只做文案与收窄,`declaredTime``page.tsx` 从 profile`reportedTime || time`,须为 `H:MM`)派生。
## 与任务书的书面偏差(均登记 BLOCKED.md
1. hydration 是一次 `GET /api/rectification/cases/:id`(turns 与快照同一端点)套 4 秒 `Promise.race`,不是 `Promise.allSettled([turns, snapshot])`;语义不变。契约锁改锁 `hydrateRectificationCase` + `RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS`。unified-loading 落地时应把它的 4 秒常量与 `RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS` 合一。
2. turns 后到的填充与重试计数复位不用 `useEffect``npm run lint``react-hooks/set-state-in-effect` 拦):前者是渲染中按上一 prop 调整 state,后者在 `applyCaseSnapshot`(有问题到达)与 `setPending(true)` 里复位。
3. 选择卡 pending 行用 `InlineSpinner` + shimmer 文案而不是嵌一个 `ConsultationTimelineLiveRow`:卡片内是 `<p role="status">`,与时间线 live 行同一形态(同 spinner、同 `agent-activity-status__text`)。
## 被触碰的既有断言(红线 4,每处源码上方均注明原值与错因)
| 文件 | 原断言 | 现在 | 理由 |
| --- | --- | --- | --- |
| `tests/rectification-agentic-entry.test.ts` :85、:194 | `key={…-${rectificationTurns.length > 0 ? "ready" : "loading"}}` | key 只含 session/Case`doesNotMatch /"ready" : "loading"/``initialSnapshot={rectificationSnapshot}` | 锁的正是重挂缺陷(BUG-479) |
| 同上 :93 | `page` 含 turn 解析的 `methods: Array.isArray(…)` | 改读 `rectification-surface-state.ts``page``parsePersistedRectificationTurns(payload?.turns)` | 解析器搬家,形状不变(BUG-479) |
| 同上 :221:224 | `await loadCaseSnapshot(); … choiceContinuationPending.current = true;`、effect 里 `void send("read_only", "")``doesNotMatch` 同链 `await send` | 锁 `await loadCaseSnapshot(); … await send("read_only", "", { reuseAssistantRenderKey…, label: adoptingLabel })``doesNotMatch /choiceContinuationPending/` | 锁的是 effect 中转本身(BUG-480 |
| 同上 :504 | workspace className 以 ` is-board-open` 结尾 | 追加 `${candidateResult ? "" : " is-board-empty"}` | 空板收窄(BUG-483 |
| 同上 :557 | `match(chat, /isStructuredChoiceUserText/)` | `doesNotMatch` + `userVisibleChoiceLine(answeredCard, optionId)` | 过滤掉的正是用户回显(BUG-482) |
| `tests/rectification-answer-choice.test.ts` :1123 | `send\("read_only", ""\)` | `await send("read_only", "", { reuseAssistantRenderKey…, label: recordingLabel })` | 续接参数(BUG-480 |
| `tests/rectification-spoken-collect.test.ts` :134:138 | `showMissingQuestion = Boolean(caseSnapshotLoaded…currentQuestion === null` + 「当前没有可回答的问题,正在等待服务端更新」 | 锁 `rectificationQuestionSlotState({… snapshotLoaded: caseSnapshotLoaded, … resumableCase,``doesNotMatch /等待服务端更新/` | 锁的正是开发者文案(BUG-479) |
未改任何其它既有断言;`rectification-answer-choice` :583 的 `isStructuredChoiceUserText` 纯函数用例原样保留(函数仍存在,只是不再用于过滤渲染)。
## 命令输出
`./node_modules/.bin/tsc --noEmit`**0**(任务 0、任务 1 后各一次)。
`./node_modules/.bin/eslint` 触及 13 个文件 → **0 error**42 warning 全在 `page.tsx`(与 `origin/staging` 同文件既有 `react-hooks` warning;本轮在 `page.tsx` 只加了 2 个 state、1 个派生值、4 行 props,未新增 warning)。开发中一度因 effect 内 setState 报 2 个 error,已改(见偏差 2)。
Python quick gate 4 个文件(`test_daily_and_rectification_entrypoints` / `test_session_management_entrypoints` / `test_birth_time_journey_contract` / `test_supabase_user_data_contract`)→ **24 passed**
`./node_modules/.bin/tsx --test tests/*.test.ts`(基线在同一 worktree 的 `4dc0c8c7` 干净状态上跑):
| | 基线 `4dc0c8c7` | 现在 |
| --- | ---: | ---: |
| tests | 2486 | 2503 |
| pass | 2452 | 2469 |
| fail | 24 | 24 |
| skipped | 10 | 10 |
失败清单与基线逐条比对:`comm -3` 差集为空,同一集合。24 条失败全是本机无 Docker / PostgreSQL / 部署夹具的数据库、Better Auth、staging workflow、live sync 类既有缺口(与前两轮 PROGRESS 同一组)。
聚焦 26 套件(本轮 2 个新文件 + 校正/会话/composer/滚动/样式契约)→ 248 / 248。
`./node_modules/.bin/next build`**0**`✓ Compiled successfully in 49s``Finished TypeScript in 49s``Generating static pages (62/62)``/` 仍是 `○ (Static)`。5 条既有 `skill-package-registry.ts` filesystem-tracing warning,未失败。
## 未做与原因
`BLOCKED.md` 顶部:浏览器级手工验收(六条手工项 + 整轮录屏)无登录态无 Chrome,已写进 `docs/testing/staging-manual-walkthrough-20260901.md` 第 8 节;1.3 的宫位表 API 不提供;两条书面偏差。
+80
View File
@@ -7362,3 +7362,83 @@
- 相关记录:BUG-477
- 复发自:无
- 修复版本:待发布
## BUG-479 | 生时校正会话面在打开、恢复与第一轮结束时各有一段空白,且整块重挂
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`page.tsx``use-rectification-surface.ts``use-session-management.ts``rectification-agentic-chat.tsx``starter-home.tsx``sidebar-session-row.tsx``app-sidebar.tsx``rectification-surface-state.ts`(新)
- 用户现象:首页点「生时校正」卡片无任何反馈;从侧栏点开已有校正会话先闪一帧普通对话、再整块空白、再重挂;新建校正第一轮回答结束时整个面板闪一下、滚动归零;恢复会话后问题槽在快照回来前空着;问题槽出现「正在等待服务端更新」的开发者文案。
- 触发条件:任意进入校正面;任意校正第一轮完成。
- 根因:面板 key 含 `rectificationTurns.length > 0 ? "ready" : "loading"`turns 到达即卸载重挂;`rectificationSurfaceOpen` 要求 `activeSession.id === rectificationSessionId`,而 `selectSession` 先切会话再异步 open;面板挂载后自己再拉一次快照;入口卡只 `disabled`
- 修复:`/cases/open` 返回后先 `hydrateRectificationCase`(一次 Case 读取同时取 turns 与快照,4 秒上限)再一次性切换会话;`selectSession` 对未打开的校正会话不再先 `setActiveSessionId`;面板 key 只保留 `${sessionId}-${caseId}`turns/快照作为初始 state,后到的 turns 经 prop 更新只填空 transcript;卸载时中止流与快照读;入口卡与侧栏行给静态「正在打开…」/「打开中」;问题槽改为纯函数四态(`rectification-surface-state.ts`),缝隙期显示时间线 live 行并定时重拉,两次后给「重新加载」按钮,旧文案删除。
- 验证:`tests/rectification-surface-contract.test.ts``tests/rectification-surface-state.test.ts``rectification-agentic-entry`key 锁改写)、`rectification-spoken-collect`(文案锁改写)、`chat-session-url`
- 防复发:校正面一个会话只挂载一次;turns 与快照只能是 prop/state 更新;任何等待必须是时间线 live 行或问题槽 live 行,不得出现「等待服务端更新」类文案;揭幕后不得再出现非生成中的 spinner。
- 相关记录:BUG-480、BUG-481、BUG-478
- 复发自:无
- 修复版本:待发布
## BUG-480 | 选择题点击与采用候选之间有空帧、下一题卡片闪现,采用时只有按钮文案在变
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`rectification-agentic-chat.tsx`
- 用户现象:点完选项后「正在记录本次选择」行消失一帧,下一题卡片闪一下又没了,再出现新的「正在处理」行;点「采用此时间」只看到按钮变灰变成「正在采用…」,对话区静止数秒。
- 触发条件:任意会继续追问的结构化选择;任意采用候选。
- 根因:`submitStructuredChoice``willContinue` 分支删掉 thinking 行、置 `choiceContinuationPending`,由下一次 effect 再 `send("read_only")` 并重新追加一行;中间 `busy` 掉回 false`showLiveChoiceCard` 对刚装进的新卡片为真。`acceptCandidate` 不在 transcript 里放任何 live 行。
- 修复:`send` 增加 `continuation: { reuseAssistantRenderKey, label }`,续接同一条 live 行并跳过 busy 守卫;选择与采用都在同一个 async 链里 `await loadCaseSnapshot()` 后直接 `await send("read_only", …)``busy` 全程为 true`choiceContinuationPending` 与对应 effect 删除;采用时先追加「正在采用 HH:MM…」行。
- 验证:`tests/rectification-surface-contract.test.ts``rectification-agentic-entry`(采用链锁改写)、`rectification-answer-choice``send("read_only", "")` 锁改写)。
- 防复发:续接的回合复用已有 live 行,不得删行再由 effect 追加;`busy` 在整条链上不得中途回落。
- 相关记录:BUG-479
- 复发自:无
- 修复版本:待发布
## BUG-481 | 开场失败或未持久化的校正会话再进来永远空白,没有任何起点
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`rectification-agentic-chat.tsx``rectification-surface-state.ts`
- 用户现象:从侧栏进入一个没有任何记录的旧校正会话,画面空白,输入框可用但用户不知道该先说什么。
- 触发条件:服务端对 resumed case 一律返回 `should_start_opening=false`,且该 case 的 turns 为空。
- 根因:面板只在 `shouldStartOpening` 为真时自动发开场,其它情况不显示任何 CTA。
- 修复:`rectificationConversationState` 判定 `empty` 时显示「这段校正还没有开始。」与「开始提问」按钮,调用已有的 `send("opening", "")`;服务端已抑制重复开场,客户端 `openingStarted` 守卫。不改服务端与 SQL。
- 验证:`tests/rectification-surface-state.test.ts``tests/rectification-surface-contract.test.ts`
- 防复发:面板任何"无内容"状态都必须给出下一步动作。
- 相关记录:BUG-479
- 复发自:无
- 修复版本:待发布
## BUG-482 | 用户的选择不回显、选中无确认感
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`rectification-agentic-chat.tsx``rectification-choice-card.tsx``globals.css`
- 用户现象:点完选项,transcript 里没有一条"我选了 B",刷新后更看不到自己答过什么;卡片选中后没有对勾,等待时只是鼠标变沙漏。
- 触发条件:任意结构化选择。
- 根因:答过的卡贴在 assistant 消息下但不产生用户行;服务端返回的 `userMessage``applied.userDisplay`)客户端未用;持久化的结构化选择 turn 被 `isStructuredChoiceUserText` 过滤掉;卡片 pending 只改 `cursor: wait`
- 修复:点击即以 `userVisibleChoiceLine`(停止用 `stop_label`)追加用户气泡,成功后以服务端 `userMessage` 校正文本,失败则连同 live 行一起移除;`messagesFromTurns` 保留结构化选择 turn;卡片选中项显示 `Check` + 「已选择」,pending 时卡顶一行 `InlineSpinner` + 「正在记录…」。
- 验证:`tests/rectification-surface-contract.test.ts``rectification-agentic-entry``isStructuredChoiceUserText` 锁改写)。
- 防复发:用户的每个动作在 transcript 里都要有对应的用户行;等待态必须有可见进度。
- 相关记录:BUG-480
- 复发自:无
- 修复版本:待发布
## BUG-483 | 盘面首态是一整块空面板
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`rectification-board.tsx``rectification-board-model.ts``rectification-surface-state.ts``page.tsx``globals.css`
- 用户现象:第一阶段右侧 18–22.5rem 的面板只有一句「补充经历后,这里会显示当前本命宫位和换升时刻。」;移动端 peek 是「补充经历后会在这里更新」。用户明明填过出生时间。
- 触发条件:任何候选结果出现之前。
- 根因:面板没有拿到填报时间;快照 API 不含填报时间对应的宫位表(已核实 `cases/[caseId]/route.ts` 只有 accepted/confirmed time)。
- 修复:面板新增 `declaredTime``page.tsx` 从 profile 派生),无结果时时钟位显示填报时间,正文两行「填报出生时间 HH:MM」「回答几个问题后,这里会显示宫位随时间的变化。」;peek 为「当前盘面 · 填报 HH:MM」;`is-board-empty` 把板列收为 `minmax(16rem, 18rem)`。不造宫位数据。
- 验证:`tests/rectification-surface-state.test.ts``tests/rectification-surface-contract.test.ts``rectification-board-model`
- 防复发:面板空态必须显示已知的用户数据,不得只放一句说明。
- 相关记录:BUG-479
- 复发自:无
- 修复版本:待发布
@@ -56,3 +56,22 @@
---
四轮改造:BUG-464(消息权威化)、BUG-465(会话 URL)、BUG-466(云端唯一真相)、拆页两批。自动化侧每轮均已验收(tsc / 2400+ 条测试 / 构建 / SQL 幂等),此清单覆盖的是自动化够不着的真浏览器行为。
## 8. 生时校正面:一次揭幕与无缝续接(BUG-479–483)
1. 首页点「生时校正」卡片。
- ✅ 预期:卡片脚注立即变成「正在打开…」(不转圈),随后**一次性**出现完整面板:历史消息、问题槽、右侧盘面同时就位;不会先出现一个空面板再填内容。
2. 在普通对话里,从侧栏点一个已有的校正会话。
- ✅ 预期:旧画面保持不动,侧栏该行短暂显示「打开中」,然后直接切到校正面板;**不闪**一帧普通对话,**不出现**空白面板。
3. 新建一段校正,等第一条引导说完。
- ✅ 预期:第一轮结束的瞬间画面不闪、滚动不归零、时间线开合状态保留。
4. 连续答两道选择题。
- ✅ 预期:点完选项立刻在卡上看到对勾「已选择」、卡顶一行「正在记录…」,transcript 里出现你选的那句作为你的气泡,后面一条「正在记录本次选择…」的进行中行**一直在**,直到下一题出现;中间没有空帧,下一题卡片不会闪现又消失。
5. 出现候选时间后点「采用此时间」。
- ✅ 预期:transcript 末尾出现「正在采用 HH:MM…」进行中行,同一条行一直到后续回复出现。
6. 找一个当初开场失败的旧校正会话(记录为空)进入。
- ✅ 预期:看到「这段校正还没有开始。」和「开始提问」按钮,点后正常开场。
7. 右侧盘面在没有候选前。
- ✅ 预期:头部时钟位显示你填报的出生时间,正文是「填报出生时间 HH:MM」两行;面板比有结果时窄一些。
8. 流式回答中点停止。
- ✅ 预期:已生成内容保留,提示「已停止,已生成的内容保留;本次不会扣点。」,不是「暂时不可用」。
+27 -1
View File
@@ -200,6 +200,32 @@ 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 question slot and a house board. 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 or sidebar row) reads the turns and the snapshot in one Case request before the switch, under the same 4-second budget as the home reveal; 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 question slot, the transcript's trailing live row and the composer show:
| State | Question slot | Trailing live row | Composer |
|---|---|---|---|
| `opening` | empty | “正在读取你的出生资料,准备第一个问题…” then tool labels | disabled, stop visible |
| `resuming` | whatever the snapshot already holds | none | enabled |
| `empty` | empty | none; the transcript shows “这段校正还没有开始。” and one primary action “开始提问” | enabled |
| `waiting-question` | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | none | enabled |
| `waiting-question`, retries spent | “没有拿到下一个问题。” and a secondary 44px “重新加载” | none | enabled |
| `choice-live` | the choice card | none | enabled, placeholder points at the card |
| `choice-pending` | the answered card, `Check` + “已选择” on the tap, a top row “正在记录…” | the same live row from “正在记录本次选择…” through the follow-up turn | disabled, stop visible |
| `candidates` | empty | none | enabled |
| `accepted` | empty until the next question | “正在采用 HH:MM…” through the follow-up turn, then “用这个时间看盘” | disabled during the chain |
| `confirmed` | empty | none; “已确认校正时间:HH:MM” | enabled |
| `readonly` | hidden | none; “该校正已结束,只能查看历史。” and “再次校正” | disabled |
| `failed` / stopped | unchanged | the row settles with what streamed; notice “已停止,已生成的内容保留;本次不会扣点。” | enabled |
- **Rules:** the question slot is the last entry of the transcript, indented like an assistant entry, never a widget below the cards. A reader's tap is echoed as their own line (server wording when it differs), live and after a refresh alike. 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.
- **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 opening notes set `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.
@@ -405,7 +431,7 @@ or user IDs.
| 行内 / 局部等待 | 出生地解析、两个会话面时间线的 live 步、兜底活动面板的 live 行、个人报告列表与详情 | `InlineSpinner``inline-spin` | 0.8s linear | `animation: none`,收成静止圆点,不要半圈圆弧 |
| 流式生成中 | 引导语打字、时间线 summary 与 live 行的文案 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear | 保持现有全局降级 |
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。校正面所有等待复用行内等待:问题槽的「正在准备下一个问题…」与选择卡的「正在记录…」都是时间线 live 行的同一形态;进入校正面之前的等待只用静态文案(「正在打开…」/「打开中」),不转圈。
今日星语首次拉取是行内等待,但不用 spinner、也不用透明度呼吸:卡片用静态占位文案(`aria-busy` 仍保留)。轨道环消失后不得再换一套动效继续等。
+59
View File
@@ -2624,6 +2624,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 (BUG-483). */
.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);
@@ -2926,6 +2931,60 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-question-slot__recover {
display: grid;
gap: var(--space-2);
justify-items: start;
}
.rectification-message-wrap > .rectification-question-slot:empty { display: none; }
/* A Case with nothing to show and no automatic opening: one line and one way to start (BUG-481). */
.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 question slot's own live step reuses the timeline row; no summary above it. */
.consultation-run-timeline__list.is-standalone { margin-top: 0; }
/* Static "opening" notes: no spinner after the reveal (unified-loading ruling). */
.session-opening-note {
flex: 0 0 auto;
color: var(--color-ink-tertiary);
font-size: var(--type-overline);
font-weight: 500;
letter-spacing: .02em;
}
.product-entrypoint-card[data-opening="true"],
.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; }
.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);
+17 -3
View File
@@ -25,6 +25,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,
@@ -289,6 +294,8 @@ export default function Home() {
const [rectificationReadonly, setRectificationReadonly] = useState(false);
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
const [rectificationSnapshot, setRectificationSnapshot] = useState<RectificationCaseSnapshotPayload | null>(null);
const [rectificationOpeningSessionId, setRectificationOpeningSessionId] = useState<string | null>(null);
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
const [hydrated, setHydrated] = useState(false);
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
@@ -406,7 +413,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;
@@ -474,7 +484,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,
});
@@ -1671,6 +1682,7 @@ export default function Home() {
sessions={sidebarSessions}
charts={sidebarCharts}
activeSessionId={activeSession?.id ?? null}
openingSessionId={rectificationOpeningSessionId}
account={sidebarAccount}
accountMenuOpen={accountMenuOpen}
accountTriggerRef={accountTrigger}
@@ -1887,12 +1899,14 @@ export default function Home() {
{rectificationSurfaceOpen && rectificationCaseId && (
<ConversationalBirthTimeRectification
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 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)}
+4
View File
@@ -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<HTMLButtonElement>;
@@ -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)}
@@ -83,6 +83,20 @@ export function ConsultationRunTimeline({
);
}
/**
* One live step outside a reply's timeline: the same marker, spinner and
* shimmer label as a timeline row, for a surface that is waiting on the agent
* between turns (the rectification question slot). There is no second waiting
* vocabulary; this is the timeline row itself.
*/
export function ConsultationTimelineLiveRow({ label, id = "live" }: Readonly<{ label: string; id?: string }>) {
return (
<ol className="agent-thinking-timeline consultation-run-timeline__list is-standalone" aria-label="进行中">
<TimelineRow row={{ id, kind: "write", status: "live", label }} />
</ol>
);
}
function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
const KindIcon = KIND_ICONS[row.kind];
const expandable = Boolean(
@@ -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<{
@@ -25,6 +26,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;
@@ -48,10 +48,29 @@ import {
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
import {
parsePersistedRectificationTurns,
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,
rectificationAdoptingLabel,
rectificationConversationState,
rectificationInitialLiveLabel,
rectificationQuestionSlotState,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
import {
CHOICE_ACTION,
STOP_ACTION,
isStructuredChoiceUserText,
userVisibleChoiceLine,
shouldContinueAfterStructuredChoice,
stableChoiceActionKey,
type ChoiceOptionId,
@@ -68,6 +87,7 @@ import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anc
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,
@@ -172,6 +192,8 @@ type RectificationAgenticChatProps = Readonly<{
readonly: boolean;
shouldStartOpening: boolean;
initialTurns: readonly PersistedTurn[];
initialSnapshot: RectificationCaseSnapshotPayload | null;
declaredTime: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
@@ -265,7 +287,9 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
}];
}
if (isIncompleteRunBanner(turn.text ?? "")) return [];
if (isStructuredChoiceUserText(turn.text)) return [];
// A structured choice the reader tapped is persisted as a user turn and is
// shown as one: the reader can see what they answered after a refresh, the
// same as the live echo (BUG-482).
return [{
role: "user",
text: turn.text ?? "",
@@ -275,6 +299,31 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
});
}
type CaseSnapshotState = Readonly<{
candidate: CandidateResult;
question: CurrentQuestionModel | null;
choice: ChoiceCardModel | null;
caseStatus: RectificationCaseStatus | null;
savedTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
}>;
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
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),
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,
@@ -282,6 +331,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
readonly,
shouldStartOpening,
initialTurns,
initialSnapshot,
declaredTime,
models,
selectedModelId,
onSelectModel,
@@ -297,23 +348,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
headerSlot,
} = props;
// Turns and snapshot were read before this surface mounted (one reveal,
// BUG-479); both are initial state, and later arrivals are prop updates,
// never a remount.
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [savedTime, setSavedTime] = useState<string | null>(null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false);
const [savedTime, setSavedTime] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [openingRequested, setOpeningRequested] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const [choiceNonce, setChoiceNonce] = useState(0);
const choiceContinuationPending = useRef(false);
const conversation = useRef<HTMLElement>(null);
const workspace = useRef<HTMLDivElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
@@ -359,11 +414,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const setPending = useCallback((value: boolean) => {
setBusy(value);
// A new turn starting also ends the question slot's retry cycle.
if (value) setQuestionRetryAttempts(0);
onPendingChange?.(value);
}, [onPendingChange]);
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const applyCaseSnapshot = useCallback((payload: {
latest_result?: unknown;
current_question?: unknown;
@@ -373,62 +428,83 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
accepted_time?: unknown;
confirmed_time?: unknown;
};
turns?: unknown;
} | null) => {
if (!payload) return;
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
const nextCaseStatus = isRectificationCaseStatus(payload.case?.status)
? payload.case.status
: null;
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
setCandidateResult(nextCandidate);
const next = caseSnapshotState(payload);
if (!next) return;
const nextQuestion = next.question;
setCandidateResult(next.candidate);
setCurrentQuestion(nextQuestion);
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
// A question arriving ends the slot's retry cycle.
if (nextQuestion !== null) setQuestionRetryAttempts(0);
setChoiceCard(next.choice);
setCaseStatus(next.caseStatus);
setCaseSnapshotLoaded(true);
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
} else if (acceptedTime) {
setSavedTime(acceptedTime);
setSavedStatus("accepted");
if (next.savedStatus && next.savedTime) {
setSavedTime(next.savedTime);
setSavedStatus(next.savedStatus);
}
// A late hydration (the reveal deadline passed first) lands here: the
// Case read carries the turns too, so an empty transcript fills from it.
const turns = parsePersistedRectificationTurns(payload?.turns);
if (turns.length > 0) {
setMessages((current) => (current.length === 0 ? messagesFromTurns(turns) : current));
}
}, []);
// Snapshot reads after mount (turn completion, question-slot retries) are
// abortable so none of them lands after the surface has gone.
const snapshotAbort = useRef<AbortController | null>(null);
const loadCaseSnapshot = useCallback(async () => {
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;
applyCaseSnapshot(await response.json().catch(() => null));
} catch {
// Snapshot refresh is best-effort; the durable Case remains on the server.
} 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) 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
* (BUG-480). 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);
@@ -436,19 +512,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
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 initialLabel = rectificationInitialLiveLabel(action, continuation?.label);
const initialActivity: AgentActivityView = {
phase: "evidence-validation",
label: initialLabel,
startedAt: Date.now(),
};
setMessages((current) => [
...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(),
} },
]);
setMessages((current) => (continuation
? current.map((message) => (message.renderKey === assistantRenderKey
? { ...message, text: "", state: "thinking", activityTrace: emptyActivityTrace(), activity: initialActivity }
: message))
: [
...current,
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: initialActivity },
]));
setDraft("");
let raw = "";
@@ -456,11 +538,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
let completedTurnId: string | undefined;
let currentActivity: AgentActivityView | undefined = {
phase: "evidence-validation",
label: "正在处理…",
startedAt: Date.now(),
};
let currentActivity: AgentActivityView | undefined = initialActivity;
// Every stream event lands in `frames`; it commits at most once per animation
// frame and releases text at a steady pace. The loop below never calls
// setMessages for a live turn directly except on `attempt.reset`.
@@ -507,7 +585,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return;
}
if (response.status === 402) {
window.location.assign(membershipHref("rectification"));
// Say why before the page changes underneath the reader (task 1.5).
setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE);
window.setTimeout(() => {
window.location.assign(membershipHref("rectification"));
}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS);
return;
}
if (response.status === 401) setError("请先登录。");
@@ -694,6 +776,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? caught.name === "AbortError"
: caught instanceof Error && caught.name === "AbortError";
if (aborted) {
// Stopped by the reader: what streamed stays in place and the notice
// says so; nothing here is an outage (task 0.7).
if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE);
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
if (raw.trim() || hasActivityReceipt(completedReceipt)) {
@@ -763,6 +848,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
keyCounter.current += 1;
const turnKey = keyCounter.current;
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
const echoRenderKey = `v9-choice-echo-${turnKey}`;
// The tap is echoed as the reader's own line, right after the answered
// card and before the working row, so the transcript reads as a
// conversation and survives a refresh the same way (BUG-482).
const echoText = optionId === "stop" ? answeredCard.stop_label : userVisibleChoiceLine(answeredCard, optionId);
const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer;
setMessages((current) => [
...current.map((message) => (
offeringRenderKey && message.renderKey === offeringRenderKey
@@ -772,6 +863,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
: message
)),
{ role: "user", text: echoText, renderKey: echoRenderKey, state: "settled" },
{
role: "assistant",
text: "",
@@ -780,7 +872,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activityTrace: emptyActivityTrace(),
activity: {
phase: "evidence-validation",
label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer,
label: recordingLabel,
startedAt: Date.now(),
},
},
@@ -808,7 +900,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const payload = await response.json().catch(() => null);
if (!response.ok) {
setMessages((current) => current.flatMap((message) => {
if (message.renderKey === assistantRenderKey) return [];
if (message.renderKey === assistantRenderKey || message.renderKey === echoRenderKey) return [];
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
return [{ ...message, choiceAttachment: undefined }];
}
@@ -822,12 +914,23 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setError(payload?.message || payload?.error || `请求失败(${response.status}`);
return;
}
const serverEcho = typeof payload?.userMessage === "string" && payload.userMessage.trim()
? payload.userMessage.trim()
: echoText;
if (serverEcho !== echoText) {
setMessages((current) => current.map((message) => (
message.renderKey === echoRenderKey ? { ...message, text: serverEcho } : message
)));
}
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
onCompleted?.();
await loadCaseSnapshot();
if (willContinue) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
choiceContinuationPending.current = true;
// The same working row carries straight into the follow-up turn: no
// frame without a live row, and no next card flashing in between
// (BUG-480). `busy` stays true across the whole chain.
onMessagesChange?.([{ role: "user", text: serverEcho }]);
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
} else {
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
@@ -841,12 +944,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
: message));
onMessagesChange?.([
{ role: "user", text: serverEcho },
{ role: "assistant", text: narration },
]);
}
} catch {
setMessages((current) => current.flatMap((message) => {
if (message.renderKey === assistantRenderKey) return [];
if (message.renderKey === assistantRenderKey || message.renderKey === echoRenderKey) return [];
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
return [{ ...message, choiceAttachment: undefined }];
}
@@ -868,16 +972,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?.();
@@ -885,15 +984,43 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
if (readonly || openingStarted.current || !shouldStartOpening) return;
openingStarted.current = true;
setOpeningRequested(true);
onOpeningConsumed?.();
void send("opening", "");
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
// 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 (BUG-481).
function startOpeningManually() {
if (readonly || busy || openingStarted.current) return;
openingStarted.current = true;
setOpeningRequested(true);
void send("opening", "");
}
const acceptCandidate = useCallback(async (candidateId: string) => {
if (!candidateResult || acceptingCandidateId || busy || readonly) return;
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 (BUG-480).
const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? "";
const adoptingLabel = rectificationAdoptingLabel(candidateTime);
keyCounter.current += 1;
const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`;
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`,
@@ -941,14 +1068,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, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
async function copyMessage(message: RenderMessage) {
try {
@@ -1048,24 +1176,53 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? currentQuestion.prompt
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const showMissingQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& currentQuestion === null,
);
const showUnavailableQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& currentQuestion !== null
&& ((currentQuestion.kind === "choice" && !choiceCard)
|| (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)),
);
// The slot has four visible states. A gap after a settled turn is
// `preparing` (one live row, timed refetches) and then `unavailable` (a
// reload button) — never bare copy telling the reader to wait (BUG-479).
const questionSlot = rectificationQuestionSlotState({
questionKind: currentQuestion?.kind ?? null,
questionPrompt: currentQuestion?.prompt ?? null,
hasChoiceCard: Boolean(choiceCard),
choiceAnswered: Boolean(choiceCard && answeredQuestionIds.has(choiceCard.question_id)),
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-slot recovery: the turn already refetched once on completion;
// while the slot 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.
const questionRetrying = questionSlot === "preparing";
useVisibilityAwarePoll({
enabled: questionRetrying,
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();
}
function submitChoice(key: ChoiceKey) {
if (!choiceCard) return;
void submitStructuredChoice(CHOICE_ACTION, key);
@@ -1079,6 +1236,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
declaredTime={declaredTime}
expanded={boardOpen}
boardId={boardId}
onOpen={toggleBoard}
@@ -1088,7 +1246,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return (
<div
ref={workspace}
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}${candidateResult ? "" : " is-board-empty"}`}
>
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
@@ -1104,6 +1262,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{pendingConsultationQuestion.trim()}
</p>
)}
{conversationState === "empty" && (
<div className="rectification-empty-state">
<p>{RECTIFICATION_EMPTY_COPY}</p>
<Button type="button" onClick={startOpeningManually}>{RECTIFICATION_EMPTY_ACTION_LABEL}</Button>
</div>
)}
{messages.map((message) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
@@ -1188,34 +1352,39 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
);
})}
<section className="rectification-question-slot" aria-label="当前问题">
{showLiveChoiceCard && choiceCard && (
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}`}
card={choiceCard}
pending={busy}
disabled={readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{collectSpokenPrompt && !showLiveChoiceCard && !readonly && (
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
{collectSpokenPrompt}
</p>
)}
{showMissingQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
{showUnavailableQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
</section>
{/* The question slot is the last entry of the transcript, indented like
every assistant entry, never a widget hanging below the cards. */}
<div className="rectification-message-wrap rectification-message-entry">
<section className="rectification-question-slot" aria-label="当前问题">
{showLiveChoiceCard && choiceCard && (
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}`}
card={choiceCard}
pending={busy}
disabled={readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{collectSpokenPrompt && !showLiveChoiceCard && !readonly && (
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
{collectSpokenPrompt}
</p>
)}
{questionSlot === "preparing" && (
<ConsultationTimelineLiveRow id="question-preparing" label={RECTIFICATION_QUESTION_PREPARING_LABEL} />
)}
{questionSlot === "unavailable" && (
<div className="rectification-question-slot__recover" role="status">
<p className="rectification-question-slot__status">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
<Button type="button" variant="secondary" onClick={reloadQuestion}>
{RECTIFICATION_QUESTION_RELOAD_LABEL}
</Button>
</div>
)}
</section>
</div>
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
{savedTime}
@@ -1292,6 +1461,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
<RectificationBoard
result={candidateResult}
declaredTime={declaredTime}
savedStatus={savedStatus}
diff={boardDiff}
compact={compactBoard}
@@ -17,6 +17,7 @@ import {
windowScanLayerLabel,
type WindowScanLayer,
} from "@/lib/rectification-agentic/v9/refinement-packet";
import { rectificationBoardEmptyCopy } from "@/lib/rectification-surface-state";
import { RectificationHouseTableView } from "./rectification-house-table";
import { ConfirmationGateDisclosure, TechniqueAuditDisclosure } from "./technique-audit-disclosure";
@@ -35,6 +36,7 @@ function LayerChips({ layers }: Readonly<{ layers: readonly WindowScanLayer[] }>
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({
<header className="rectification-board__header">
<div className="rectification-board__title-row">
<h2 id={titleId}></h2>
{workingTime ? <span className="rectification-board__clock">{workingTime}</span> : null}
{workingTime
? <span className="rectification-board__clock">{workingTime}</span>
: emptyCopy.clock
? <span className="rectification-board__clock is-declared">{emptyCopy.clock}</span>
: null}
{compact && (
<button
className="rectification-board__close"
@@ -86,9 +94,9 @@ function RectificationBoardBody({
<div className="rectification-board__body">
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">{diff.liveMessage}</p>
{!table && (
<p className="rectification-board__empty">
</p>
<div className="rectification-board__empty">
{emptyCopy.lines.map((line) => <p key={line}>{line}</p>)}
</div>
)}
{table && (
<div className="rectification-snapshot">
@@ -194,6 +202,7 @@ function RectificationBoardBody({
export function RectificationBoard({
result,
declaredTime,
savedStatus,
diff,
compact,
@@ -203,6 +212,7 @@ export function RectificationBoard({
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
declaredTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
diff: RectificationBoardDiff;
compact: boolean;
@@ -237,6 +247,7 @@ export function RectificationBoard({
>
<RectificationBoardBody
result={result}
declaredTime={declaredTime}
savedStatus={savedStatus}
diff={diff}
compact={compact}
@@ -258,16 +269,18 @@ export function RectificationBoard({
export function RectificationBoardPeek({
result,
declaredTime = null,
expanded,
boardId,
onOpen,
}: Readonly<{
result: RectificationCandidateResult | null;
declaredTime?: string | null;
expanded: boolean;
boardId: string;
onOpen: () => void;
}>) {
const peek = rectificationBoardPeekCopy(result);
const peek = rectificationBoardPeekCopy(result, declaredTime);
return (
<button
className="rectification-board-peek"
@@ -1,6 +1,8 @@
"use client";
import { Check } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { InlineSpinner } from "@/components/inline-spinner";
import type {
ChoiceKey,
RectificationChoiceCard as ChoiceCard,
@@ -43,6 +45,14 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
className={`rectification-choice-card${props.pending ? " is-pending" : ""}${answered ? " is-answered" : ""}`}
aria-label={props.card.scoring ? "校正判断" : "盘外核对"}
>
{props.pending && answered ? (
// The tap is being recorded: the same live-row shape as a timeline step,
// so the card itself says something is happening (BUG-482).
<p className="rectification-choice-card__pending" role="status">
<InlineSpinner size={12} />
<span className="agent-activity-status__text"></span>
</p>
) : null}
<fieldset
className="birth-time-choice-question"
disabled={props.pending || props.disabled || answered}
@@ -61,6 +71,12 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
onClick={() => select(option.key)}
>
<strong>{option.key}.</strong> {option.label}
{selectedKey === option.key ? (
<span className="rectification-choice-card__selected">
<Check aria-hidden="true" />
</span>
) : null}
</button>
))}
<button
@@ -70,6 +86,12 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
onClick={stop}
>
{props.card.stop_label}
{selectedKey === "stop" ? (
<span className="rectification-choice-card__selected">
<Check aria-hidden="true" />
</span>
) : null}
</button>
</div>
</fieldset>
@@ -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 (BUG-479). */
readonly opening?: boolean;
readonly disabled: boolean;
readonly menuOpen: boolean;
readonly onMenuOpenChange: (open: boolean) => void;
@@ -53,6 +56,7 @@ type SidebarSessionRowProps = {
export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRowProps>(function SidebarSessionRow({
session,
active,
opening = false,
disabled,
menuOpen,
onMenuOpenChange,
@@ -83,12 +87,14 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
type="button"
isActive={active}
aria-current={active ? "page" : undefined}
aria-busy={opening ? true : undefined}
disabled={disabled}
onClick={onSelect}
>
<span className="session-title">
{session.pinned ? <Star aria-label="已收藏" /> : null}
<span className="truncate">{session.title}</span>
{opening ? <span className="session-opening-note">{RECTIFICATION_SIDEBAR_OPENING_NOTE}</span> : null}
</span>
</SidebarMenuButton>
<Menu.Trigger
+6 -1
View File
@@ -96,7 +96,12 @@ export function StarterHome({
<span className="product-entrypoint-action" aria-hidden="true">{natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
<article
className="birth-rectification-card product-entrypoint-card"
aria-labelledby="birth-rectification-title"
aria-busy={rectificationLoading ? true : undefined}
data-opening={rectificationLoading ? "true" : undefined}
>
<button
className="product-entrypoint-hitarea"
type="button"
+31 -22
View File
@@ -27,6 +27,14 @@ import {
} from "@/lib/rectification-entry";
import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import {
hydrateRectificationCase,
parsePersistedRectificationTurns,
RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE,
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
rectificationCaseHref,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
export type RectificationSurfaceParams = {
account: Account | null;
@@ -58,6 +66,8 @@ export type RectificationSurfaceParams = {
setRectificationReadonly: Dispatch<SetStateAction<boolean>>;
setRectificationSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationShouldStartOpening: Dispatch<SetStateAction<boolean>>;
setRectificationSnapshot: Dispatch<SetStateAction<RectificationCaseSnapshotPayload | null>>;
setRectificationOpeningSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationTurns: Dispatch<SetStateAction<PersistedRectificationTurn[]>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
uiPreview: MutableRefObject<boolean>;
@@ -98,6 +108,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setRectificationReadonly,
setRectificationSessionId,
setRectificationShouldStartOpening,
setRectificationSnapshot,
setRectificationOpeningSessionId,
setRectificationTurns,
setSessions,
uiPreview,
@@ -121,27 +133,10 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
async function refreshRectificationCase(caseId: string, sessionId: string) {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
);
const response = await fetch(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
if (!response.ok) return;
const payload = await response.json().catch(() => null);
const turns = Array.isArray(payload?.turns) ? payload.turns : [];
setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown }) => ({
id: String(turn?.id ?? ""),
role: turn?.role === "user" ? "user" as const : "assistant" as const,
text: typeof turn?.text === "string" ? turn.text : null,
status: String(turn?.status ?? "completed"),
receipt: turn?.receipt && typeof turn.receipt === "object" ? {
status: String((turn.receipt as { status?: unknown }).status ?? ""),
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
} : null,
})));
setRectificationTurns(parsePersistedRectificationTurns(payload?.turns));
} catch {
// History refresh is best-effort; the stream restores live turns.
}
@@ -168,8 +163,12 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
return null;
}
// Read the selection source now: `selectSession` resets it synchronously
// right after handing off, and the URL write below happens after awaits.
const selectionSource = sessionSelectionSource.current;
rectificationOpenInFlight.current = true;
setRectificationLoading(true);
setRectificationOpeningSessionId(exactSessionId);
setRectificationError("");
try {
@@ -201,6 +200,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
return null;
}
// One reveal: turns and snapshot are read before the surface mounts, so
// the reader never sees an empty transcript or an empty question slot
// while they load. Past the deadline the surface opens with what arrived
// and its own retry path fills in the rest (BUG-479).
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
});
// Merge the server-created session into the local list. The browser
// never generates a Case id; it only mirrors the returned binding.
const existing = sessions.find((session) => session.id === opened.sessionId);
@@ -228,19 +235,20 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setRectificationTurns(hydration.turns);
setRectificationSnapshot(hydration.snapshot);
setRectificationSessionId(opened.sessionId);
setRectificationCaseId(opened.caseId);
setRectificationShouldStartOpening(opened.shouldStartOpening);
setRectificationReadonly(
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
);
setRectificationTurns([]);
activeSessionIdRef.current = opened.sessionId;
setActiveSessionId(opened.sessionId);
if (!uiPreview.current && sessionSelectionSource.current === "user") {
if (!uiPreview.current && selectionSource === "user") {
writeSessionUrl(opened.sessionId, "push");
}
void refreshRectificationCase(opened.caseId, opened.sessionId);
if (!hydration.complete) setComposerNotice(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE);
void refreshRectificationEntrySummary();
return opened;
} catch {
@@ -249,6 +257,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
} finally {
sessionSelectionSource.current = "user";
rectificationOpenInFlight.current = false;
setRectificationOpeningSessionId(null);
setRectificationLoading(false);
}
}
+10 -3
View File
@@ -320,7 +320,14 @@ export function useSessionManagement(params: SessionManagementParams) {
function selectSession(sessionId: string) {
const nextSession = sessions.find((session) => session.id === sessionId);
setActiveSessionId(sessionId);
// A rectification session that is not the open one is not switched to
// here: the surface hook opens the Case, hydrates turns and snapshot, and
// only then makes it active and writes the URL, so the current view stays
// put instead of flashing a plain transcript and then an empty panel
// (BUG-479). It reads the selection source before we reset it below.
const deferredRectificationSwitch = nextSession?.sessionType === "birth_time_rectification"
&& nextSession.id !== rectificationSessionId;
if (!deferredRectificationSwitch) setActiveSessionId(sessionId);
setDraft("");
setDraftEntrypoint(null);
setComposerNotice("");
@@ -333,7 +340,7 @@ export function useSessionManagement(params: SessionManagementParams) {
}
if (nextSession?.sessionType === "birth_time_rectification") {
setRectificationError("");
if (nextSession.id !== rectificationSessionId) {
if (deferredRectificationSwitch) {
// The exact sessionId is passed to the server; the server resolves
// the exact Case and never switches to another rectification record.
void openRectificationSession(nextSession.id);
@@ -341,7 +348,7 @@ export function useSessionManagement(params: SessionManagementParams) {
} else {
void ensureSessionMessages(sessionId);
}
if (!uiPreview.current && sessionSelectionSource.current === "user") {
if (!deferredRectificationSwitch && !uiPreview.current && sessionSelectionSource.current === "user") {
writeSessionUrl(sessionId, "push");
}
sessionSelectionSource.current = "user";
@@ -160,13 +160,18 @@ export function diffRectificationBoard(
};
}
export function rectificationBoardPeekCopy(result: RectificationCandidateResult | null): Readonly<{
export function rectificationBoardPeekCopy(
result: RectificationCandidateResult | null,
declaredTime: string | null = null,
): Readonly<{
title: string;
detail: string | null;
}> {
const table = result ? workingRectificationHouseTable(result) : null;
if (!table) {
return { title: "当前盘面", detail: "补充经历后会在这里更新" };
// Before any candidate exists the board still has a time to show: the one
// the reader declared (BUG-483). Without one, say what fills it later.
return { title: "当前盘面", detail: declaredTime ? `填报 ${declaredTime}` : "补充经历后会在这里更新" };
}
return { title: "当前盘面", detail: `${table.time} · 上升${table.lagna}` };
}
@@ -0,0 +1,243 @@
/**
* Pure state for the rectification chat surface: what the transcript, the
* question slot and the entry card show in each phase, and how a Case is
* hydrated (turns + snapshot) before the surface is revealed.
*
* Nothing here touches React. The chat component and the surface hook call
* these so the "is there anything to wait for, and what does the reader see
* while waiting" decisions are testable without a DOM.
*/
import type { PersistedRectificationTurn } from "../components/conversational-birth-time-rectification.tsx";
/**
* Upper bound on hydrating a Case (turns + snapshot) after `/cases/open`
* returns and before the surface is revealed. Past it the surface opens with
* whatever arrived and the question slot's retry path fills the rest. Shared
* with the home reveal budget: keep it equal to the unified-loading constant.
*/
export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = 4_000;
/** Static entry copy while `/cases/open` and hydration are in flight. No spinner. */
export const RECTIFICATION_OPENING_LABEL = "正在打开…";
export const RECTIFICATION_SIDEBAR_OPENING_NOTE = "打开中";
/** Question-slot recovery: one immediate refetch after a turn, then this many timed retries. */
export const RECTIFICATION_QUESTION_RETRY_LIMIT = 2;
export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "重新加载";
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
export const RECTIFICATION_STOPPED_NOTICE = "已停止,已生成的内容保留;本次不会扣点。";
export const RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE = "校正点数不足,正在前往兑换…";
export const RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS = 600;
/** Live-row labels by the action that started the turn (task 1.4). */
export const RECTIFICATION_OPENING_LIVE_LABEL = "正在读取你的出生资料,准备第一个问题…";
export const RECTIFICATION_MESSAGE_LIVE_LABEL = "正在处理…";
export function rectificationInitialLiveLabel(
action: "opening" | "message" | "read_only",
continuationLabel?: string,
): string {
if (action === "opening") return RECTIFICATION_OPENING_LIVE_LABEL;
if (action === "read_only" && continuationLabel) return continuationLabel;
return RECTIFICATION_MESSAGE_LIVE_LABEL;
}
export type RectificationCaseSnapshotPayload = Readonly<{
latest_result?: unknown;
current_question?: unknown;
choice_card?: unknown;
case?: Readonly<{
status?: unknown;
accepted_time?: unknown;
confirmed_time?: unknown;
}>;
turns?: unknown;
}>;
export function isRectificationCaseSnapshotPayload(value: unknown): value is RectificationCaseSnapshotPayload {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
/** The Case API's `turns` rows, normalised to what the chat renders. */
export function parsePersistedRectificationTurns(value: unknown): PersistedRectificationTurn[] {
if (!Array.isArray(value)) return [];
return value.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown }) => ({
id: String(turn?.id ?? ""),
role: turn?.role === "user" ? "user" as const : "assistant" as const,
text: typeof turn?.text === "string" ? turn.text : null,
status: String(turn?.status ?? "completed"),
receipt: turn?.receipt && typeof turn.receipt === "object" ? {
status: String((turn.receipt as { status?: unknown }).status ?? ""),
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
} : null,
}));
}
export type RectificationCaseHydration = Readonly<{
turns: PersistedRectificationTurn[];
snapshot: RectificationCaseSnapshotPayload | null;
/** False when the request failed or the deadline passed first. */
complete: boolean;
}>;
export function rectificationCaseHref(caseId: string, sessionId: string): string {
return `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`;
}
/**
* Turns and snapshot come from one Case read, so hydration is a single request
* raced against the reveal deadline. A late or failed read still resolves, with
* empty turns and no snapshot, so the caller can reveal and let the surface's
* own retry path fill in the rest.
*/
export async function hydrateRectificationCase(
caseId: string,
sessionId: string,
options: Readonly<{
timeoutMs?: number;
fetchImpl?: typeof fetch;
setTimeoutImpl?: (callback: () => void, delayMs: number) => unknown;
clearTimeoutImpl?: (handle: unknown) => void;
}> = {},
): Promise<RectificationCaseHydration> {
const timeoutMs = options.timeoutMs ?? RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS;
const fetchImpl = options.fetchImpl ?? fetch;
const schedule = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const cancel = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
const incomplete: RectificationCaseHydration = { turns: [], snapshot: null, complete: false };
let deadline: unknown;
const timeout = new Promise<RectificationCaseHydration>((resolve) => {
deadline = schedule(() => resolve(incomplete), timeoutMs);
});
const read = (async (): Promise<RectificationCaseHydration> => {
try {
const response = await fetchImpl(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
if (!response.ok) return incomplete;
const payload: unknown = await response.json().catch(() => null);
if (!isRectificationCaseSnapshotPayload(payload)) return incomplete;
return {
turns: parsePersistedRectificationTurns(payload.turns),
snapshot: payload,
complete: true,
};
} catch {
return incomplete;
}
})();
try {
return await Promise.race([read, timeout]);
} finally {
cancel(deadline);
}
}
export type RectificationConversationState =
| "readonly"
| "busy"
| "opening"
| "empty"
| "conversation";
/**
* What the transcript area is doing when there are no rendered turns. `opening`
* means the first turn is about to be requested (server said so); `empty` means
* the Case has nothing to show and no automatic first turn the reader needs a
* way to start.
*/
export function rectificationConversationState(input: Readonly<{
messageCount: number;
busy: boolean;
readonly: boolean;
shouldStartOpening: boolean;
openingStarted: boolean;
}>): RectificationConversationState {
if (input.readonly) return "readonly";
if (input.busy) return "busy";
if (input.messageCount > 0) return "conversation";
if (input.shouldStartOpening || input.openingStarted) return "opening";
return "empty";
}
export type RectificationQuestionSlotState =
| "choice-live"
| "spoken-prompt"
| "preparing"
| "unavailable"
| "idle";
export type RectificationQuestionSlotInput = Readonly<{
questionKind: "choice" | "collect_spoken" | null;
questionPrompt: string | null;
hasChoiceCard: boolean;
choiceAnswered: boolean;
busy: boolean;
readonly: boolean;
regenerating: boolean;
snapshotLoaded: boolean;
resumableCase: boolean;
retryAttempts: number;
retryLimit?: number;
}>;
/**
* The four visible states of the question slot. A gap between a settled turn
* and its next question is `preparing` while retries remain, and
* `unavailable` with a manual reload once they run out. There is no
* "waiting for the snapshot" state: the snapshot arrives with the reveal.
*/
export function rectificationQuestionSlotState(input: RectificationQuestionSlotInput): RectificationQuestionSlotState {
const limit = input.retryLimit ?? RECTIFICATION_QUESTION_RETRY_LIMIT;
if (input.readonly || input.busy || input.regenerating) return "idle";
if (input.questionKind === "choice" && input.hasChoiceCard && !input.choiceAnswered) return "choice-live";
if (input.questionKind === "collect_spoken" && input.questionPrompt) return "spoken-prompt";
if (!input.snapshotLoaded || !input.resumableCase) return "idle";
// An answered card that is still the current question means the next one
// has not arrived yet: that is a gap too.
const gap = input.questionKind === null
|| (input.questionKind === "choice" && (!input.hasChoiceCard || input.choiceAnswered))
|| (input.questionKind === "collect_spoken" && !input.questionPrompt);
if (!gap) return "idle";
return input.retryAttempts < limit ? "preparing" : "unavailable";
}
/** Whether the question slot should run another timed snapshot refetch. */
export function rectificationQuestionRetryActive(state: RectificationQuestionSlotState): boolean {
return state === "preparing";
}
/** Adoption live-row copy: the minute is shown so the reader knows what is being applied. */
export function rectificationAdoptingLabel(time: string): string {
return `正在采用 ${time}`;
}
/** Board copy when no candidate result exists yet (task 1.3). */
export function rectificationBoardEmptyCopy(declaredTime: string | null): Readonly<{
clock: string | null;
lines: readonly string[];
}> {
if (!declaredTime) {
return { clock: null, lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"] };
}
return {
clock: declaredTime,
lines: [`填报出生时间 ${declaredTime}`, "回答几个问题后,这里会显示宫位随时间的变化。"],
};
}
/** The declared birth minute from the profile, or null when only a period was given. */
export function declaredBirthTime(profile: Readonly<{ time?: string; reportedTime?: string }>): string | null {
const candidate = (profile.reportedTime || profile.time || "").trim();
return /^\d{1,2}:\d{2}$/.test(candidate) ? candidate : null;
}
@@ -82,9 +82,20 @@ test("persisted rectification turns hydrate after the async Case refresh", () =>
assert.doesNotMatch(chat, /spoken-answer/);
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
assert.match(chat, /text: raw,/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
// Former lock: key=`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`.
// That suffix remounted the whole surface when the first turns arrived — an empty panel, then a
// flash, then a second mount (BUG-479). Turns are hydrated before the reveal and later arrive as a
// prop update; the key is the session/Case binding only.
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.doesNotMatch(page, /"ready" : "loading"/);
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
// Former lock read this parser out of the page surface (`use-rectification-surface.ts`). The
// turn parser moved to `rectification-surface-state.ts` so the hook's hydration and the chat's
// late-snapshot fill share one (BUG-479); the shape it produces is unchanged.
const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
assert.match(surfaceState, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
assert.match(page, /parsePersistedRectificationTurns\(payload\?\.turns\)/);
assert.match(component, /methods\?: readonly string\[\]/);
});
@@ -191,7 +202,13 @@ test("persisted turns survive remounts; duplicate openings are suppressed by the
assert.match(chat, /initialTurns/);
assert.match(chat, /const openingStarted = useRef\(false\)/);
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
// Former lock: key=`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`.
// That suffix remounted the whole surface when the first turns arrived — an empty panel, then a
// flash, then a second mount (BUG-479). Turns are hydrated before the reveal and later arrive as a
// prop update; the key is the session/Case binding only.
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.doesNotMatch(page, /"ready" : "loading"/);
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
assert.match(page, /initialTurns=\{rectificationTurns\}/);
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
@@ -218,10 +235,16 @@ test("candidate acceptance is non-billable, mutually exclusive, and continues th
assert.match(chat, /resultId: candidateResult\.resultId/);
assert.match(chat, /if \(!candidateResult \|\| acceptingCandidateId \|\| busy \|\| readonly\) return;/);
assert.match(chat, /setAcceptingCandidateId\(candidateId\);[\s\S]*setPending\(true\);/);
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*choiceContinuationPending\.current = true;/);
// Former locks: `await loadCaseSnapshot(); … choiceContinuationPending.current = true;`, the
// `if (!choiceContinuationPending.current || busy || readonly) return; … void send("read_only", "")`
// effect, and a doesNotMatch on `await loadCaseSnapshot(); … await send("read_only", "")`. That
// effect hop dropped the live row for a frame and let the next card flash in (BUG-480); the
// follow-up turn now continues in the same async chain on the same row, still through the
// durable case endpoint first.
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
assert.match(chat, /finally \{[\s\S]*setAcceptingCandidateId\(null\);[\s\S]*setPending\(false\);/);
assert.match(chat, /if \(!choiceContinuationPending\.current \|\| busy \|\| readonly\) return;[\s\S]*void send\("read_only", ""\);/);
assert.doesNotMatch(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", ""\);/);
assert.doesNotMatch(chat, /choiceContinuationPending/);
assert.match(chat, /rectificationAdoptingLabel\(candidateTime\)/);
assert.doesNotMatch(chat, /action: "accept_candidate"/);
const acceptRoute = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url),
@@ -501,7 +524,9 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
assert.match(styles, /\.chat-header-rectification \.rectification-board-peek \{[\s\S]*width: auto/);
assert.doesNotMatch(chat, /rectification-workspace__board-trigger/);
assert.doesNotMatch(chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("<form className=\"composer\"")), /RectificationBoardPeek/);
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}`\}/);
// Former lock: the workspace class expression ended at ` is-board-open`. The board now also
// carries `is-board-empty` before any candidate exists, so the empty board takes less width (BUG-483).
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}\$\{candidateResult \? "" : " is-board-empty"\}`\}/);
});
test("rectification composer can stop a live agent run", () => {
@@ -554,7 +579,11 @@ test("time-selection cards use server adoption state and stay mutually exclusive
assert.match(choiceCardComponent, /selectedKey/);
assert.match(choiceCardComponent, /is-answered/);
assert.match(chat, /showLiveChoiceCard = Boolean\(/);
assert.match(chat, /isStructuredChoiceUserText/);
// Former lock: `isStructuredChoiceUserText` in the chat — it filtered the reader's tapped choice out
// of the persisted transcript, so after a refresh nobody could see what they had answered (BUG-482).
// The tap is now echoed as the reader's line, live and persisted alike.
assert.doesNotMatch(chat, /isStructuredChoiceUserText/);
assert.match(chat, /userVisibleChoiceLine\(answeredCard, optionId\)/);
assert.match(chat, /submitStructuredChoice\(CHOICE_ACTION/);
assert.match(chat, /submitStructuredChoice\(STOP_ACTION/);
assert.match(route, /answer_choice/);
@@ -1120,7 +1120,10 @@ test("the public agent route treats structured choice as a non-model command", (
assert.match(chat, /isPersistedFocusId\(focusId\)/);
assert.match(chat, /focusId,/);
assert.match(chat, /shouldContinueAfterStructuredChoice\(payload\?\.nextAction, payload\)/);
assert.match(chat, /send\("read_only", ""\)/);
// Former lock: `send("read_only", "")` with no third argument. The follow-up turn now reuses the
// live row the tap put in place (`{ reuseAssistantRenderKey, label }`) so nothing disappears
// between the tap and the next question (BUG-480).
assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel \}\)/);
assert.match(chat, /willContinue/);
assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/);
// Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow
@@ -131,11 +131,15 @@ test("collect_spoken current_question renders the prompt in the question slot",
test("missing current_question is explicit only for resumable cases", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/);
// Former locks: `const showMissingQuestion = Boolean(caseSnapshotLoadedresumableCasecurrentQuestion === null`
// and the copy 「当前没有可回答的问题,正在等待服务端更新」. That copy told the reader to wait with no
// action and no end (BUG-479); the gap is now a pure slot state — `preparing` with timed refetches,
// then `unavailable` with a reload — still only for resumable cases with a loaded snapshot.
assert.match(chat, /const questionSlot = rectificationQuestionSlotState\(\{[\s\S]*snapshotLoaded: caseSnapshotLoaded,[\s\S]*resumableCase,/);
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
assert.doesNotMatch(chat, /等待服务端更新/);
assert.match(chat, /readonly && \(/);
assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
assert.doesNotMatch(chat, /questionSlot[\s\S]*caseStatus.*TERMINAL/);
});
test("choice_card still renders through the existing choice-card branch", () => {
@@ -0,0 +1,132 @@
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 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-479: 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\);/);
// 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\)\);/);
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(initialTurns\.length === 0\) return;/);
// 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("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, /aria-busy=\{rectificationLoading \? true : undefined\}/);
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.
const spinnerUses = chat.match(/InlineSpinner/g) ?? [];
assert.equal(spinnerUses.length, 0);
assert.match(timeline, /export function ConsultationTimelineLiveRow/);
assert.match(chat, /<ConsultationTimelineLiveRow id="question-preparing" label=\{RECTIFICATION_QUESTION_PREPARING_LABEL\} \/>/);
});
test("the question slot has four states, sits in the transcript flow, and never tells the reader to wait for the server", () => {
assert.doesNotMatch(chat, /等待服务端更新/);
assert.doesNotMatch(chat, /showMissingQuestion|showUnavailableQuestion/);
assert.match(chat, /const questionSlot = rectificationQuestionSlotState\(\{/);
assert.match(chat, /questionSlot === "preparing" && \(/);
assert.match(chat, /questionSlot === "unavailable" && \(/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionRetrying,\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: wrapped like every other entry, after the message loop, before the saved-time line.
const slotIndex = chat.indexOf('<div className="rectification-message-wrap rectification-message-entry">\n <section className="rectification-question-slot"');
assert.ok(slotIndex > chat.indexOf("{messages.map((message) => {"));
assert.ok(slotIndex < chat.indexOf("{savedTime &&"));
});
test("a tapped choice or an adopted candidate keeps one live row through the follow-up turn", () => {
// BUG-480: 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, /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*choiceContinuationPending/);
});
test("an empty Case with no automatic opening offers a way to start", () => {
// BUG-481
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; the live-row label follows the action", () => {
assert.match(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\);/);
assert.match(chat, /const initialLabel = rectificationInitialLiveLabel\(action, continuation\?\.label\);/);
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 tap is echoed as the reader's line and the card confirms the selection", () => {
// BUG-482
assert.match(chat, /\{ role: "user", text: echoText, renderKey: echoRenderKey, state: "settled" \}/);
assert.match(chat, /typeof payload\?\.userMessage === "string"/);
assert.doesNotMatch(chat, /isStructuredChoiceUserText/);
assert.match(choiceCard, /<Check aria-hidden="true" \/>/);
assert.match(choiceCard, /已选择/);
assert.match(choiceCard, /rectification-choice-card__pending/);
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
assert.match(styles, /\.rectification-choice-card__selected \{/);
assert.match(styles, /\.rectification-choice-card__pending \{/);
});
test("the board's first state shows the declared minute and takes less width", () => {
// BUG-483
assert.match(board, /declaredTime: string \| null;/);
assert.match(board, /rectificationBoardEmptyCopy\(declaredTime\)/);
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime\)/);
assert.doesNotMatch(board, /补充经历后,这里会显示当前本命宫位和换升时刻/);
assert.match(chat, /\$\{candidateResult \? "" : " is-board-empty"\}/);
assert.match(styles, /\.rectification-workspace\.is-board-empty \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
// The empty modifier is declared before `.is-compact`, so the single-column layout still wins.
assert.ok(styles.indexOf(".rectification-workspace.is-board-empty {") < styles.indexOf(".rectification-workspace.is-compact {"));
});
@@ -0,0 +1,160 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
declaredBirthTime,
hydrateRectificationCase,
parsePersistedRectificationTurns,
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationAdoptingLabel,
rectificationBoardEmptyCopy,
rectificationConversationState,
rectificationInitialLiveLabel,
rectificationQuestionRetryActive,
rectificationQuestionSlotState,
type RectificationQuestionSlotInput,
} from "../src/lib/rectification-surface-state.ts";
const slotBase: RectificationQuestionSlotInput = {
questionKind: null,
questionPrompt: null,
hasChoiceCard: false,
choiceAnswered: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
};
test("conversation state: readonly and busy win, then opening, then empty", () => {
const base = { messageCount: 0, busy: false, readonly: false, shouldStartOpening: false, openingStarted: false };
assert.equal(rectificationConversationState({ ...base, readonly: true }), "readonly");
assert.equal(rectificationConversationState({ ...base, busy: true }), "busy");
assert.equal(rectificationConversationState({ ...base, messageCount: 3 }), "conversation");
assert.equal(rectificationConversationState({ ...base, shouldStartOpening: true }), "opening");
assert.equal(rectificationConversationState({ ...base, openingStarted: true }), "opening");
// A resumed Case with no turns and no server-started opening: the reader needs a way to begin (BUG-481).
assert.equal(rectificationConversationState(base), "empty");
});
test("question slot: a live choice card and a spoken prompt render as themselves", () => {
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: true }), "choice-live");
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: true, choiceAnswered: true }), "preparing");
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "collect_spoken", questionPrompt: "哪年上的大学?" }), "spoken-prompt");
});
test("question slot: a gap is preparing while retries remain, then unavailable with a reload", () => {
assert.equal(rectificationQuestionSlotState(slotBase), "preparing");
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: false }), "preparing");
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "collect_spoken", questionPrompt: null }), "preparing");
assert.equal(rectificationQuestionSlotState({ ...slotBase, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
assert.equal(rectificationQuestionSlotState({ ...slotBase, retryAttempts: 5 }), "unavailable");
assert.equal(rectificationQuestionRetryActive("preparing"), true);
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
assert.equal(rectificationQuestionRetryActive("choice-live"), false);
});
test("question slot: nothing is shown while busy, readonly, regenerating, or for non-resumable cases", () => {
assert.equal(rectificationQuestionSlotState({ ...slotBase, busy: true }), "idle");
assert.equal(rectificationQuestionSlotState({ ...slotBase, readonly: true }), "idle");
assert.equal(rectificationQuestionSlotState({ ...slotBase, regenerating: true }), "idle");
assert.equal(rectificationQuestionSlotState({ ...slotBase, resumableCase: false }), "idle");
// No snapshot at all (hydration failed and no retry has answered yet): nothing to say about a question.
assert.equal(rectificationQuestionSlotState({ ...slotBase, snapshotLoaded: false }), "idle");
// Readonly hides even a live card.
assert.equal(rectificationQuestionSlotState({ ...slotBase, readonly: true, questionKind: "choice", hasChoiceCard: true }), "idle");
});
test("live-row labels follow the action that started the turn", () => {
assert.equal(rectificationInitialLiveLabel("opening"), "正在读取你的出生资料,准备第一个问题…");
assert.equal(rectificationInitialLiveLabel("message"), "正在处理…");
assert.equal(rectificationInitialLiveLabel("read_only", "正在记录本次选择…"), "正在记录本次选择…");
assert.equal(rectificationInitialLiveLabel("read_only"), "正在处理…");
assert.equal(rectificationAdoptingLabel("04:53"), "正在采用 04:53…");
});
test("board copy before any candidate shows the declared minute, never invented data", () => {
assert.deepEqual(rectificationBoardEmptyCopy("05:10"), {
clock: "05:10",
lines: ["填报出生时间 05:10", "回答几个问题后,这里会显示宫位随时间的变化。"],
});
assert.equal(rectificationBoardEmptyCopy(null).clock, null);
assert.equal(rectificationBoardEmptyCopy(null).lines.length, 1);
assert.equal(declaredBirthTime({ reportedTime: "05:10", time: "05:10" }), "05:10");
assert.equal(declaredBirthTime({ reportedTime: "", time: "7:05" }), "7:05");
assert.equal(declaredBirthTime({ reportedTime: "", time: "" }), null);
assert.equal(declaredBirthTime({ time: "凌晨" }), null);
});
test("persisted turns parse with receipts and tolerate junk", () => {
const turns = parsePersistedRectificationTurns([
{ id: "t1", role: "user", text: "2014年毕业", status: "completed" },
{ id: "t2", role: "assistant", text: "记下了", status: "completed", receipt: { status: "ok", phases: ["a"], tools: ["x"], methods: ["d9-navamsa"], skill_name: "s", skill_version: "1" } },
{ id: 3, role: "other", text: 5 },
]);
assert.equal(turns.length, 3);
assert.equal(turns[0]?.role, "user");
assert.equal(turns[1]?.receipt?.methods?.[0], "d9-navamsa");
assert.equal(turns[2]?.role, "assistant");
assert.equal(turns[2]?.text, null);
assert.deepEqual(parsePersistedRectificationTurns(undefined), []);
});
function fakeResponse(body: unknown, ok = true) {
return { ok, json: async () => body } as unknown as Response;
}
test("hydration reads turns and snapshot from one Case read", async () => {
const calls: string[] = [];
const result = await hydrateRectificationCase("case-1", "session-1", {
timeoutMs: 1_000,
fetchImpl: async (input) => {
calls.push(String(input));
return fakeResponse({
turns: [{ id: "t1", role: "assistant", text: "你好", status: "completed" }],
current_question: { kind: "choice", prompt: "哪一个?" },
case: { status: "collecting", accepted_time: null, confirmed_time: null },
});
},
});
assert.equal(calls.length, 1);
assert.match(calls[0] ?? "", /\/api\/rectification\/cases\/case-1\?sessionId=session-1$/);
assert.equal(result.complete, true);
assert.equal(result.turns.length, 1);
assert.equal((result.snapshot?.current_question as { kind?: string })?.kind, "choice");
});
test("hydration resolves incomplete, not rejected, on a failed read or a passed deadline", async () => {
const failed = await hydrateRectificationCase("case-1", "session-1", {
timeoutMs: 1_000,
fetchImpl: async () => fakeResponse({ error: "nope" }, false),
});
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false });
const thrown = await hydrateRectificationCase("case-1", "session-1", {
timeoutMs: 1_000,
fetchImpl: async () => { throw new Error("network"); },
});
assert.equal(thrown.complete, false);
let fire: (() => void) | undefined;
const late = hydrateRectificationCase("case-1", "session-1", {
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
fetchImpl: () => new Promise(() => {}),
setTimeoutImpl: (callback, delayMs) => {
assert.equal(delayMs, RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS);
fire = callback;
return 1;
},
clearTimeoutImpl: () => {},
});
assert.ok(fire);
fire?.();
const timedOut = await late;
assert.equal(timedOut.complete, false);
assert.deepEqual(timedOut.turns, []);
assert.equal(RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, 4_000);
});