fix(web): keep rectification stages aligned and stop jump overlay covering choices
Independent Staging Quality Gate / validate (push) Failing after 11m44s
Independent Staging Quality Gate / publish (push) Has been skipped

Progress and spoken text shared a 24px consultation-report gap, and the jump chip covered option D. Put D-chart names back on the activity strip from evidence rescore methods.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-25 19:31:14 +08:00
co-authored by Cursor
parent a658ef8d2b
commit 7a4360d848
13 changed files with 183 additions and 13 deletions
+16
View File
@@ -5547,6 +5547,22 @@
- 复发自:BUG-379(邻近年学业存在性探针已跳过,但仍用整窗观察和剩余 D24 题干继续问入学/高考)
- 修复版本:待发布
## BUG-381 | 生时纠正进度条与点选卡重叠、分盘名消失
- 状态:resolved
- 首次发现:2026-08-25
- 最近更新:2026-08-25
- 影响面:生时纠正聊天气泡、阶段进度、点选卡、「回到最新」
- 用户现象:有工具进度的回复看起来比上一句更往里缩;阶段和正文之间空一大截;进度条只剩「读取校正记录 / 整理证据」,看不到 D 盘对照;点选卡最下面的 D 和「先这样」被「回到最新」挡住。
- 触发条件:本轮有公开工具进度;证据写入触发了重算但比较工具没单独出场;用户略微离开底部看点选卡。
- 根因:(1) 有进度又有正文时套了咨询页 `consultation-thinking-report`,格子间距 24px,进度条自己还有下边距。(2) 已落盘回合不回放工具进度,最新一轮有勾选列、上一轮没有,看起来像缩进。(3) 证据批处理把 `executed_methods` 放在 `rescore` 里,公开活动只读顶层字段,技法句又写在口语后面。(4) 「回到最新」贴在输入框上方居中,离开底部 96px 就出现,正好盖住点选卡下沿。
- 修复:进度和正文改用 8px 紧凑叠放。已落盘回执回放工具步骤。证据重算的分盘名并进进度条,不写进口语。点选卡仍在视口下沿时不显示「回到最新」,按钮改到末行外侧。不改 Skill `10.0.11`
- 验证:`frontend/tests/chat-stream-layout.test.ts``frontend/tests/rectification-agentic-entry.test.ts``frontend/tests/rectification-answer-choice.test.ts``frontend/tests/rectification-v9-stream.test.ts``frontend/tests/agent-activity-progress.test.ts`
- 防复发:有进度的纠正气泡不得用咨询页 24px 报告间距。技法句属于进度条,不得再进 `answer.delta`。点选卡仍可见时不得用居中浮层挡住 D / 「先这样」。
- 相关记录:BUG-373、BUG-376、BUG-377
- 复发自:BUG-376(关掉思考正文后,进度条只剩工具名,技法句不再出现;回到最新仍按咨询页浮层)
- 修复版本:待发布
## BUG-379 | 生时纠正已记入学后仍编造高考年并再问入学
- 状态:resolved
+26 -1
View File
@@ -333,6 +333,17 @@ button:disabled { cursor: default; opacity: .45; }
}
.agent-activity-status + .message-answer,
.agent-thinking-panel + .message-answer { margin-top: var(--space-2); }
.message-stage-and-answer {
display: grid;
gap: var(--space-2);
}
.message-stage-and-answer .agent-thinking-panel,
.message-stage-and-answer .agent-activity-status {
margin-bottom: 0;
}
.message-stage-and-answer .consultation-report-analysis .message-answer {
margin-top: 0;
}
.composer textarea::placeholder { color: var(--color-ink-tertiary); }
.composer button svg { width: 19px; height: 19px; }
.dialog-close svg { width: 19px; height: 19px; }
@@ -2366,11 +2377,24 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
grid-template-rows: minmax(0, 1fr) auto;
}
.rectification-workspace__chat .conversation {
--rectification-jump-clearance: calc(44px + var(--space-3));
padding-bottom: var(--space-4);
}
.rectification-workspace__chat .message-list {
width: 100%;
max-width: 720px;
padding-bottom: var(--rectification-jump-clearance);
}
.conversation.is-rectification .message-assistant .agent-thinking-step {
grid-template-columns: 14px minmax(0, 1fr);
gap: 6px;
}
.conversation.is-rectification .message-assistant .agent-thinking-marker {
width: 14px;
height: 14px;
}
.conversation.is-rectification .message-actions {
margin-inline-start: var(--assistant-content-inset);
}
.rectification-workspace__chat .composer-wrap {
position: relative;
@@ -2382,8 +2406,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
bottom: 100%;
z-index: 3;
display: flex;
justify-content: center;
justify-content: flex-end;
padding-bottom: var(--space-2);
padding-inline-end: var(--space-4);
pointer-events: none;
}
.rectification-jump-latest button {
@@ -66,6 +66,17 @@ function MessageThinkingTrace({
);
}
function VargaTraceStep({ sentence }: Readonly<{ sentence: string }>) {
return (
<li className="agent-thinking-step is-done">
<span className="agent-thinking-marker" aria-hidden="true">
<Check />
</span>
<span>{sentence}</span>
</li>
);
}
export function AgentActivityStatus({
state,
label = labels[state],
@@ -73,6 +84,7 @@ export function AgentActivityStatus({
completedTrail,
thinkingText,
activityTrace,
vargaSentence,
hasAnswer = false,
showLive = true,
}: Readonly<{
@@ -82,12 +94,15 @@ export function AgentActivityStatus({
completedTrail?: string;
thinkingText?: string;
activityTrace?: readonly AgentActivityTraceItem[];
vargaSentence?: string | null;
hasAnswer?: boolean;
showLive?: boolean;
}>) {
const completedSteps = activityCompletedSteps(completedTrail);
const live = showLive && !hasAnswer;
const trace = activityTrace ?? [];
const varga = vargaSentence?.trim() ?? "";
const showVarga = Boolean(varga) && !trace.some((item) => item.label === varga);
if (trace.length > 0) {
return (
<div className="agent-thinking-panel agent-activity-status">
@@ -100,15 +115,16 @@ export function AgentActivityStatus({
hasAnswer={hasAnswer}
/>
))}
{showVarga ? <VargaTraceStep sentence={varga} /> : null}
</ol>
</div>
);
}
if (!live && completedSteps.length === 0 && !thinkingText?.trim()) return null;
if (!live && completedSteps.length === 0 && !thinkingText?.trim() && !showVarga) return null;
return (
<div className="agent-thinking-panel agent-activity-status">
{(live || completedSteps.length > 0) && (
{(live || completedSteps.length > 0 || showVarga) && (
<ol className="agent-thinking-timeline">
{completedSteps.map((step) => (
<li className="agent-thinking-step is-done" key={step}>
@@ -129,6 +145,7 @@ export function AgentActivityStatus({
</span>
</li>
) : null}
{showVarga ? <VargaTraceStep sentence={varga} /> : null}
</ol>
)}
{thinkingText ? <MessageThinkingTrace text={thinkingText} hasAnswer={hasAnswer} /> : null}
+3 -2
View File
@@ -104,6 +104,7 @@ export function ChatMessageRow({
completedTrail={message.activity?.completedTrail}
thinkingText={hasTrace ? undefined : message.thinkingText}
activityTrace={message.activityTrace}
vargaSentence={vargaSentence}
hasAnswer={hasAnswer}
showLive={showLiveActivity}
/>
@@ -114,7 +115,7 @@ export function ChatMessageRow({
<ChatMessageContent
text={message.text}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={vargaSentence}
vargaSentence={showThinkingPanel ? null : vargaSentence}
/>
)
: null;
@@ -157,7 +158,7 @@ export function ChatMessageRow({
/>
)}
{stackedThinkingAndAnswer ? (
<div className="consultation-thinking-report">
<div className="message-stage-and-answer">
{thinkingPanel}
<section className="consultation-report-analysis" aria-label="回复">
{spokenAnswer}
@@ -17,6 +17,7 @@ import {
RECTIFICATION_ACTIVITY_PROGRESS_LABELS,
RECTIFICATION_TOOL_DONE_LABELS,
RECTIFICATION_TOOL_PROGRESS_LABELS,
activityTraceFromReceipt,
rectificationCompletedTrail,
rectificationToolActivityPhase,
} from "@/lib/rectification-activity-labels";
@@ -45,7 +46,7 @@ import {
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
import { isNearBottom } from "@/lib/rectification-sticky-scroll";
import { isNearBottom, shouldShowJumpToLatest } from "@/lib/rectification-sticky-scroll";
import {
CHOICE_ACTION,
STOP_ACTION,
@@ -226,12 +227,14 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
if (failed && !raw) return [];
if (isIncompleteRunBanner(raw)) return [];
const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw };
const completedReceipt = completedReceiptFromPersisted(turn.receipt);
return [{
role: "assistant",
text: split.spoken,
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
completedReceipt: completedReceiptFromPersisted(turn.receipt),
completedReceipt,
activityTrace: activityTraceFromReceipt(completedReceipt),
failed,
turnId: turn.id,
}];
@@ -291,6 +294,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const followTailRef = useRef(true);
const scrollFrameRef = useRef<number | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const choiceCardsOpen = useRef(false);
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
@@ -332,7 +336,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!viewport) return;
const nearBottom = isNearBottom(viewport.scrollHeight, viewport.scrollTop, viewport.clientHeight);
followTailRef.current = nearBottom;
setShowJumpToLatest(!nearBottom);
setShowJumpToLatest(shouldShowJumpToLatest(
viewport.scrollHeight,
viewport.scrollTop,
viewport.clientHeight,
choiceCardsOpen.current,
));
}, []);
const scrollToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
@@ -974,6 +983,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& !readonly
&& regeneratingMessageKey === null,
);
choiceCardsOpen.current = showChoiceCards;
useLayoutEffect(() => {
updateFollowState();
}, [showChoiceCards, updateFollowState]);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& offeredSelectionOnce
@@ -1051,7 +1064,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}),
}
: message;
const vargaSentence = message.state === "settled" && !message.failed
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
return (
@@ -1,5 +1,7 @@
import type { AgentActivityTraceItem } from "./agent-activity-trace.ts";
import { activityCompletedTrail } from "./chat-message-view.ts";
import type { PublicActivityPhase } from "./consultation-agent-events.ts";
import type { CompletedActivityReceiptView } from "./rectification-activity-receipt.ts";
import type {
PublicRectificationActivity,
PublicRectificationTool,
@@ -62,6 +64,19 @@ export function rectificationCompletedTrail(steps: readonly PublicRectificationT
return activityCompletedTrail(steps.map((tool) => RECTIFICATION_TOOL_DONE_LABELS[tool]));
}
export function activityTraceFromReceipt(
receipt: CompletedActivityReceiptView | null | undefined,
): readonly AgentActivityTraceItem[] {
if (!receipt?.steps.length) return [];
return receipt.steps.map((tool) => ({
id: `persisted-${tool}`,
kind: "activity" as const,
status: "done" as const,
label: RECTIFICATION_TOOL_DONE_LABELS[tool],
tool,
}));
}
export function rectificationToolActivityPhase(tool: PublicRectificationTool): PublicActivityPhase {
if (LOAD_TOOLS.has(tool)) return "loading-method";
if (COMPARE_TOOLS.has(tool)) return "chart-calculation";
@@ -106,6 +106,23 @@ export function isPublicRectificationToolName(value: unknown): value is PublicRe
return isPublicRectificationTool(value);
}
function executedMethodsFromRecord(value: unknown): PublicRectificationMethod[] {
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
const found: PublicRectificationMethod[] = [];
const add = (methods: unknown) => {
if (!Array.isArray(methods)) return;
for (const method of methods) {
if (isPublicRectificationMethod(method) && !found.includes(method)) found.push(method);
}
};
add(record.executed_methods);
if (record.rescore && typeof record.rescore === "object") {
add((record.rescore as Record<string, unknown>).executed_methods);
}
return found;
}
function resultMethods(chunk: AgentChunkType): PublicRectificationMethod[] {
if (chunk.type !== "tool-result") return [];
const payload = chunk.payload && typeof chunk.payload === "object"
@@ -113,9 +130,8 @@ function resultMethods(chunk: AgentChunkType): PublicRectificationMethod[] {
: {};
const candidates = [payload.result, payload.output, (chunk as unknown as { object?: unknown }).object];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") continue;
const methods = (candidate as Record<string, unknown>).executed_methods;
if (Array.isArray(methods)) return [...new Set(methods.filter(isPublicRectificationMethod))];
const methods = executedMethodsFromRecord(candidate);
if (methods.length > 0) return methods;
}
return [];
}
@@ -6,6 +6,8 @@
*/
export const RECTIFICATION_NEAR_BOTTOM_PX = 96;
/** Keep the jump chip hidden while a choice card still occupies the composer overlay band. */
export const RECTIFICATION_CHOICE_NEAR_BOTTOM_PX = 360;
export function distanceFromBottom(
scrollHeight: number,
@@ -24,6 +26,18 @@ export function isNearBottom(
return distanceFromBottom(scrollHeight, scrollTop, clientHeight) <= thresholdPx;
}
export function shouldShowJumpToLatest(
scrollHeight: number,
scrollTop: number,
clientHeight: number,
choiceCardOpen = false,
): boolean {
const distance = distanceFromBottom(scrollHeight, scrollTop, clientHeight);
if (distance <= RECTIFICATION_NEAR_BOTTOM_PX) return false;
if (choiceCardOpen && distance <= RECTIFICATION_CHOICE_NEAR_BOTTOM_PX) return false;
return true;
}
export function shouldFollowLatest(followTail: boolean): boolean {
return followTail === true;
}
@@ -14,6 +14,7 @@ import {
} from "../src/lib/consultation-activity-labels.ts";
import {
RECTIFICATION_TOOL_PROGRESS_LABELS,
activityTraceFromReceipt,
rectificationCompletedTrail,
rectificationToolActivityPhase,
} from "../src/lib/rectification-activity-labels.ts";
@@ -96,6 +97,17 @@ test("live rectification labels name the actual public tool", () => {
);
});
test("persisted receipts rebuild the public activity steps without thinking text", () => {
assert.deepEqual(
activityTraceFromReceipt({
steps: ["rectification-read-case", "rectification-record-evidence-batch"],
methods: ["d1-rashi", "d10-dashamsa"],
}).map((row) => `${row.kind}:${row.label}`),
["activity:读取校正记录", "activity:整理多条事件证据"],
);
assert.deepEqual(activityTraceFromReceipt({ steps: [], methods: ["d1-rashi"] }), []);
});
test("multi-domain chart calculation names the current item without domain ids", () => {
assert.equal(chartCalculationProgressLabel(1, 1), CONSULTATION_CHART_CALCULATION_LABEL);
assert.equal(chartCalculationProgressLabel(2, 3), "正在计算本命盘(第 2/3 项)…");
@@ -75,6 +75,10 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(messageRowSource, /<ChatMessageContent[\s\S]*text=\{message\.text\}[\s\S]*auditRows=\{message\.agentExecutionReceipt\?\.techniqueAuditTable\}/);
assert.match(messageRowSource, /stackedThinkingAndAnswer/);
assert.match(messageRowSource, /aria-label="回复"/);
assert.match(messageRowSource, /className="message-stage-and-answer"/);
assert.match(messageRowSource, /vargaSentence=\{showThinkingPanel \? null : vargaSentence\}/);
assert.match(globalStyles, /\.message-stage-and-answer/);
assert.match(globalStyles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
assert.match(activitySource, /className="agent-activity-status__text"/);
@@ -276,7 +276,13 @@ test("Agentic rectification follows the conversation tail only while the reader
assert.match(chat, /followLatestContent/);
assert.match(chat, /const container = conversation\.current/);
assert.match(chat, /top: viewport\.scrollHeight/);
assert.match(chat, /shouldShowJumpToLatest/);
assert.match(chat, /choiceCardsOpen/);
assert.match(chat, /updateFollowState/);
assert.match(chat, /\}, \[busy, candidateResult, choiceCard, error, messages, savedTime, followLatestContent\]\);/);
assert.match(styles, /\.rectification-jump-latest \{[\s\S]*justify-content: flex-end/);
assert.match(styles, /--rectification-jump-clearance/);
assert.match(styles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/);
assert.doesNotMatch(styles, /scrollIntoView/);
});
@@ -295,6 +301,9 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
assert.match(chat, /vargaSentenceFromMethods/);
assert.match(chat, /vargaSentence=\{vargaSentence\}/);
assert.match(chat, /activityTraceFromReceipt/);
assert.match(activityStatus, /vargaSentence/);
assert.match(activityStatus, /VargaTraceStep/);
assert.match(board, /RectificationHouseTableView/);
assert.doesNotMatch(chat, /activeActivity/);
assert.match(chat, /RECTIFICATION_TOOL_PROGRESS_LABELS/);
@@ -10,7 +10,7 @@ import {
quoteIsFromAssistantQuestion,
userVisibleChoiceLine,
} from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { isNearBottom, shouldFollowLatest } from "../src/lib/rectification-sticky-scroll.ts";
import { isNearBottom, shouldFollowLatest, shouldShowJumpToLatest } from "../src/lib/rectification-sticky-scroll.ts";
import { projectTurnDecision, TURN_DECISION_MAX_BYTES, turnDecisionByteLength } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import {
mapModelFinishToErrorCode,
@@ -131,6 +131,13 @@ test("does not force-follow when the reader has scrolled upward", () => {
assert.equal(shouldFollowLatest(true), true);
});
test("jump-to-latest stays hidden while a choice card still sits in the overlay band", () => {
assert.equal(shouldShowJumpToLatest(1_000, 100, 400), true);
assert.equal(shouldShowJumpToLatest(1_000, 520, 400), false);
assert.equal(shouldShowJumpToLatest(1_000, 500, 400, true), false);
assert.equal(shouldShowJumpToLatest(1_000, 200, 400, true), true);
});
test("choice quotes come from the option label, not the assistant question year", () => {
const question = "2016 年前后,有没有明显高考或重要考试发挥失常?";
assert.equal(quoteIsFromAssistantQuestion("2016年", question), true);
@@ -135,6 +135,27 @@ test("every public rectification tool maps its real lifecycle to public activity
assert.equal(mapStreamChunkToActivity(chunk("text-delta", { text: "x" }) as never), null);
});
test("evidence-batch rescore methods surface on the public activity event", () => {
assert.deepEqual(
mapStreamChunkToActivity(chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: {
accepted_count: 1,
rescore: {
status: "completed",
executed_methods: ["d1-rashi", "d10-dashamsa", "private-method"],
},
},
}) as never),
{
type: "tool.activity",
tool: "rectification-record-evidence-batch",
status: "completed",
methods: ["d1-rashi", "d10-dashamsa"],
},
);
});
test("reasoning, raw payloads, provider metadata and step internals never map to the answer channel", () => {
assert.equal(mapStreamChunkToPhase(chunk("reasoning-start", { id: "r1" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "内部推理" }) as never), null);