From ce91a0d23c83eb05015218ffc3aacefc2aa36cb7 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 9 Sep 2026 04:23:05 +0000 Subject: [PATCH] feat(rectification): show the credible range as a persistent timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr --- frontend/DESIGN.md | 31 ++ frontend/src/app/globals.css | 122 +++++++- .../components/rectification-agentic-chat.tsx | 33 +++ .../src/components/rectification-timeline.tsx | 69 +++++ .../src/lib/rectification-surface-state.ts | 20 ++ .../src/lib/rectification-timeline-scale.ts | 208 +++++++++++++ .../rectification-timeline-20260909.test.ts | 278 ++++++++++++++++++ 7 files changed, 760 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/rectification-timeline.tsx create mode 100644 frontend/src/lib/rectification-timeline-scale.ts create mode 100644 frontend/tests/rectification-timeline-20260909.test.ts diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 7a3ea312..538490d5 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -501,3 +501,34 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小 Admin 的 antd `` 是独立设计系统,不在此表。 +## 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 以下 56px;56px 等于既有的 `--rectification-jump-clearance`(44px 触控尺寸 + `space-3`),不新造常数。 + +**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与区间读数(`05:07–05: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 防复发)。 + diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1ec4f197..8764c000 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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 + 7–9 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)); diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index b3fcfe57..6b59517b 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -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(() => caseSnapshotState(initialSnapshot)?.question ?? null); const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null); const [caseStatus, setCaseStatus] = useState(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null); + const [searchWindow, setSearchWindow] = useState(() => caseSnapshotState(initialSnapshot)?.searchWindow ?? null); + const [caseStage, setCaseStage] = useState(() => caseSnapshotState(initialSnapshot)?.stage ?? null); const [nextUserActionId, setNextUserActionId] = useState(() => 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}
+ {/* Its own grid row, above and outside the scroll container, so + scrollHeight is untouched and the scroll anchor needs no changes. */} +