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 <cursoragent@cursor.com>
This commit is contained in:
@@ -8902,4 +8902,20 @@
|
|||||||
- 复发自:无
|
- 复发自:无
|
||||||
- 修复版本:`b466a6fc8cbb17d48c5aa242dbafd44b9a7c651b`
|
- 修复版本:`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`。
|
||||||
|
- 相关记录:无
|
||||||
|
- 复发自:无
|
||||||
|
- 修复版本:待发布
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -3083,23 +3083,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
|||||||
.rectification-choice-why-user p {
|
.rectification-choice-why-user p {
|
||||||
margin: var(--space-2) 0 0;
|
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 {
|
.rectification-composer-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin: 0 0 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 {
|
.rectification-composer-meta .rectification-collect-stop {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,8 @@ import {
|
|||||||
isRecommendedRectificationCandidate,
|
isRecommendedRectificationCandidate,
|
||||||
natalRecastMeaning,
|
natalRecastMeaning,
|
||||||
parseRectificationCandidateResult,
|
parseRectificationCandidateResult,
|
||||||
parseRectificationStepState,
|
|
||||||
workingRectificationHouseTable,
|
workingRectificationHouseTable,
|
||||||
type RectificationCandidateResult,
|
type RectificationCandidateResult,
|
||||||
type RectificationStepState,
|
|
||||||
} from "@/lib/rectification-candidate-result";
|
} from "@/lib/rectification-candidate-result";
|
||||||
import {
|
import {
|
||||||
diffRectificationBoard,
|
diffRectificationBoard,
|
||||||
@@ -435,7 +433,6 @@ type CaseSnapshotState = Readonly<{
|
|||||||
question: CurrentQuestionModel | null;
|
question: CurrentQuestionModel | null;
|
||||||
questionSource: "focus" | "unavailable" | null;
|
questionSource: "focus" | "unavailable" | null;
|
||||||
choice: ChoiceCardModel | null;
|
choice: ChoiceCardModel | null;
|
||||||
stepState: RectificationStepState | null;
|
|
||||||
caseStatus: RectificationCaseStatus | null;
|
caseStatus: RectificationCaseStatus | null;
|
||||||
savedTime: string | null;
|
savedTime: string | null;
|
||||||
savedStatus: "accepted" | "confirmed" | null;
|
savedStatus: "accepted" | "confirmed" | null;
|
||||||
@@ -457,7 +454,6 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca
|
|||||||
question: currentQuestionFromSnapshot(payload.current_question),
|
question: currentQuestionFromSnapshot(payload.current_question),
|
||||||
questionSource: questionSourceFromSnapshot(payload.question_source),
|
questionSource: questionSourceFromSnapshot(payload.question_source),
|
||||||
choice: parseRectificationChoiceCard(payload.choice_card),
|
choice: parseRectificationChoiceCard(payload.choice_card),
|
||||||
stepState: parseRectificationStepState(payload.step_state),
|
|
||||||
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
|
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
|
||||||
savedTime: confirmedTime ?? acceptedTime,
|
savedTime: confirmedTime ?? acceptedTime,
|
||||||
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
|
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 [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null);
|
||||||
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
|
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
|
||||||
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
|
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
|
||||||
const [stepState, setStepState] = useState<RectificationStepState | null>(() => caseSnapshotState(initialSnapshot)?.stepState ?? null);
|
|
||||||
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
|
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
|
||||||
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
|
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
|
||||||
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
|
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
|
||||||
@@ -672,7 +667,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
|||||||
setCurrentQuestion(nextQuestion);
|
setCurrentQuestion(nextQuestion);
|
||||||
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
|
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
|
||||||
setChoiceCard(nextChoice);
|
setChoiceCard(nextChoice);
|
||||||
setStepState(parseRectificationStepState(payload.step_state));
|
|
||||||
setCaseStatus(nextCaseStatus);
|
setCaseStatus(nextCaseStatus);
|
||||||
setNextUserActionId(nextActionId || null);
|
setNextUserActionId(nextActionId || null);
|
||||||
setCaseSnapshotLoaded(true);
|
setCaseSnapshotLoaded(true);
|
||||||
@@ -1772,13 +1766,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
|||||||
<span className="rectification-adopt-status__hint">之后新建对话即按此时间排盘。</span>
|
<span className="rectification-adopt-status__hint">之后新建对话即按此时间排盘。</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="rectification-composer-meta">
|
{currentQuestion?.kind === "collect_spoken" && !readonly && !busy && (
|
||||||
{stepState ? (
|
<div className="rectification-composer-meta">
|
||||||
<p className="rectification-step-state" role="status">
|
|
||||||
{stepState.headline} — {stepState.reason} — 下一步:{stepState.next}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{currentQuestion?.kind === "collect_spoken" && !readonly && !busy && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rectification-collect-stop"
|
className="rectification-collect-stop"
|
||||||
@@ -1786,8 +1775,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
|||||||
>
|
>
|
||||||
{CHOICE_STOP_LABEL}
|
{CHOICE_STOP_LABEL}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
<ChatComposer
|
<ChatComposer
|
||||||
inputRef={composer}
|
inputRef={composer}
|
||||||
value={draft}
|
value={draft}
|
||||||
|
|||||||
@@ -21,13 +21,8 @@ type RectificationChoiceCardProps = Readonly<{
|
|||||||
|
|
||||||
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
||||||
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "skip_probe" | "">("");
|
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "skip_probe" | "">("");
|
||||||
const [hoveredKey, setHoveredKey] = useState<ChoiceKey | "">("");
|
|
||||||
const selectedKey = props.selectedKey || localSelected;
|
const selectedKey = props.selectedKey || localSelected;
|
||||||
const answered = Boolean(selectedKey);
|
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) {
|
function select(key: ChoiceKey) {
|
||||||
if (props.pending || props.disabled || selectedKey) return;
|
if (props.pending || props.disabled || selectedKey) return;
|
||||||
@@ -78,10 +73,6 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="birth-time-choice-option is-primary"
|
className="birth-time-choice-option is-primary"
|
||||||
data-selected={selectedKey === option.key ? "true" : "false"}
|
data-selected={selectedKey === option.key ? "true" : "false"}
|
||||||
onMouseEnter={() => setHoveredKey(option.key)}
|
|
||||||
onMouseLeave={() => setHoveredKey("")}
|
|
||||||
onFocus={() => setHoveredKey(option.key)}
|
|
||||||
onBlur={() => setHoveredKey("")}
|
|
||||||
onClick={() => select(option.key)}
|
onClick={() => select(option.key)}
|
||||||
>
|
>
|
||||||
<strong>{option.key}.</strong> {option.label}
|
<strong>{option.key}.</strong> {option.label}
|
||||||
@@ -98,9 +89,6 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{impactLine ? (
|
|
||||||
<p className="rectification-choice-impact" role="status">{impactLine}</p>
|
|
||||||
) : null}
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,10 +55,11 @@ test("step state parser rejects forbidden certainty words", () => {
|
|||||||
assert.doesNotMatch(blob, /概率|置信度|确定/);
|
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 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");
|
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(chat, /rectification-composer-meta/);
|
||||||
assert.match(route, /step_state: stepStateFromCaseDossier/);
|
assert.match(route, /step_state: stepStateFromCaseDossier/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, /data-selected=\{selectedKey === option\.key \? "true" : "false"\}/);
|
||||||
assert.match(choiceCard, /rectification-choice-card__pending/);
|
assert.match(choiceCard, /rectification-choice-card__pending/);
|
||||||
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
|
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.doesNotMatch(styles, /\.rectification-choice-card__selected/);
|
||||||
assert.match(styles, /\.rectification-choice-card__pending \{/);
|
assert.match(styles, /\.rectification-choice-card__pending \{/);
|
||||||
// BUG-518: scoring cards do not render 先这样,先看当前范围; reverse_verify still has 这题跳过.
|
// BUG-518: scoring cards do not render 先这样,先看当前范围; reverse_verify still has 这题跳过.
|
||||||
|
|||||||
@@ -235,9 +235,9 @@ test("shouldStartOpening is server-owned: only freshly created never-started cas
|
|||||||
assert.equal(shouldStartOpening("readonly", 0), false);
|
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 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");
|
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||||
assert.match(route, /step_state: stepStateFromCaseDossier\(dossier\)/);
|
assert.match(route, /step_state: stepStateFromCaseDossier\(dossier\)/);
|
||||||
assert.match(chat, /rectification-step-state/);
|
assert.doesNotMatch(chat, /rectification-step-state/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user