From ca921c3f2a9c648ab684b713d1d50e3eec2f61a5 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 9 Sep 2026 18:05:56 +0800 Subject: [PATCH] fix(rectification): keep collect chips, timeline, and adopt cards aligned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move 没有/记不清 under the spoken question, inset the timeline to the assistant column, and keep the three-column card after 更像这个 with the closing line under it (BUG-618–620). Co-authored-by: Cursor --- CHANGELOG.md | 13 ++- docs/BUG_HISTORY.md | 50 +++++++- .../rectification-scenarios-20260907.md | 5 +- frontend/DESIGN.md | 8 +- frontend/docs/VOICE.md | 4 +- frontend/src/app/globals.css | 41 ++++--- .../components/rectification-agentic-chat.tsx | 107 ++++++++++++------ .../rectification-range-delivery.tsx | 2 +- .../rectification-agentic/v9/turn-question.ts | 33 ++++++ .../class-name-definition-contract.test.ts | 1 - ...fication-adopt-cards-stay-20260909.test.ts | 53 +++++++++ .../tests/rectification-agentic-entry.test.ts | 8 +- ...ctification-candidate-offer-anchor.test.ts | 37 ++++++ ...ication-probe-replay-loss-20260908.test.ts | 4 +- .../rectification-spoken-collect.test.ts | 30 +++-- .../rectification-timeline-20260909.test.ts | 4 + 16 files changed, 329 insertions(+), 71 deletions(-) create mode 100644 frontend/tests/rectification-adopt-cards-stay-20260909.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c84754ed..261de770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,21 @@ # 印度占星 Skill 更新日志 +## 2026-09-09 — 点「更像这个」后三列对照卡留在原处 + +生时校正点「更像这个」之后,三列对照卡不再整张消失。被点的列先显示「正在采用…」,成功后变成「已采用」,其它列仍可改选。下面那句「已采用 HH:MM,新建对话即按这个时间排盘。」跟卡片同一条左边线,不再单独贴到屏幕左边。Skill 版本不变。 + +## 2026-09-09 — 生时校正时间轴收到和对话同一列 + +常驻时间轴的读数和刻度改跟助手气泡、候选卡同一条左边线:桌面让出 `space-8` 和头像列,窄屏让出 `space-4` 和头像列。条的背景和底边仍拉满聊天列,高度不变。Skill 版本不变。 + +## 2026-09-09 — 采集题「没有 / 记不清」改到问题下面 + +口述采集时,「没有」「记不清」两个按钮改挂在同一条助手气泡的问题下方,不再浮在输入框上面。点选语义不变:没有仍跳过这方面,记不清仍先放着。Skill 版本不变。 + ## 2026-09-09 — 首页「深入看今日」算完会写出三节回答,思考区不再漏英文词渣 从首页点「深入看今日」时,按「时运」计算,不再被改成「综合」。正文按三节写:今日趋势、适合推进 / 需要避开、一个行动。某一节没写出字会再试一次;分段写作时不能再调排盘工具。思考区按整句过滤,不会再出现缺词英文或 `run-jyotish-` 残段。空回答仍然不扣点。Skill 版本不变。 - ## 2026-09-09 — 婚恋激活句按心动 / 成对 / 领证出,网页咨询接上当前大小运 网页咨询路径现在用当前大运/小运喂给婚恋三层,不再拿出生时大运空转。没有领证命中时,激活句只写观察、并标明不等于领证;没有事件类命中时不再写「关键期」。小运缺失时大运名不再拼 `Unknown`。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0cb1177d..65285451 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9394,7 +9394,7 @@ - 修复:composer-wrap 内两个 44px 次要按钮,类名 `rectification-collect-reply`,不复用 `rectification-step-state`。「没有」与打字「没有」同一 POST,走 declined,回执「记下了,这方面先跳过。」「记不清」走 skipped,回执「记下了,这题先放着。」精确这两句不调模型。skipped 领域本会话不再采集;采用后核对仍可碰到 skipped,不碰 declined。 - 验证:`frontend/tests/rectification-spoken-collect.test.ts`(按钮只在 collect_spoken、精确「没有 / 记不清」短路分类器、skipped 不再采集、reverse-verify 仍可碰)。 - 防复发:采集快捷回答不得画成「先这样」,不得用 `rectification-step-state`。不得在回答条预填年份。 -- 相关记录:BUG-546 +- 相关记录:BUG-546、BUG-618 - 复发自:无 - 修复版本:`aa7ccb30` @@ -9525,3 +9525,51 @@ - 相关记录:BUG-612(同一次事故的空回答)、`TASK-consultation-daily-empty-answer-20260909.md` - 复发自:无 - 修复版本:待发布 + +## BUG-618 | 采集题「没有 / 记不清」浮在输入框上方,离问题太远 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-09 +- 影响面:`rectification-agentic-chat.tsx` 口述采集快捷回答、`globals.css` `.rectification-collect-replies` +- 用户现象:口述采集时,「没有」「记不清」两个按钮贴在底部输入框上方,和问题气泡分开,看起来像多余的条。 +- 触发条件:`collect_spoken` 焦点激活且未 busy。 +- 根因:BUG-605 把快捷回答挂在 `composer-wrap` 输入框上方,没有跟选择题卡一样嵌进同一条助手气泡。 +- 修复:两个按钮改到问题 `afterAnswer` 里,紧挨题干;composer 不再渲染它们。点选仍走原来的「没有 / 记不清」短路。 +- 验证:`frontend/tests/rectification-spoken-collect.test.ts`(按钮在 afterAnswer,不在 composer-wrap)。 +- 防复发:采集快捷回答不得回到 composer。选择题卡继续用嵌入卡,口述采集用 `rectification-collect-reply`。 +- 相关记录:BUG-605 +- 复发自:无 +- 修复版本:待发布 + +## BUG-619 | 生时校正时间轴比对话内容更贴边 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-09 +- 影响面:`globals.css` `.rectification-timeline` 水平内边距 +- 用户现象:校正对话上方的时间轴比下面的候选卡、助手气泡更靠屏幕外边,左右对不齐。 +- 触发条件:打开生时校正,尤其是已出现三列候选卡时。 +- 根因:条用 `space-5` / 窄屏 `space-4`,对话列是 `space-8` / `space-4` 再加头像让位 `--assistant-content-inset`;条又不在 `.message-list` 里,继承不到该变量。 +- 修复:条的内边距改为对话沟槽加头像让位;背景和底边发丝线仍拉满。高度不变。 +- 验证:`frontend/tests/rectification-timeline-20260909.test.ts`。 +- 防复发:时间轴水平内边距必须含 `--assistant-content-inset`,不得写回贴边的 `space-5`。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待发布 + +## BUG-620 | 点「更像这个」后三列卡消失,收尾句贴左不对齐 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-09 +- 影响面:`rectification-agentic-chat.tsx` 区间交付卡、`verified_idle` 收尾句、`globals.css` `.rectification-pending-note` +- 用户现象:点「更像这个」后三列时间卡忽然消失,底下出现未对齐的「已采用 HH:MM,新建对话即按这个时间排盘。」 +- 触发条件:交付卡出现后点其中一列「更像这个」;尤其是没有后续核对题、`next_user_action` 为 `start_consultation` 时。 +- 根因:`showSelectionCards` 用 `!busy` 整张卸卡;采用请求把对话标成 busy。收尾句用未定义样式的 `.rectification-pending-note` 直接挂在消息列表里,没有助手列让位。采用后若 live offer 锚点被清掉,卡也不再回到原消息。 +- 修复:采用中或已采用时 busy 不再卸卡;用 resultId 锁住首次出示卡片的那条消息。收尾句改到卡片下方,样式与卡片同一条左边线。已采用列按钮保持按下并禁用。不恢复输入框上方状态条。 +- 验证:`frontend/tests/rectification-adopt-cards-stay-20260909.test.ts`、`frontend/tests/rectification-candidate-offer-anchor.test.ts`。 +- 防复发:交付卡不得因采用 busy 卸载;`verified_idle` 不得使用无 inset 的裸段落;不得把采用状态写回 composer。 +- 相关记录:BUG-595、BUG-619 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index 45cc9e63..a7b5c2eb 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -85,7 +85,8 @@ 期望: - 交付轮只见一张卡:范围、对照了 N 件经历、至多三列并排(相对可能性、性格处事、经历对照、往后 12 个月、「更像这个」)、底部边界句 -- 点「更像这个」即采用;已采用列按钮显示「已采用」,其它列仍可点改选 +- 点「更像这个」即采用;卡片留在原处,被点的列显示「正在采用…」再变成「已采用」,其它列仍可点改选 +- 采用后的收尾句跟卡片同一列,不要单独贴左,也不要出现在输入框上方 - 卡片下方「查看验证报告」默认收起,打开后是真正的表格 - 助手正文只有三句(范围、经历与吻合率、边界句),不含「排盘用」「方法1」或 Technique Audit - 不见「相对支持度」「当前推荐」「采用此时间」「按这个范围用」「再补一件经历」「都不像」「说不好」 @@ -166,6 +167,6 @@ - 这一条消息只触发一次候选比较,账本写入三件 - 下一问不再是已经说过的那三个领域 - 只报一件时也只比较一次,下一问是既有逐领域采集,正文没有「还有吗 / 还能想起别的吗」;第一道逐领域题干末尾可以有一次「想到别的也可以一起说」 -- 口述采集输入框上方有「没有」「记不清」,没有「先这样」 +- 口述采集问题下方有「没有」「记不清」,没有「先这样」,输入框上方不再挂这两个按钮 - 每轮助手正文一句复述(可另接一句范围变化);气泡里没有「本轮对照了…」,也没有「很有帮助 / 很有价值 / 很有分量 / 特别有用」 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ecb9a865..8eefe836 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -229,7 +229,7 @@ The birth-time rectification session is the consultation transcript plus a house | `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card | | `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled | | `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled | -| `verified_idle` | one closing line `postAdoptVerifyDone` after adopted reverse-verify is exhausted; no spinner, no reload | enabled | +| `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled | | `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible | | `candidates` | one range-delivery card of up to three compare columns (highest posterior first; “更像这个” adopts) under the offering message; a closed “查看验证报告” fold | enabled | | `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible | @@ -252,7 +252,7 @@ The birth-time rectification session is the consultation transcript plus a house - **Life-event evidence:** after deterministic questionnaire completion, render three structured event rows by default and allow up to six. Each row uses a domain select, a precision select, and a matching year/month/day control; free-form descriptions are not part of scoring. - **Candidate result:** keep the reported range, candidate interval, and active-time status visually separate. Delivery shows one range card: title (range + event count), up to three compare columns (time, relative likelihood, D9/D10/nakshatra traits, event-fit counts, next-12-month windows, “更像这个”), and the representative-minute boundary. An eight-method report sits in a closed `
` fold. Support numbers stay on the house board. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels a column as the true birth time. - **Evidence accessibility:** every row keeps visible labels, validation errors use live regions, add/remove controls retain 44px targets, and scoring/confirmation loading states disable duplicate submission without hiding the existing evidence. -- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year. Spoken collect, when the focus is live and the turn is not busy, shows two 44px secondary buttons above the input, class `rectification-collect-reply` (not `rectification-step-state`): 「没有」 posts the same declined path as typing 没有; 「记不清」 posts skipped. Stop on spoken collect is not a composer button: `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) stays on choice cards, and generating turns keep “停止回答”. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The method sentence (`vargaSentence`) lives in the expanded activity timeline, not in the spoken bubble. The composer has no `rectification-step-state` status sentence and no `rectification-composer-meta`. +- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year. Spoken collect, when the focus is live and the turn is not busy, shows two 44px secondary buttons under the spoken question in the same assistant bubble, class `rectification-collect-reply` (not `rectification-step-state`): 「没有」 posts the same declined path as typing 没有; 「记不清」 posts skipped. The composer never hosts those chips. Stop on spoken collect is not a composer button: `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) stays on choice cards, and generating turns keep “停止回答”. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The method sentence (`vargaSentence`) lives in the expanded activity timeline, not in the spoken bubble. The composer has no `rectification-step-state` status sentence and no `rectification-composer-meta`. - **Draft review:** natural-language answers become one inline review card. The evidence domain is read-only and uses its Chinese label; precision controls which exact year, month, or day input is available. Incomplete drafts keep edit and skip paths visible, while confirmation is disabled until the structured date is valid. Status and errors use polite or assertive live regions without clearing the persisted journey. - **Scoring and retry:** `score_pending` is a quiet progress surface with cancellable bounded polling and no manual compare control. `retry_scoring` preserves the confirmed evidence and exposes one explicit retry action. Refresh and device changes resume from the persisted action rather than inferring progress from copy. - **Guided candidate states:** low confidence presents the saved candidate range and either another evidence question or a safe finish; medium confidence can save the range but never apply a representative minute; high confidence names both “候选时间” and “当前排盘使用时间” before explicit confirmation; ready states that the current chart time changed while the original report remains preserved. No state calls a candidate the true birth minute. @@ -475,7 +475,7 @@ or user IDs. Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。 -校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下;采集题还没答完时不出卡。卡上至多三列并排,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用后前事核对结束走 `verified_idle`:一行收尾文案,没有 live 行、没有重载、没有采用状态条。 +校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下;采集题还没答完时不出卡。卡上至多三列并排,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。 首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。 @@ -514,6 +514,8 @@ Admin 的 antd `` 是独立设计系统,不在此表。 **二、条高固定,从首次渲染起就占位。** 这是正确性要求,不是视觉偏好:条在滚动容器外,高度一变就改 `clientHeight`,而没有任何观察者会因此重新跟随——贴底的读者会看着内容悄悄滑出视野底部且无法恢复。同理,Case 未就绪时渲染**等高的空骨架**(`data-state="pending"`),不得用 spinner、不得用「正在加载」文案、不得让条晚一步挂载。桌面 64px,767px 以下 56px;56px 等于既有的 `--rectification-jump-clearance`(44px 触控尺寸 + `space-3`),不新造常数。 +读数和轴的水平内边距对齐助手列:桌面 `space-8 + --assistant-content-inset`,767px 以下 `space-4 + --assistant-content-inset`,与 `.message-list` 沟槽和头像让位相同。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。 + **三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与区间读数(`05:07–05:09 · 3 分钟`)。以下一律不得加回来——每一条都是被明确砍掉的: | 不得出现 | 理由 | diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 8ec79964..c15086f3 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -35,7 +35,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 相对支持度 16 · 采用此时间 | 一行至多三列。每列写相对可能性、性格处事、经历对照、往后 12 个月,点「更像这个」即采用。不预标「排盘用」。 | 交付对象是区间里并排的候选分钟,不是四张卡上的支持度数字。 | | 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 每列用服务器算好的 1–2 个事件窗;没有窗就写「未来一年没有明显的窗口」。 | 窗口按候选分钟各算,模型不写。 | | 「再补一件经历」后卡片消失、输入框上方空白 | 需要补经历时继续在输入框说下一件;交付卡不再提供「再补一件经历」按钮。 | 交付卡只负责选分钟。 | -| 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 采用状态在该列按钮上。`postAdoptVerifyDone` 在没有核对题时改短句。 | +| 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 采用状态在该列按钮上;整张卡留着。收尾句跟卡片同一列,不单独贴左,也不回输入框上方。 | | (采用后只念代表分钟)代表分钟是 05:12。 | 这 27 分钟里,事业方向、性格底色的判断是稳定的;婚恋(D9)会随分钟变,看盘时按范围读。这不是已确认的唯一出生分钟。 | 采用后先讲区间里什么稳、什么随分钟变;两句来自服务端,不是模型编的。 | | 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 | | 好的,记下了。 | 记下了:2016 年 9 月入学、2020 年 6 月毕业。 | 只复述年-月和事件短语,不评价价值。 | @@ -45,7 +45,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 生成中输入框变灰,回车没反应 | 生成中仍可打字;回车后显示「已排队,回答结束后发出」,可撤回 | 对标主流对话产品,焦点不丢。 | | 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 | | 也可以再说一件你记得大概时间的事。 | 能分开候选的问题已经问完,先按现有材料给你结果。 / 还差带月份的经历,领域不限。 | 采集问完后交付或说清门槛,不再开放采集。 | -| 口述采集没有停止按钮,范围小字不可点 | 选择题卡内有「先这样,先看当前范围」;生成中有「停止回答」。口述采集输入框上方是「没有」「记不清」两个 44px 次要按钮,不是先这样。范围小字是状态句,不可点。 | 没有=拒答,记不清=本会话先放着。 | +| 口述采集没有停止按钮,范围小字不可点 | 选择题卡内有「先这样,先看当前范围」;生成中有「停止回答」。口述采集问题下方是「没有」「记不清」两个 44px 次要按钮,不是先这样,也不挂在输入框上方。范围小字是状态句,不可点。 | 没有=拒答,记不清=本会话先放着。 | | 只报一件后追问「还有吗,还能想起别的吗」 | 下一问仍是既有逐领域采集。第一道题干末尾由服务端加一次「想到别的也可以一起说。」 | 邀请不是要求;不插追问轮,也不重复开场。 | | 点选后旁白写「04:31–04:39 这段领先,05:00–05:07 这段落后」 | 已记录,范围收到 04:31–04:39。 | 旁白只说范围变没变。领先/落后留在卡内「答了会怎样」,不进气泡。 | | 每轮只有技法清单,不知道现在第几步 | 输入框上方有「第 N 步 · 原因 · 下一步」。 | 用户始终看得见该做什么。 | diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 17b362c0..daff8b5d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1518,18 +1518,6 @@ button:disabled { cursor: default; opacity: .45; } .composer-queue__recall:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; } .composer-wrap { position: sticky; bottom: 0; z-index: 2; min-width: 0; border-top: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: var(--space-3) var(--space-6) var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); } -.rectification-collect-replies { - width: min(760px, 100%); - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin: 0 auto var(--space-2); -} -.rectification-collect-reply { - min-height: 44px; - min-width: 44px; - padding: 0 var(--space-4); -} /* Jump to latest: shared by both chat surfaces. Hangs off the composer wrap's top edge so it never reflows the transcript or the composer. */ .jump-to-latest { @@ -2698,6 +2686,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class The pending state keeps the same box for the same reason. */ .rectification-workspace__chat { --rectification-timeline-height: 64px; + /* Same token as `.message-list`: avatar 32px + gap. The bar sits outside + the transcript, so it cannot inherit that custom property from there. */ + --assistant-content-inset: calc(32px + var(--space-3)); } .rectification-timeline { height: var(--rectification-timeline-height); @@ -2706,7 +2697,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class flex-direction: column; justify-content: center; gap: var(--space-1); - padding: 0 var(--space-5); + /* Match the assistant column (message-list gutter + avatar inset), not the + chat-panel edge. The full-width background and hairline stay on the bar. */ + padding: 0 calc(var(--space-8) + var(--assistant-content-inset)); background: var(--color-canvas); border-bottom: 1px solid var(--color-border); overflow: hidden; @@ -2804,7 +2797,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class --rectification-timeline-height: 56px; } .rectification-timeline { - padding-inline: var(--space-4); + padding-inline: calc(var(--space-4) + var(--assistant-content-inset)); } } .rectification-workspace__chat .conversation { @@ -3089,6 +3082,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-weight: 600; line-height: 1.55; } +.rectification-collect-replies { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.rectification-collect-reply { + min-height: 44px; + min-width: 44px; + width: auto; + flex: 0 0 auto; + padding: 0 var(--space-4); +} /* The gap between a settled turn and its next question, as the last entry of the transcript: a live timeline row while refetching, then copy and a reload. */ .rectification-question-gap { @@ -3382,6 +3387,14 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class text-align: left; font-size: var(--type-caption); } +.rectification-pending-note { + width: calc(100% - var(--assistant-content-inset)); + margin: var(--space-2) 0 var(--space-4); + margin-inline-start: var(--assistant-content-inset); + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + line-height: 1.55; +} .rectification-saved { width: calc(100% - var(--assistant-content-inset)); margin: 8px 0 16px; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index fa9e20d9..47245119 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -107,9 +107,12 @@ import { applyLiveCandidateOffer, copyTextForMessage, interviewQuestionBlocksAdoptOffer, + nextSelectionCardLock, parseTurnQuestion, persistedOfferFromTurn, questionIsAnswered, + resolveSelectionCardMessageKey, + type SelectionCardLock, type TurnQuestion, } from "@/lib/rectification-agentic/v9/turn-question"; import { Button } from "@/components/ui/button"; @@ -184,6 +187,33 @@ function RectificationReadonlyRange({ ); } +function CollectSpokenReplies({ + onChoose, +}: Readonly<{ + onChoose: (text: "没有" | "记不清") => void; +}>) { + return ( +
+ + +
+ ); +} + type RectificationAgenticChatProps = Readonly<{ caseId: string; @@ -489,6 +519,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const stepStartedAtRef = useRef(0); const liveToolRef = useRef(null); const liveBaseLabelRef = useRef("正在处理…"); + const selectionOfferLockRef = useRef(null); const [compactBoard, setCompactBoard] = useState(false); const [boardOpen, setBoardOpen] = useState(false); const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null)); @@ -1425,12 +1456,29 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { stage: caseStage, }); const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey; - const selectionCardMessageKey = persistedOfferKey + const liveSelectionCardKey = persistedOfferKey ?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined); + selectionOfferLockRef.current = nextSelectionCardLock(selectionOfferLockRef.current, { + resultId: candidateResult?.resultId, + key: liveSelectionCardKey, + }); + const selectionCardMessageKey = resolveSelectionCardMessageKey({ + persistedOfferKey, + fallbackKey: latestSettledAssistant?.renderKey, + locked: selectionOfferLockRef.current, + resultId: candidateResult?.resultId, + selectedTime: candidateResult?.selectedTime, + canOffer: canOfferCards, + }); + // Adopt already has in-card “正在采用…” / “已采用”; hiding the whole card + // for `busy` is what made the three columns vanish on 「更像这个」. + const keepSelectionCardsWhileBusy = Boolean( + acceptingCandidateId || candidateResult?.selectedTime, + ); const showSelectionCards = Boolean( candidateResult && caseSnapshotLoaded - && !busy + && (!busy || keepSelectionCardsWhileBusy) && selectionCardMessageKey && (canOfferCards || Boolean(candidateResult.selectedTime)) && !messages.some((message) => ( @@ -1472,6 +1520,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { retryAttempts: questionRetryAttempts, retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT, }); + const verifiedIdleCopy = questionGap === "verified_idle" + ? postAdoptVerifyDoneCopy( + savedTime, + messages.some((message) => message.question?.kind === "reverse_verify"), + ) + : null; const conversationState = rectificationConversationState({ messageCount: messages.length, busy, @@ -1642,6 +1696,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onStop={submitStop} /> )} + {liveQuestion && question.kind === "collect_spoken" && ( + void send("message", text)} /> + )} {question.status === "skipped" && (

{savedTime ? `已跳过(已采用 ${savedTime})` : "已跳过"} @@ -1678,12 +1735,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { /> )} {showSelectionCards && candidateResult && message.renderKey === selectionCardMessageKey && ( - void acceptCandidate(candidateId)} - /> + <> + void acceptCandidate(candidateId)} + /> + {verifiedIdleCopy && ( +

+ {verifiedIdleCopy} +

+ )} + )} {showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && ( )} - {questionGap === "verified_idle" && ( + {questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (

- {postAdoptVerifyDoneCopy( - savedTime, - messages.some((message) => message.question?.kind === "reverse_verify"), - )} + {verifiedIdleCopy}

)} {savedTime && savedStatus === "confirmed" && ( @@ -1733,26 +1794,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { {!conversationAnchor.anchored && ( )} - {collectSpokenPrompt && !busy && !readonly && regeneratingMessageKey === null ? ( -
- - -
- ) : null} onAccept(column.candidate_id)} > {adopting diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts index 883b253a..1c83666e 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts @@ -195,6 +195,39 @@ export function persistedOfferFromTurn( return previous; } +export type SelectionCardLock = Readonly<{ resultId: string; key: string }>; + +/** Keep the offering message once the reader has seen the card for this result. */ +export function nextSelectionCardLock( + current: SelectionCardLock | null, + input: { resultId: string | null | undefined; key: string | undefined }, +): SelectionCardLock | null { + if (!input.resultId) return null; + if (input.key) return { resultId: input.resultId, key: input.key }; + if (current?.resultId === input.resultId) return current; + return null; +} + +/** + * After adopt, do not re-anchor onto the follow-up turn. Prefer a live offer, + * then the pre-adopt fallback, then the lock from when the card first appeared. + */ +export function resolveSelectionCardMessageKey(input: { + persistedOfferKey: string | undefined; + fallbackKey: string | undefined; + locked: SelectionCardLock | null; + resultId: string | null | undefined; + selectedTime: string | null | undefined; + canOffer: boolean; +}): string | undefined { + if (!input.resultId) return undefined; + const live = input.persistedOfferKey + ?? (input.canOffer && !input.selectedTime ? input.fallbackKey : undefined); + if (live) return live; + if (input.locked?.resultId === input.resultId) return input.locked.key; + return undefined; +} + function assistantCanOwnAdoptOffer( turn: { role?: string; question?: TurnQuestion | null }, adopted: boolean, diff --git a/frontend/tests/class-name-definition-contract.test.ts b/frontend/tests/class-name-definition-contract.test.ts index f93465e4..9c0fdfa3 100644 --- a/frontend/tests/class-name-definition-contract.test.ts +++ b/frontend/tests/class-name-definition-contract.test.ts @@ -60,7 +60,6 @@ const knownUnstyled = new Set([ "is-working", "onboarding-step-card", "personal-report-additional-charts", - "rectification-pending-note", "rectification-terminal-actions", "rectification-terminal-note", "sidebar-header", diff --git a/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts b/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts new file mode 100644 index 00000000..e4592ba2 --- /dev/null +++ b/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8"); + +const chat = read("../src/components/rectification-agentic-chat.tsx"); +const delivery = read("../src/components/rectification-range-delivery.tsx"); +const styles = read("../src/app/globals.css"); +const showBlock = chat.slice( + chat.indexOf("const keepSelectionCardsWhileBusy"), + chat.indexOf("const showReadonlyRange"), +); +const messageLoop = chat.slice( + chat.indexOf("{messages.map((message) => {"), + chat.indexOf("{savedTime &&"), +); + +test("clicking 更像这个 does not unmount the range card for busy", () => { + assert.match(showBlock, /acceptingCandidateId \|\| candidateResult\?\.selectedTime/); + assert.match(showBlock, /!busy \|\| keepSelectionCardsWhileBusy/); + assert.doesNotMatch( + showBlock, + /&& !busy\s*&& selectionCardMessageKey/, + ); +}); + +test("after adopt the card stays on the locked offering message", () => { + assert.match(chat, /resolveSelectionCardMessageKey/); + assert.match(chat, /nextSelectionCardLock/); + assert.match(chat, /selectionOfferLockRef/); + assert.match(messageLoop, /showSelectionCards && candidateResult && message\.renderKey === selectionCardMessageKey/); +}); + +test("the closing line sits under the card on the assistant column", () => { + assert.match(messageLoop, /verifiedIdleCopy && \(/); + assert.match(messageLoop, /className="rectification-pending-note"/); + assert.match( + chat, + /questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards/, + ); + assert.match( + styles, + /\.rectification-pending-note \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/, + ); + assert.doesNotMatch(chat, /rectification-adopt-status/); +}); + +test("the adopted column button is pressed and disabled; others stay 更像这个", () => { + assert.match(delivery, /disabled=\{busy \|\| adopted\}/); + assert.match(delivery, /rangeDeliveryAdopted/); + assert.match(delivery, /rangeDeliveryMoreLikeThis/); +}); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 08a8409f..068f13fe 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -592,13 +592,19 @@ test("time-selection cards use server adoption state and stay mutually exclusive // 采用后 busy 把卡藏掉,新消息又把卡带走。 // 新:canShowRectificationSelectionCards(含 session_outcome 门)+ candidateOffer 所有权; // 未采用时卡跟着最新采用旁白走,采集题还在问则不出卡;已采用后不再把卡锚到最新消息。 + // 采用中 / 已采用时 busy 不得整张卸卡;收尾句跟卡片同一列。 // 保留语义:卡片仍由服务端 can_adopt / selection_allowed / receipt 授权,前端不自造候选。 assert.match(chat, /canShowRectificationSelectionCards/); assert.match(chat, /applyLiveCandidateOffer/); assert.match(chat, /persistedOfferFromTurn/); + assert.match(chat, /resolveSelectionCardMessageKey/); + assert.match(chat, /keepSelectionCardsWhileBusy/); assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/); assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/); - assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& caseSnapshotLoaded\s*&& !busy\s*&& selectionCardMessageKey/); + assert.match( + chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const collectSpokenPrompt")), + /!busy \|\| keepSelectionCardsWhileBusy/, + ); assert.doesNotMatch( chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const collectSpokenPrompt")), /offeredSelectionOnce/, diff --git a/frontend/tests/rectification-candidate-offer-anchor.test.ts b/frontend/tests/rectification-candidate-offer-anchor.test.ts index a0bfd014..b8b49163 100644 --- a/frontend/tests/rectification-candidate-offer-anchor.test.ts +++ b/frontend/tests/rectification-candidate-offer-anchor.test.ts @@ -4,7 +4,9 @@ import test from "node:test"; import { applyLiveCandidateOffer, attachOfferResultToTurns, + nextSelectionCardLock, persistedOfferFromTurn, + resolveSelectionCardMessageKey, type TurnQuestion, } from "../src/lib/rectification-agentic/v9/turn-question.ts"; @@ -131,3 +133,38 @@ test("hydrated turns drop a stale client offer when GET says this turn is not th { resultId: "result-1" }, ); }); + +test("the offer lock keeps the original card host after adopt clears the live key", () => { + const locked = nextSelectionCardLock(null, { resultId: "result-1", key: "offer" }); + assert.deepEqual(locked, { resultId: "result-1", key: "offer" }); + assert.deepEqual( + nextSelectionCardLock(locked, { resultId: "result-1", key: undefined }), + locked, + ); + assert.equal( + nextSelectionCardLock(locked, { resultId: "result-2", key: undefined }), + null, + ); + assert.equal( + resolveSelectionCardMessageKey({ + persistedOfferKey: undefined, + fallbackKey: "latest", + locked, + resultId: "result-1", + selectedTime: "04:49", + canOffer: false, + }), + "offer", + ); + assert.equal( + resolveSelectionCardMessageKey({ + persistedOfferKey: undefined, + fallbackKey: "latest", + locked, + resultId: "result-1", + selectedTime: null, + canOffer: true, + }), + "latest", + ); +}); diff --git a/frontend/tests/rectification-probe-replay-loss-20260908.test.ts b/frontend/tests/rectification-probe-replay-loss-20260908.test.ts index 6fc604b0..e45469c9 100644 --- a/frontend/tests/rectification-probe-replay-loss-20260908.test.ts +++ b/frontend/tests/rectification-probe-replay-loss-20260908.test.ts @@ -300,7 +300,7 @@ test("BUG-588 evidence narration names a widened range after compare", () => { ); }); -test("P3 selection cards stay gated by busy and a loaded snapshot", () => { +test("P3 selection cards stay gated by a loaded snapshot; adopt busy does not unmount them", () => { const chat = readFileSync( new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8", @@ -309,7 +309,7 @@ test("P3 selection cards stay gated by busy and a loaded snapshot", () => { chat.indexOf("const showSelectionCards"), chat.indexOf("const showReadonlyRange"), ); - assert.match(block, /!busy/); + assert.match(block, /!busy \|\| keepSelectionCardsWhileBusy/); assert.match(block, /caseSnapshotLoaded/); assert.match(chat, /startTransition\(/); }); diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts index b78a9042..a130ad56 100644 --- a/frontend/tests/rectification-spoken-collect.test.ts +++ b/frontend/tests/rectification-spoken-collect.test.ts @@ -116,17 +116,27 @@ test("cases current_question remains the submit contract, not a visual slot", () const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\"")); assert.doesNotMatch(wrap, /rectification-collect-stop/); assert.doesNotMatch(wrap, /CHOICE_STOP_LABEL/); - // 旧:输入框上方无按钮(先这样已删) - // 新:collect_spoken 且未 busy 时两个 44px 次要按钮「没有」「记不清」 - // 原因:BUG-605;不得复用 rectification-step-state - assert.match(wrap, /rectification-collect-replies/); - assert.match(wrap, /rectification-collect-reply/); - assert.match(wrap, /send\("message", "没有"\)/); - assert.match(wrap, /send\("message", "记不清"\)/); - assert.match(wrap, />[\s\n]*没有[\s\n]*[\s\n]*记不清[\s\n]*[\s\n]*没有[\s\n]*[\s\n]*记不清[\s\n]* { const mobile = styles.slice(styles.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {")); assert.match(mobile, /--rectification-timeline-height: 56px;/); + assert.match(mobile, /padding-inline: calc\(var\(--space-4\) \+ var\(--assistant-content-inset\)\);/); // 56px is the existing jump-clearance constant, not a new number. assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/); });