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:
co-authored by
Claude Fable 5
parent
109b1d7f0c
commit
ce91a0d23c
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user