From 1ded6bb0d4c92cdcfb6521732917128eda92dd99 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 7 Sep 2026 14:07:49 +0800 Subject: [PATCH] fix(rectification): hide option hover impact and composer step strip (BUG-575) Hovering A/B/C/D rewrote a leading/lagging time line and made the card flicker; the step-state sentence above the composer was instructional filler, not something the user needed to answer. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++++++++++++++++ frontend/DESIGN.md | 2 +- frontend/src/app/globals.css | 12 ------------ .../components/rectification-agentic-chat.tsx | 19 ++++--------------- .../components/rectification-choice-card.tsx | 12 ------------ .../rectification-step-state-20260906.test.ts | 5 +++-- .../rectification-surface-contract.test.ts | 3 +++ .../tests/rectification-v9-contracts.test.ts | 4 ++-- 8 files changed, 29 insertions(+), 44 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 95a0fdc6..ab37c274 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8902,4 +8902,20 @@ - 复发自:无 - 修复版本:`b466a6fc8cbb17d48c5aa242dbafd44b9a7c651b` +## BUG-575 | 生时校正选项悬停时间解释闪动,输入框上方步骤提示是套话 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`RectificationChoiceCard`、`RectificationAgenticChat` 输入框上方步骤条 +- 用户现象:鼠标在 A/B/C/D 选项间移动时,选项下方出现「会让 … 这段领先/落后」并随悬停切换不断闪动;输入框上方还有「第 N 步·… — … — 下一步:…」套话。 +- 触发条件:生时校正出现选择题后,鼠标在选项间来回移动;或进入校正对话看到输入框。 +- 根因:选择题把 `answer_impact` 绑到 `onMouseEnter`/`onFocus`,悬停切换会改写同一行文案并撑开布局。步骤条把服务端 `step_state` 拼成输入框上方提示。 +- 修复:选项不再监听悬停、不再渲染 `answer_impact`。输入框上方不再展示步骤条;采集题的「先这样」按钮仍保留在输入框上方。服务端仍可生成 `answer_impact` 和 `step_state`,只是用户界面不再读出来。 +- 验证:`frontend/tests/rectification-surface-contract.test.ts`、`frontend/tests/rectification-step-state-20260906.test.ts`、`frontend/tests/rectification-v9-contracts.test.ts`。 +- 防复发:选择题源码不得再出现 `onMouseEnter` 或 `rectification-choice-impact`;聊天源码不得再出现 `rectification-step-state`。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待发布 + diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index b740c0aa..f9b464e1 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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, confidence, and active-time status visually separate. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels the representative minute 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 and keeps skip and pause as secondary 44px actions. Spoken collect (`collect_spoken`) places the same `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) as a 44px secondary button above `ChatComposer`. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem and show one `answer_impact` line for the hovered or selected option. A `rectification-step-state` status sentence sits above the composer, left of the spoken stop button when that button is present. +- **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 and keeps skip and pause as secondary 44px actions. Spoken collect (`collect_spoken`) places the same `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) as a 44px secondary button above `ChatComposer`. 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 composer has no `rectification-step-state` status sentence. - **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. diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ac8d038a..13a68c8b 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3083,23 +3083,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .rectification-choice-why-user p { margin: var(--space-2) 0 0; } -.rectification-choice-impact { - margin: var(--space-3) 0 0; - color: var(--color-ink-secondary); - font-size: var(--type-caption); - line-height: 1.5; -} .rectification-composer-meta { display: grid; gap: var(--space-3); margin: 0 0 var(--space-3); } -.rectification-step-state { - margin: 0; - color: var(--color-ink-secondary); - font-size: var(--type-caption); - line-height: 1.5; -} .rectification-composer-meta .rectification-collect-stop { margin: 0; } diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 06d9c6c3..5c48af04 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -37,10 +37,8 @@ import { isRecommendedRectificationCandidate, natalRecastMeaning, parseRectificationCandidateResult, - parseRectificationStepState, workingRectificationHouseTable, type RectificationCandidateResult, - type RectificationStepState, } from "@/lib/rectification-candidate-result"; import { diffRectificationBoard, @@ -435,7 +433,6 @@ type CaseSnapshotState = Readonly<{ question: CurrentQuestionModel | null; questionSource: "focus" | "unavailable" | null; choice: ChoiceCardModel | null; - stepState: RectificationStepState | null; caseStatus: RectificationCaseStatus | null; savedTime: string | null; savedStatus: "accepted" | "confirmed" | null; @@ -457,7 +454,6 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca question: currentQuestionFromSnapshot(payload.current_question), questionSource: questionSourceFromSnapshot(payload.question_source), choice: parseRectificationChoiceCard(payload.choice_card), - stepState: parseRectificationStepState(payload.step_state), caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null, savedTime: confirmedTime ?? acceptedTime, savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null, @@ -499,7 +495,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null); const [candidateResult, setCandidateResult] = useState(() => caseSnapshotState(initialSnapshot)?.candidate ?? null); const [choiceCard, setChoiceCard] = useState(() => caseSnapshotState(initialSnapshot)?.choice ?? null); - const [stepState, setStepState] = useState(() => caseSnapshotState(initialSnapshot)?.stepState ?? null); const [currentQuestion, setCurrentQuestion] = useState(() => caseSnapshotState(initialSnapshot)?.question ?? null); const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null); const [caseStatus, setCaseStatus] = useState(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null); @@ -672,7 +667,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setCurrentQuestion(nextQuestion); setQuestionSource(questionSourceFromSnapshot(payload.question_source)); setChoiceCard(nextChoice); - setStepState(parseRectificationStepState(payload.step_state)); setCaseStatus(nextCaseStatus); setNextUserActionId(nextActionId || null); setCaseSnapshotLoaded(true); @@ -1772,13 +1766,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { 之后新建对话即按此时间排盘。 )} -
- {stepState ? ( -

- {stepState.headline} — {stepState.reason} — 下一步:{stepState.next} -

- ) : null} - {currentQuestion?.kind === "collect_spoken" && !readonly && !busy && ( + {currentQuestion?.kind === "collect_spoken" && !readonly && !busy && ( +
- )} -
+
+ )} (""); - const [hoveredKey, setHoveredKey] = useState(""); const selectedKey = props.selectedKey || localSelected; const answered = Boolean(selectedKey); - const impactKey = (selectedKey === "A" || selectedKey === "B" || selectedKey === "C" || selectedKey === "D" - ? selectedKey - : hoveredKey) as ChoiceKey | ""; - const impactLine = impactKey ? props.card.answer_impact?.[impactKey] ?? "" : ""; function select(key: ChoiceKey) { if (props.pending || props.disabled || selectedKey) return; @@ -78,10 +73,6 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) { type="button" className="birth-time-choice-option is-primary" data-selected={selectedKey === option.key ? "true" : "false"} - onMouseEnter={() => setHoveredKey(option.key)} - onMouseLeave={() => setHoveredKey("")} - onFocus={() => setHoveredKey(option.key)} - onBlur={() => setHoveredKey("")} onClick={() => select(option.key)} > {option.key}. {option.label} @@ -98,9 +89,6 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) { ) : null} - {impactLine ? ( -

{impactLine}

- ) : null} ); diff --git a/frontend/tests/rectification-step-state-20260906.test.ts b/frontend/tests/rectification-step-state-20260906.test.ts index 8e9bfa74..61bf8432 100644 --- a/frontend/tests/rectification-step-state-20260906.test.ts +++ b/frontend/tests/rectification-step-state-20260906.test.ts @@ -55,10 +55,11 @@ test("step state parser rejects forbidden certainty words", () => { assert.doesNotMatch(blob, /概率|置信度|确定/); }); -test("chat renders rectification-step-state above the composer", () => { +test("chat does not render the step-state strip above the composer", () => { const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); const route = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8"); - assert.match(chat, /rectification-step-state/); + assert.doesNotMatch(chat, /rectification-step-state/); + assert.doesNotMatch(chat, /下一步:\{stepState\.next\}/); assert.match(chat, /rectification-composer-meta/); assert.match(route, /step_state: stepStateFromCaseDossier/); }); diff --git a/frontend/tests/rectification-surface-contract.test.ts b/frontend/tests/rectification-surface-contract.test.ts index 3bd88e90..39c1864f 100644 --- a/frontend/tests/rectification-surface-contract.test.ts +++ b/frontend/tests/rectification-surface-contract.test.ts @@ -136,6 +136,9 @@ test("the choice card confirms the tap and the board's first state shows the dec assert.match(choiceCard, /data-selected=\{selectedKey === option\.key \? "true" : "false"\}/); assert.match(choiceCard, /rectification-choice-card__pending/); assert.match(choiceCard, /InlineSpinner size=\{12\}/); + assert.doesNotMatch(choiceCard, /onMouseEnter/); + assert.doesNotMatch(choiceCard, /rectification-choice-impact/); + assert.doesNotMatch(choiceCard, /hoveredKey/); assert.doesNotMatch(styles, /\.rectification-choice-card__selected/); assert.match(styles, /\.rectification-choice-card__pending \{/); // BUG-518: scoring cards do not render 先这样,先看当前范围; reverse_verify still has 这题跳过. diff --git a/frontend/tests/rectification-v9-contracts.test.ts b/frontend/tests/rectification-v9-contracts.test.ts index 7e6e78c7..646df969 100644 --- a/frontend/tests/rectification-v9-contracts.test.ts +++ b/frontend/tests/rectification-v9-contracts.test.ts @@ -235,9 +235,9 @@ test("shouldStartOpening is server-owned: only freshly created never-started cas assert.equal(shouldStartOpening("readonly", 0), false); }); -test("case snapshot includes step_state", () => { +test("case snapshot includes step_state without a composer strip", () => { const route = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8"); const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(route, /step_state: stepStateFromCaseDossier\(dossier\)/); - assert.match(chat, /rectification-step-state/); + assert.doesNotMatch(chat, /rectification-step-state/); });