feat(rectification): show the credible range as a persistent timeline

The range is the only quantity that expresses convergence, and it existed
only as a sentence in the transcript. The bar makes it permanent.

It is the chat grid's first row, outside the scroll container, so
scrollHeight is untouched and use-conversation-scroll-anchor needs no
change. Its height is fixed because nothing observes it: a growing bar
would alter clientHeight and let an anchored reader lose the tail.

The axis is the current search window, not the opening one, since
widening overruns the opening window. Marks are binary — in range or
excluded — because relative support between candidate minutes carries no
demonstrated minute-level discrimination (BUG-560, blocked).

Band and marks move by transform alone; globals.css bans width
transitions, and transform keeps the motion off the layout path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
This commit is contained in:
Jesse_Chen
2026-09-09 04:23:05 +00:00
co-authored by Claude Fable 5
parent 109b1d7f0c
commit ce91a0d23c
7 changed files with 760 additions and 1 deletions
+31
View File
@@ -501,3 +501,34 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小
Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 10. 生时校正常驻时间轴
校正是最长的流程,唯一能表达「正在收敛」的量是范围宽度。它此前只以范围变化句的形式散在消息流里,滚上去就看不见。时间轴把这个量变成常驻形态。
**它是一条只读的横条,钉在校正对话上方,回答「我在哪、还有多宽」;「选哪一分钟」仍然只由交付卡回答。**
### 三条硬规定
**一、它是 `.rectification-workspace__chat` 的第一个 grid 行,不是 `position: sticky`。** 该容器现在是三行:`var(--rectification-timeline-height) minmax(0, 1fr) auto`。条位于滚动容器 `.conversation` **之外**,因此 `scrollHeight` 不变,`use-conversation-scroll-anchor` 的贴底距离仍然精确,它的 `ResizeObserver`(只观察滚动容器的直接子元素)也永远看不到这条。**该 hook 不因本组件改动一行**,校正面不得出现第二套滚动跟随。若改成滚动容器内的 sticky 子元素,还会撞上 BUG-218/252 的老问题:容器留了 `padding-bottom` 时,sticky 子元素被内容盒夹住,永远贴不到可视底边。
**二、条高固定,从首次渲染起就占位。** 这是正确性要求,不是视觉偏好:条在滚动容器外,高度一变就改 `clientHeight`,而没有任何观察者会因此重新跟随——贴底的读者会看着内容悄悄滑出视野底部且无法恢复。同理,Case 未就绪时渲染**等高的空骨架**(`data-state="pending"`),不得用 spinner、不得用「正在加载」文案、不得让条晚一步挂载。桌面 64px,767px 以下 56px56px 等于既有的 `--rectification-jump-clearance`44px 触控尺寸 + `space-3`),不新造常数。
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与区间读数(`05:0705:09 · 2 分钟`)。以下一律不得加回来——每一条都是被明确砍掉的:
| 不得出现 | 理由 |
| --- | --- |
| 范围变化句 | 服务端已写进落库助手消息,BUG-588 防复发要求它在那里;条上再写=同句同屏两次 |
| 「仍在范围内 N / 已排除 N」计数与图例 | 实心=在内、空心=已排除,答完第一题即自明;图例是过度提示的典型形态 |
| 阶段标签(时段 / 分钟) | `block_scan` / `minute` 是后台词汇,贴近 BUG-043 红线 |
| 「已对照 N 件经历」 | 归交付卡,那里每列已带经历对照计数 |
| 「时间轴只读」注脚 | 在解释一个不存在的东西;没有元素看起来可点,就不需要声明不可点 |
| 预计剩余时间、定时器插值动画 | 与报告进度条同一条原则:不得演出后端没做的动作 |
### 轴与标记
- **轴 = 当前搜索窗口(`candidate_range`),不是开场窗口。** 开场窗口不是定值(±15 / ±30 / ±60 / ±120 / 整天),且放宽会顶穿它(BUG-572:±15 → ±30 → ±60 → ±120)。窗口变化时轴重新对到新窗口,区间带与标记用 CSS transition 过渡到新位置(`prefers-reduced-motion` 由全局 `*` 规则收成一帧)。窗口不变时轴稳定,收窄照样读得出来。
- **候选标记一律等大,只分在范围内 / 已排除。** 不得按 `relativeSupport` / `probability_percent` 分级(尺寸、深浅、粗细都不行):BUG-560 状态是 blocked,候选间相对支持度 7–9 分(满分 100),校准显示分钟级几乎无区分力,分级显示等于用视觉编码放大统计上不显著的差异。被排除的点留在原地变空心、不消失——用户由此看见自己刚才那一答排掉了哪几分钟。
- **时段阶段(`block_scan`)不画分钟点**,区间带覆盖整个窗口。那一刻确实什么都还没排除,写「23 小时 59 分」是事实而非占位。
- 跨午夜的窗口(`late_night` 是 23:00–03:59,末端早于始端)按次日展开,轴保持单调。
- 条上不得出现 `rectification-step-state` 类名(BUG-575 防复发)。
+121 -1
View File
@@ -2673,7 +2673,127 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
/* Timeline row, scroll row, composer row. The timeline is deliberately its
own row rather than a sticky child of .conversation: outside the scroll
container it leaves scrollHeight untouched, so the stick-to-bottom distance
in use-conversation-scroll-anchor stays exact and its ResizeObserver (which
watches only the container's children) never sees it. */
grid-template-rows: var(--rectification-timeline-height) minmax(0, 1fr) auto;
}
/* Fixed height is correctness, not taste: the bar sits outside the scroll
container, so a height change alters clientHeight with nothing observing it,
and an anchored reader would have content slide out of view unrecovered.
The pending state keeps the same box for the same reason. */
.rectification-workspace__chat {
--rectification-timeline-height: 64px;
}
.rectification-timeline {
height: var(--rectification-timeline-height);
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--space-1);
padding: 0 var(--space-5);
background: var(--color-canvas);
border-bottom: 1px solid var(--color-border);
overflow: hidden;
}
.rectification-timeline__readout {
margin: 0;
display: flex;
align-items: baseline;
gap: var(--space-2);
font-size: 13px;
line-height: 1.2;
}
.rectification-timeline__range {
font-variant-numeric: tabular-nums;
font-weight: 500;
color: var(--color-ink);
}
.rectification-timeline__width {
color: var(--color-ink-secondary);
}
.rectification-timeline__axis {
position: relative;
height: 26px;
flex: none;
}
.rectification-timeline__rule {
position: absolute;
inset-inline: 0;
top: 7px;
height: 1px;
background: var(--color-border-strong);
}
/* Positioned and resized by transform alone: transitioning left/width would run
layout on every frame, and this stylesheet bans width transitions outright
(the sidebar shell animates none of its geometry either). The band is full
width and scaled down from its left edge, so one transform carries both the
start and the span. No side borders — scaleX would distort them, and the
fill edge already reads as the boundary. */
.rectification-timeline__band {
position: absolute;
top: 2px;
left: 0;
width: 100%;
height: 11px;
border-radius: 2px;
background: var(--color-action-soft);
transform-origin: left center;
will-change: transform;
transition: transform 420ms cubic-bezier(.32, .72, .24, 1);
}
.rectification-timeline__mark-at {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
transition: transform 420ms cubic-bezier(.32, .72, .24, 1);
}
/* Every mark is the same size. Relative support between candidate minutes is
79 out of 100 with no demonstrated minute-level discrimination (BUG-560,
blocked), so grading them by size or weight would show a difference the
engine cannot back. In-range versus excluded is the only honest encoding. */
.rectification-timeline__mark {
position: absolute;
top: 3px;
left: -4.5px;
width: 9px;
height: 9px;
border-radius: 50%;
border: 1px solid var(--color-action);
background: var(--color-action);
transition: background 260ms ease, border-color 260ms ease, opacity 260ms ease;
}
.rectification-timeline__mark[data-state="out"] {
background: var(--color-canvas);
border-color: var(--color-border-strong);
opacity: .55;
}
.rectification-timeline__tick {
position: absolute;
top: 13px;
transform: translateX(-50%);
font-size: 10px;
line-height: 1.3;
color: var(--color-ink-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* 56px is the existing --rectification-jump-clearance (44px touch target plus
space-3), reused rather than inventing a second constant. Past ~64px the bar
costs a whole message bubble on a 667px viewport. */
@media (max-width: 767px) {
.rectification-workspace__chat {
--rectification-timeline-height: 56px;
}
.rectification-timeline {
padding-inline: var(--space-4);
}
}
.rectification-workspace__chat .conversation {
--rectification-jump-clearance: calc(44px + var(--space-3));
@@ -60,8 +60,15 @@ import {
rectificationConversationState,
rectificationInitialLiveLabel,
rectificationQuestionGapState,
searchWindowFromSnapshot,
caseStageFromSnapshot,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import { RectificationTimeline } from "@/components/rectification-timeline";
import {
buildRectificationTimeline,
type RectificationTimelineStage,
} from "@/lib/rectification-timeline-scale";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
isPublicRectificationActivity,
@@ -392,6 +399,8 @@ type CaseSnapshotState = Readonly<{
savedTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
nextUserActionId: string | null;
searchWindow: readonly [string, string] | null;
stage: RectificationTimelineStage | null;
}>;
function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null {
@@ -413,6 +422,8 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca
savedTime: confirmedTime ?? acceptedTime,
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
nextUserActionId: nextUserActionIdFromSnapshot(payload),
searchWindow: searchWindowFromSnapshot(payload.case?.candidate_range),
stage: caseStageFromSnapshot(payload.case?.stage),
};
}
@@ -453,6 +464,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
const [searchWindow, setSearchWindow] = useState<readonly [string, string] | null>(() => caseSnapshotState(initialSnapshot)?.searchWindow ?? null);
const [caseStage, setCaseStage] = useState<RectificationTimelineStage | null>(() => caseSnapshotState(initialSnapshot)?.stage ?? null);
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
@@ -590,6 +603,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
status?: unknown;
accepted_time?: unknown;
confirmed_time?: unknown;
candidate_range?: unknown;
stage?: unknown;
};
} | null) => {
if (!payload) return;
@@ -604,6 +619,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const nextActionId = typeof payload.next_user_action?.id === "string"
? payload.next_user_action.id.trim()
: "";
// Only overwrite when the payload actually carries them: a snapshot without
// a window should leave the timeline showing the last known one rather than
// dropping to the skeleton mid-session.
const nextWindow = searchWindowFromSnapshot(payload.case?.candidate_range);
if (nextWindow) setSearchWindow(nextWindow);
const nextStage = caseStageFromSnapshot(payload.case?.stage);
if (nextStage) setCaseStage(nextStage);
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
@@ -1393,6 +1415,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& regeneratingMessageKey === null,
);
const canOfferCards = canShowRectificationSelectionCards(candidateResult);
// Cheap arithmetic over at most a dozen candidate minutes; recomputed with
// the render rather than memoised, matching the rest of this component.
const timelineView = buildRectificationTimeline({
searchWindow,
credibleRange: candidateResult?.credibleRange ?? null,
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
stage: caseStage,
});
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
const selectionCardMessageKey = persistedOfferKey
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
@@ -1520,6 +1550,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
>
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
{/* Its own grid row, above and outside the scroll container, so
scrollHeight is untouched and the scroll anchor needs no changes. */}
<RectificationTimeline view={timelineView} />
<section
ref={conversation}
className="conversation is-rectification"
@@ -0,0 +1,69 @@
/**
* The always-present range bar above the rectification conversation.
*
* Read-only by design: it answers "how wide is the range now", never "which
* minute do I take". Adoption stays on the delivery card, so this component
* takes no callbacks and renders no controls.
*
* It carries exactly two things the axis and the range readout. Counts, a
* legend, the stage name, the range-change sentence and an explanatory footnote
* were all considered and cut: that sentence is already written into the
* persisted assistant message (BUG-588 requires it there, so repeating it here
* would put one sentence on screen twice), and the rest explain what a single
* glance already shows.
*
* The bar lives outside the scroll container as its own grid row, so its height
* must never change see `.rectification-timeline` in globals.css. An unready
* case renders the same-height skeleton rather than nothing.
*/
"use client";
import type { RectificationTimelineView } from "@/lib/rectification-timeline-scale";
export function RectificationTimeline({ view }: { view: RectificationTimelineView | null }) {
if (!view) {
return <div className="rectification-timeline" data-state="pending" aria-hidden="true" />;
}
return (
<div className="rectification-timeline" data-state="ready">
<p className="rectification-timeline__readout">
<span className="rectification-timeline__range">{view.rangeLabel}</span>
<span className="rectification-timeline__width">{view.widthLabel}</span>
</p>
<div
className="rectification-timeline__axis"
role="img"
aria-label={`出生时间搜索范围 ${view.axisStartLabel}${view.axisEndLabel},当前区间 ${view.rangeLabel}${view.widthLabel}`}
>
<span className="rectification-timeline__rule" />
{/* Placed and resized with transform only. Animating left/width would
run layout every frame, and globals.css bans transitions on width
outright (the sidebar shell animates none of its geometry). */}
<span
className="rectification-timeline__band"
style={{ transform: `translateX(${view.bandStartPercent}%) scaleX(${view.bandWidthPercent / 100})` }}
/>
{view.marks.map((mark) => (
<span
key={mark.key}
className="rectification-timeline__mark-at"
style={{ transform: `translateX(${mark.percent}%)` }}
>
<span className="rectification-timeline__mark" data-state={mark.state} />
</span>
))}
{view.ticks.map((tick) => (
<span
key={tick.key}
className="rectification-timeline__tick"
style={{ left: `${tick.percent}%` }}
>
{tick.label}
</span>
))}
</div>
</div>
);
}
@@ -65,10 +65,30 @@ export type RectificationCaseSnapshotPayload = Readonly<{
status?: unknown;
accepted_time?: unknown;
confirmed_time?: unknown;
/**
* Both are already on the wire from the case route; declaring them here
* lets the timeline read the current search window and stage. No server
* change was needed.
*/
candidate_range?: unknown;
stage?: unknown;
}>;
turns?: unknown;
}>;
/** `candidate_range` as `[start, end]`; null when absent or malformed. */
export function searchWindowFromSnapshot(value: unknown): readonly [string, string] | null {
if (!value || typeof value !== "object") return null;
const row = value as { start_time?: unknown; end_time?: unknown };
const start = typeof row.start_time === "string" ? row.start_time.trim() : "";
const end = typeof row.end_time === "string" ? row.end_time.trim() : "";
return start && end ? [start, end] : null;
}
export function caseStageFromSnapshot(value: unknown): "minute" | "block_scan" | null {
return value === "minute" || value === "block_scan" ? value : null;
}
export function isRectificationCaseSnapshotPayload(value: unknown): value is RectificationCaseSnapshotPayload {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,208 @@
/**
* Geometry for the rectification timeline: clock strings in, percentages out.
*
* Kept apart from the component so the scale is testable without a DOM. The
* component renders what this returns and decides nothing itself.
*
* Two rules this module exists to hold:
*
* - The axis is the **current** search window (`candidate_range`), never the
* opening one. The window widens (BUG-572: ±15 ±30 ±60 ±120) and
* narrows (a chosen block), so an axis pinned to the opening window would be
* overrun by its own band.
* - Candidate marks carry no confidence. BUG-560 is blocked: relative support
* between candidate minutes is 79 out of 100 and calibration found no
* minute-level discriminating power, so a graded mark would render a
* difference the engine cannot support. The only encoding is in-range or
* excluded.
*/
const MINUTES_PER_DAY = 1440;
export type RectificationTimelineStage = "minute" | "block_scan";
export type TimelineMark = Readonly<{
/** Stable across re-renders so CSS transitions animate rather than restart. */
key: string;
percent: number;
/** Binary by design; see the module note on BUG-560. */
state: "in" | "out";
}>;
export type TimelineTick = Readonly<{
key: string;
percent: number;
label: string;
}>;
export type RectificationTimelineView = Readonly<{
/** Inclusive clock labels for the axis ends, for assistive text. */
axisStartLabel: string;
axisEndLabel: string;
bandStartPercent: number;
bandWidthPercent: number;
marks: readonly TimelineMark[];
ticks: readonly TimelineTick[];
/** `05:0705:09` */
rangeLabel: string;
/** `2 分钟` */
widthLabel: string;
}>;
/** `HH:MM` or `HH:MM:SS` to minutes past midnight; null when unparseable. */
export function parseClockMinutes(value: unknown): number | null {
if (typeof value !== "string") return null;
const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(value.trim());
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null;
if (hours > 23 || minutes > 59) return null;
return hours * 60 + minutes;
}
export function formatClockMinutes(value: number): string {
const wrapped = ((Math.round(value) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
const hours = Math.floor(wrapped / 60);
const minutes = wrapped % 60;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
/**
* Declared periods may cross midnight (`late_night` is 23:0003:59), which
* arrives as an end earlier than its start. Unrolling onto a second day keeps
* the axis monotonic; every other calculation then works on plain numbers.
*/
export function unrollWindowEnd(start: number, end: number): number {
return end >= start ? end : end + MINUTES_PER_DAY;
}
/** Place a clock reading on an axis that may run past midnight. */
export function alignToAxis(minute: number, axisStart: number, axisEnd: number): number {
if (minute >= axisStart) return minute;
const shifted = minute + MINUTES_PER_DAY;
return shifted <= axisEnd ? shifted : minute;
}
/** Coarser ticks as the window grows; the labels stay on round clock values. */
export function timelineTickStepMinutes(span: number): number {
if (span <= 40) return 5;
if (span <= 90) return 10;
if (span <= 180) return 20;
if (span <= 400) return 60;
if (span <= 900) return 120;
return 240;
}
export function timelineDurationLabel(minutes: number): string {
const total = Math.max(0, Math.round(minutes));
if (total < 60) return `${total} 分钟`;
const hours = Math.floor(total / 60);
const rest = total % 60;
return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest}`;
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
if (value < 0) return 0;
if (value > 100) return 100;
return value;
}
/** Position on the axis as a percentage; callers clamp-safe by construction. */
export function timelinePercent(minute: number, axisStart: number, axisEnd: number): number {
const span = axisEnd - axisStart;
if (span <= 0) return 0;
return clampPercent(((minute - axisStart) / span) * 100);
}
function buildTicks(axisStart: number, axisEnd: number): TimelineTick[] {
const span = axisEnd - axisStart;
const step = timelineTickStepMinutes(span);
const ticks: TimelineTick[] = [];
const first = Math.ceil(axisStart / step) * step;
for (let at = first; at <= axisEnd; at += step) {
ticks.push({
key: `t${at}`,
percent: timelinePercent(at, axisStart, axisEnd),
label: formatClockMinutes(at),
});
}
return ticks;
}
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[];
stage: RectificationTimelineStage | null;
}>;
/**
* Returns null when the bar has nothing truthful to draw the caller renders
* an equal-height skeleton rather than shrinking, because the bar sits outside
* the scroll container and a height change would silently break stick-to-bottom.
*/
export function buildRectificationTimeline(
input: RectificationTimelineInput,
): RectificationTimelineView | null {
const windowStart = parseClockMinutes(input.searchWindow?.[0]);
const windowEndRaw = parseClockMinutes(input.searchWindow?.[1]);
if (windowStart === null || windowEndRaw === null) return null;
const axisStart = windowStart;
const axisEnd = unrollWindowEnd(windowStart, windowEndRaw);
if (axisEnd <= axisStart) return null;
// Block scan has no scored candidates yet, so the credible range is the whole
// window. Saying "24 小时" there is accurate, not a placeholder.
const bandStartRaw = parseClockMinutes(input.credibleRange?.[0]);
const bandEndRaw = parseClockMinutes(input.credibleRange?.[1]);
const hasBand = bandStartRaw !== null && bandEndRaw !== null;
const bandStart = hasBand
? Math.max(axisStart, alignToAxis(bandStartRaw, axisStart, axisEnd))
: axisStart;
const bandEnd = hasBand
? Math.min(axisEnd, Math.max(bandStart, alignToAxis(unrollWindowEnd(bandStartRaw, bandEndRaw), axisStart, axisEnd)))
: axisEnd;
const bandStartPercent = timelinePercent(bandStart, axisStart, axisEnd);
const bandWidthPercent = clampPercent(
timelinePercent(bandEnd, axisStart, axisEnd) - bandStartPercent,
);
// Minute stage only: during block scan the marks would be blocks, and their
// boundaries do not reach the client as structured data (see PROGRESS).
const marks: TimelineMark[] = [];
if (input.stage === "minute") {
const seen = new Set<number>();
for (const raw of input.candidateTimes) {
const parsed = parseClockMinutes(raw);
if (parsed === null) continue;
const at = alignToAxis(parsed, axisStart, axisEnd);
if (at < axisStart || at > axisEnd) continue;
if (seen.has(at)) continue;
seen.add(at);
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",
});
}
marks.sort((left, right) => left.percent - right.percent);
}
return {
axisStartLabel: formatClockMinutes(axisStart),
axisEndLabel: formatClockMinutes(axisEnd),
bandStartPercent,
bandWidthPercent,
marks,
ticks: buildTicks(axisStart, axisEnd),
rangeLabel: `${formatClockMinutes(bandStart)}${formatClockMinutes(bandEnd)}`,
widthLabel: timelineDurationLabel(bandEnd - bandStart),
};
}
@@ -0,0 +1,278 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildRectificationTimeline,
formatClockMinutes,
parseClockMinutes,
timelineDurationLabel,
timelineTickStepMinutes,
} from "../src/lib/rectification-timeline-scale.ts";
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const component = read("../src/components/rectification-timeline.tsx");
const scale = read("../src/lib/rectification-timeline-scale.ts");
const chat = read("../src/components/rectification-agentic-chat.tsx");
const anchor = read("../src/hooks/use-conversation-scroll-anchor.ts");
const styles = read("../src/app/globals.css");
/** ±15 around a declared 05:00, the most common opening window. */
const declaredWindow = ["04:45", "05:15"] as const;
test("clock parsing rejects anything that is not a real reading", () => {
assert.equal(parseClockMinutes("04:45"), 285);
assert.equal(parseClockMinutes("4:45"), 285);
assert.equal(parseClockMinutes("05:07:00"), 307);
assert.equal(parseClockMinutes("24:00"), null);
assert.equal(parseClockMinutes("05:60"), null);
assert.equal(parseClockMinutes(""), null);
assert.equal(parseClockMinutes(undefined), null);
assert.equal(formatClockMinutes(307), "05:07");
assert.equal(formatClockMinutes(1440), "00:00");
});
test("tick step coarsens as the window grows, and labels stay on round clock values", () => {
assert.equal(timelineTickStepMinutes(30), 5); // ±15
assert.equal(timelineTickStepMinutes(60), 10); // ±30
assert.equal(timelineTickStepMinutes(120), 20); // ±60
assert.equal(timelineTickStepMinutes(240), 60); // ±120
assert.equal(timelineTickStepMinutes(1439), 240); // whole day
const half = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["04:47", "05:14"],
candidateTimes: [],
stage: "minute",
});
assert.ok(half);
assert.deepEqual(half.ticks.map((tick) => tick.label), [
"04:45", "04:50", "04:55", "05:00", "05:05", "05:10", "05:15",
]);
const fourHours = buildRectificationTimeline({
searchWindow: ["03:00", "07:00"],
credibleRange: null,
candidateTimes: [],
stage: "block_scan",
});
assert.ok(fourHours);
assert.deepEqual(fourHours.ticks.map((tick) => tick.label), [
"03:00", "04:00", "05:00", "06:00", "07:00",
]);
const wholeDay = buildRectificationTimeline({
searchWindow: ["00:00", "23:59"],
credibleRange: null,
candidateTimes: [],
stage: "block_scan",
});
assert.ok(wholeDay);
assert.deepEqual(wholeDay.ticks.map((tick) => tick.label), [
"00:00", "04:00", "08:00", "12:00", "16:00", "20:00",
]);
});
test("the band is placed against the axis and never escapes it", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: [],
stage: "minute",
});
assert.ok(view);
// 05:07 is 22 minutes into a 30-minute axis.
assert.equal(Math.round(view.bandStartPercent), 73);
assert.equal(Math.round(view.bandWidthPercent), 7);
assert.ok(view.bandStartPercent + view.bandWidthPercent <= 100);
assert.equal(view.rangeLabel, "05:0705:09");
assert.equal(view.widthLabel, "2 分钟");
// A range reported wider than the window is clamped rather than overflowing.
const overflowing = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["04:00", "06:00"],
candidateTimes: [],
stage: "minute",
});
assert.ok(overflowing);
assert.equal(overflowing.bandStartPercent, 0);
assert.equal(overflowing.bandWidthPercent, 100);
});
test("candidate marks are binary and the range boundary counts as inside", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: ["05:06", "05:07", "05:08", "05:09", "05:10"],
stage: "minute",
});
assert.ok(view);
assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "in", "in", "out"]);
// Closed interval: both ends of the range are in, not on the fence.
assert.equal(view.marks[1]?.state, "in");
assert.equal(view.marks[3]?.state, "in");
// No third state exists to grade confidence with.
assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true);
});
test("marks outside the window are dropped and duplicates collapse", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:00", "05:10"],
candidateTimes: ["03:00", "05:02", "05:02", "06:30", "05:08"],
stage: "minute",
});
assert.ok(view);
assert.deepEqual(view.marks.map((mark) => mark.key), ["m302", "m308"]);
});
test("block scan draws the window as the range and no minute marks", () => {
const view = buildRectificationTimeline({
searchWindow: ["00:00", "23:59"],
credibleRange: null,
candidateTimes: ["05:07", "06:20"],
stage: "block_scan",
});
assert.ok(view);
// Saying "23 小时 59 分" here is the truth: nothing has been ruled out yet.
assert.equal(view.bandStartPercent, 0);
assert.equal(view.bandWidthPercent, 100);
assert.equal(view.rangeLabel, "00:0023:59");
assert.equal(view.widthLabel, "23 小时 59 分");
assert.deepEqual(view.marks, []);
});
test("minute stage draws marks where block scan draws none, from the same candidates", () => {
const shared = { searchWindow: ["06:20", "07:40"] as const, credibleRange: ["06:50", "07:06"] as const, candidateTimes: ["06:55", "07:02"] };
const minute = buildRectificationTimeline({ ...shared, stage: "minute" });
const block = buildRectificationTimeline({ ...shared, stage: "block_scan" });
assert.ok(minute);
assert.ok(block);
assert.equal(minute.marks.length, 2);
assert.equal(block.marks.length, 0);
});
test("widening the window rescales the axis and keeps the band inside it", () => {
// BUG-572: a low event fit rate near the window edge widens ±15 to ±30.
const before = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:01", "05:09"],
candidateTimes: ["05:02", "05:08"],
stage: "minute",
});
const after = buildRectificationTimeline({
searchWindow: ["04:30", "05:30"],
credibleRange: ["04:56", "05:20"],
candidateTimes: ["05:02", "05:08"],
stage: "minute",
});
assert.ok(before);
assert.ok(after);
// The same candidate minute sits at a different percentage once the axis grows.
assert.notEqual(before.marks[0]?.percent, after.marks[0]?.percent);
assert.equal(Math.round(before.marks[0]!.percent), 57);
assert.equal(Math.round(after.marks[0]!.percent), 53);
// The widened range still fits: an axis pinned to the opening window would not hold it.
assert.ok(after.bandStartPercent >= 0);
assert.ok(after.bandStartPercent + after.bandWidthPercent <= 100);
assert.equal(after.widthLabel, "24 分钟");
});
test("a window that crosses midnight stays monotonic", () => {
// late_night is 23:0003:59, which arrives with an end earlier than its start.
const view = buildRectificationTimeline({
searchWindow: ["23:00", "03:59"],
credibleRange: ["00:30", "01:30"],
candidateTimes: ["23:30", "01:00", "03:00"],
stage: "minute",
});
assert.ok(view);
assert.equal(view.axisStartLabel, "23:00");
assert.equal(view.axisEndLabel, "03:59");
assert.equal(view.rangeLabel, "00:3001:30");
assert.equal(view.widthLabel, "1 小时");
assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "out"]);
assert.ok(view.marks.every((mark) => mark.percent >= 0 && mark.percent <= 100));
});
test("duration reads as clock language, not raw minutes", () => {
assert.equal(timelineDurationLabel(2), "2 分钟");
assert.equal(timelineDurationLabel(59), "59 分钟");
assert.equal(timelineDurationLabel(60), "1 小时");
assert.equal(timelineDurationLabel(80), "1 小时 20 分");
assert.equal(timelineDurationLabel(240), "4 小时");
});
test("no window means no bar content; the caller still renders the same box", () => {
assert.equal(buildRectificationTimeline({ searchWindow: null, credibleRange: ["05:07", "05:09"], candidateTimes: [], stage: "minute" }), null);
assert.equal(buildRectificationTimeline({ searchWindow: ["bad", "05:15"], credibleRange: null, candidateTimes: [], stage: "minute" }), null);
// Same element, same height, no spinner and no loading copy.
assert.match(component, /if \(!view\) \{\s*return <div className="rectification-timeline" data-state="pending"/);
assert.doesNotMatch(component, /InlineSpinner|正在加载|骨架/);
});
test("the bar is read-only and carries no confidence grading", () => {
// BUG-575 banned hover-revealed detail on this surface; BUG-560 blocks grading.
assert.doesNotMatch(component, /onMouseEnter|onMouseOver|onClick|onFocus/);
assert.doesNotMatch(component, /<button|relativeSupport|probability/);
assert.doesNotMatch(scale, /relativeSupport|probability/);
// Two elements only: the readout and the axis.
assert.match(component, /rectification-timeline__readout/);
assert.match(component, /rectification-timeline__axis/);
// The cut list stays cut.
assert.doesNotMatch(component, /已对照|已排除|仍在范围内|范围从|只读|时段阶段|分钟阶段/);
assert.doesNotMatch(component, /rectification-step-state/);
});
test("the band and marks move by transform, never by animating layout", () => {
// globals.css bans transitions on width outright (sidebar-contract guards it);
// transform also keeps the motion off the layout path.
assert.match(component, /transform: `translateX\(\$\{view\.bandStartPercent\}%\) scaleX\(/);
assert.match(component, /transform: `translateX\(\$\{mark\.percent\}%\)`/);
// The two moving parts never take an inline left/width; ticks may, because a
// rescale rebuilds the whole tick set rather than sliding it.
assert.doesNotMatch(component, /__band"[\s\S]{0,120}(?:left|width): `/);
assert.doesNotMatch(component, /__mark-at"[\s\S]{0,120}(?:left|width): `/);
const barStyles = styles.slice(
styles.indexOf(".rectification-timeline__band"),
styles.indexOf(".rectification-timeline__tick"),
);
assert.match(barStyles, /transition: transform 420ms/);
assert.doesNotMatch(barStyles, /transition:[^;}]*\b(?:width|left|height)\b/);
});
test("nothing on the bar advances on a timer", () => {
// BUG-601's principle: never animate motion the backend has not made.
assert.doesNotMatch(component, /setInterval|setTimeout|requestAnimationFrame/);
assert.doesNotMatch(scale, /setInterval|setTimeout|requestAnimationFrame|Date\.now/);
});
test("the bar is its own grid row, outside the scroll container", () => {
// Rendered as a sibling before <section className="conversation">, not inside it.
assert.match(
chat,
/<RectificationTimeline view=\{timelineView\} \/>\s*<section\s*ref=\{conversation\}\s*className="conversation is-rectification"/,
);
assert.doesNotMatch(component, /position:\s*sticky|sticky/);
// The scroll anchor knows nothing about the timeline and needs no changes.
assert.doesNotMatch(anchor, /timeline|rectification-timeline/i);
});
test("the chat grid has three rows and the timeline row is a fixed height", () => {
const chatRule = styles.slice(styles.indexOf(".rectification-workspace__chat {"));
assert.match(chatRule, /grid-template-rows: var\(--rectification-timeline-height\) minmax\(0, 1fr\) auto;/);
assert.match(styles, /--rectification-timeline-height: 64px;/);
const barRule = styles.slice(styles.indexOf(".rectification-timeline {"), styles.indexOf(".rectification-timeline__readout"));
assert.match(barRule, /height: var\(--rectification-timeline-height\);/);
// Not min-height and not content-driven: a growing bar would silently break
// stick-to-bottom, since it sits outside the observed scroll container.
assert.doesNotMatch(barRule, /min-height|height:\s*auto/);
});
test("the mobile bar stays within the 44px touch rhythm", () => {
const mobile = styles.slice(styles.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {"));
assert.match(mobile, /--rectification-timeline-height: 56px;/);
// 56px is the existing jump-clearance constant, not a new number.
assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/);
});