fix(rectification): count timeline width inclusively and keep eliminated minutes hollow (BUG-602, BUG-603)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-09 13:58:55 +08:00
co-authored by Cursor
parent d925251654
commit 92e3d5e7a3
11 changed files with 338 additions and 25 deletions
@@ -1421,6 +1421,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
searchWindow,
credibleRange: candidateResult?.credibleRange ?? null,
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
inferenceMarks: candidateResult?.inferenceMarks ?? [],
stage: caseStage,
});
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
@@ -66,6 +66,11 @@ export type RectificationNatalRecast = Readonly<{
confirmation_allowed: false;
}>;
export type RectificationInferenceMark = Readonly<{
time: string;
eliminated: boolean;
}>;
export type RectificationCandidateResult = Readonly<{
resultId: string;
candidates: readonly RectificationCandidate[];
@@ -95,6 +100,8 @@ export type RectificationCandidateResult = Readonly<{
credibleRange: readonly [string, string] | null;
rangeDelivery: RangeDeliveryProjection | null;
verificationReportMarkdown: string | null;
/** Inference-layer minutes including eliminated ones. Empty when no inference_state. */
inferenceMarks?: readonly RectificationInferenceMark[];
}>;
function record(value: unknown): Record<string, unknown> | null {
@@ -284,6 +291,24 @@ function parseCredibleRange(value: unknown): readonly [string, string] | null {
return [start, end];
}
function parseInferenceMarks(receipt: Record<string, unknown> | null): readonly RectificationInferenceMark[] {
const state = record(receipt?.inference_state);
if (!state || !Array.isArray(state.candidates)) return [];
const marks: RectificationInferenceMark[] = [];
const seen = new Set<string>();
for (const value of state.candidates) {
const row = record(value);
const clock = time(row?.time);
if (!clock || seen.has(clock)) continue;
seen.add(clock);
marks.push({
time: clock,
eliminated: row?.status === "eliminated",
});
}
return marks;
}
export function parseRectificationCandidateResult(value: unknown): RectificationCandidateResult | null {
const snapshot = record(value);
if (!snapshot || typeof snapshot.resultId !== "string") return null;
@@ -372,6 +397,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
?? verificationMarkdownFromUnknown(
(snapshot.rangeDelivery ?? snapshot.range_delivery) as { verification_markdown?: unknown } | undefined,
),
inferenceMarks: parseInferenceMarks(receipt),
};
}
@@ -45,7 +45,7 @@ export type RectificationTimelineView = Readonly<{
ticks: readonly TimelineTick[];
/** `05:0705:09` */
rangeLabel: string;
/** `2 分钟` */
/** `3 分钟` */
widthLabel: string;
}>;
@@ -131,12 +131,23 @@ function buildTicks(axisStart: number, axisEnd: number): TimelineTick[] {
return ticks;
}
export type TimelineInferenceMark = Readonly<{
time: string;
eliminated: boolean;
}>;
export type RectificationTimelineInput = Readonly<{
/** `candidate_range`: the case's current search window, and the axis. */
searchWindow: readonly [string, string] | null;
/** `credible_range`; absent during block scan, where the band is the window. */
credibleRange: readonly [string, string] | null;
candidateTimes: readonly string[];
/**
* Inference-layer minutes with elimination status. When present and non-empty,
* these are the marks; `eliminated` is the only in/out judge. When absent,
* engine `candidateTimes` are drawn solid.
*/
inferenceMarks?: readonly TimelineInferenceMark[];
stage: RectificationTimelineStage | null;
}>;
@@ -177,9 +188,12 @@ export function buildRectificationTimeline(
// boundaries do not reach the client as structured data (see PROGRESS).
const marks: TimelineMark[] = [];
if (input.stage === "minute") {
const sourced = input.inferenceMarks && input.inferenceMarks.length > 0
? input.inferenceMarks.map((mark) => ({ time: mark.time, eliminated: mark.eliminated }))
: input.candidateTimes.map((time) => ({ time, eliminated: false }));
const seen = new Set<number>();
for (const raw of input.candidateTimes) {
const parsed = parseClockMinutes(raw);
for (const item of sourced) {
const parsed = parseClockMinutes(item.time);
if (parsed === null) continue;
const at = alignToAxis(parsed, axisStart, axisEnd);
if (at < axisStart || at > axisEnd) continue;
@@ -188,8 +202,8 @@ export function buildRectificationTimeline(
marks.push({
key: `m${at}`,
percent: timelinePercent(at, axisStart, axisEnd),
// Closed interval: a candidate sitting on a boundary is still in range.
state: at >= bandStart && at <= bandEnd ? "in" : "out",
// Status from the inference layer, never band position (BUG-603).
state: item.eliminated ? "out" : "in",
});
}
marks.sort((left, right) => left.percent - right.percent);
@@ -203,6 +217,6 @@ export function buildRectificationTimeline(
marks,
ticks: buildTicks(axisStart, axisEnd),
rangeLabel: `${formatClockMinutes(bandStart)}${formatClockMinutes(bandEnd)}`,
widthLabel: timelineDurationLabel(bandEnd - bandStart),
widthLabel: timelineDurationLabel(bandEnd - bandStart + 1),
};
}