fix(rectification): simplify delivery card and guard double turns (BUG-595, BUG-596)
Independent Staging Quality Gate / validate (push) Successful in 25m9s
Independent Staging Quality Gate / publish (push) Successful in 37m19s

Make the range card row-select, drop the composer 先这样 control and adopt status bar, keep delivery copy to three sentences with a folded verification report, and skip a second no-message agent run after a terminal delivery turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-08 19:56:48 +08:00
co-authored by Cursor
parent 6368ebba0f
commit 7e3b6cdc7a
60 changed files with 1561 additions and 1105 deletions
@@ -39,7 +39,7 @@ import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import { RangeDeliveryContinueHint, RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
@@ -78,7 +78,7 @@ import {
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
import { RECTIFICATION_USER_COPY, postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
@@ -458,7 +458,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [openingRequested, setOpeningRequested] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [dismissedRangeResultId, setDismissedRangeResultId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
@@ -473,9 +472,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const snapshotAbort = useRef<AbortController | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const currentQuestionRef = useRef(currentQuestion);
const offerSectionRef = useRef<HTMLDivElement | null>(null);
const offerFlashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [offerFlash, setOfferFlash] = useState(false);
const runStartedAtRef = useRef(0);
const stepStartedAtRef = useRef(0);
const liveToolRef = useRef<string | null>(null);
@@ -494,17 +490,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
currentQuestionRef.current = currentQuestion;
}, [currentQuestion]);
useEffect(() => () => {
if (offerFlashTimer.current) clearTimeout(offerFlashTimer.current);
}, []);
const scrollToAdoptOffer = useCallback(() => {
offerSectionRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
setOfferFlash(true);
if (offerFlashTimer.current) clearTimeout(offerFlashTimer.current);
offerFlashTimer.current = setTimeout(() => setOfferFlash(false), 600);
}, []);
useLayoutEffect(() => {
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
const update = () => {
@@ -1429,9 +1414,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: null;
const adoptedRangeLabel = candidateResult?.credibleRange
? `${candidateResult.credibleRange[0]}${candidateResult.credibleRange[1]}`
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const liveQuestionOnMessages = messages.some((message) => (
message.role === "assistant"
@@ -1521,15 +1503,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void submitStructuredChoice(STOP_ACTION, "stop");
}
const rangeCardDismissed = Boolean(
candidateResult
&& dismissedRangeResultId === candidateResult.resultId
&& !candidateResult.selectedTime
&& savedStatus !== "accepted"
&& savedStatus !== "confirmed"
&& !readonly
);
const spokenCollectOpen = currentQuestion?.kind === "collect_spoken" && !readonly && !busy;
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
@@ -1646,13 +1619,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return (
<div
key={message.renderKey}
className={[
"rectification-message-wrap rectification-message-entry",
offerFlash && message.renderKey === selectionCardMessageKey
? "rectification-message-flash"
: "",
].filter(Boolean).join(" ")}
ref={showSelectionCards && message.renderKey === selectionCardMessageKey ? offerSectionRef : undefined}
className="rectification-message-wrap rectification-message-entry"
>
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
@@ -1676,13 +1643,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onRegenerate={() => void regenerateMessage(message)}
/>
)}
{showSelectionCards && candidateResult && dismissedRangeResultId !== candidateResult.resultId && message.renderKey === selectionCardMessageKey && (
{showSelectionCards && candidateResult && message.renderKey === selectionCardMessageKey && (
<RectificationRangeDelivery
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
readonly={readonly || busy || regeneratingMessageKey !== null}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
onContinue={() => setDismissedRangeResultId(candidateResult.resultId)}
/>
)}
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
@@ -1708,7 +1674,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)}
{questionGap === "verified_idle" && (
<p className="rectification-pending-note" role="status">
{RECTIFICATION_USER_COPY.postAdoptVerifyDone}
{postAdoptVerifyDoneCopy(
savedTime,
messages.some((message) => message.question?.kind === "reverse_verify"),
)}
</p>
)}
{savedTime && savedStatus === "confirmed" && (
@@ -1730,38 +1699,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{!conversationAnchor.anchored && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
{savedTime && savedStatus === "accepted" && (
<div className="rectification-adopt-status" role="status">
<span>
{savedTime}
{adoptedRangeLabel ? ` · 范围 ${adoptedRangeLabel}` : ""}
</span>
{showSelectionCards && (
<button
type="button"
className="rectification-adopt-status__link"
onClick={scrollToAdoptOffer}
>
</button>
)}
<span className="rectification-adopt-status__hint"></span>
</div>
)}
{(rangeCardDismissed || spokenCollectOpen) && (
<div className="rectification-composer-meta">
<RangeDeliveryContinueHint visible={rangeCardDismissed && !busy} />
{spokenCollectOpen && (
<button
type="button"
className="rectification-collect-stop"
onClick={submitStop}
>
{CHOICE_STOP_LABEL}
</button>
)}
</div>
)}
<ChatComposer
inputRef={composer}
value={draft}