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. */} +