fix(rectification): simplify delivery card and guard double turns (BUG-595, BUG-596)
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:
@@ -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}
|
||||
|
||||
@@ -1,171 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { RectificationCandidateResult } from "@/lib/rectification-candidate-result";
|
||||
import {
|
||||
RECTIFICATION_USER_COPY,
|
||||
REPRESENTATIVE_MINUTE_DISCLAIMER,
|
||||
rangeDeliveryEventCopy,
|
||||
rangeDeliverySensitiveCopy,
|
||||
rangeDeliveryStableCopy,
|
||||
} from "@/lib/rectification-agentic/user-copy";
|
||||
import { STEP_STATE_COPY } from "@/lib/rectification-agentic/v9/step-state";
|
||||
import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel";
|
||||
|
||||
function adoptCandidateId(
|
||||
result: RectificationCandidateResult,
|
||||
selectedColumnId: string | null,
|
||||
): string | null {
|
||||
const delivery = result.rangeDelivery;
|
||||
if (selectedColumnId) return selectedColumnId;
|
||||
if (delivery?.representative_candidate_id) return delivery.representative_candidate_id;
|
||||
const wanted = delivery?.representative_time ?? result.representativeTime;
|
||||
if (!wanted) return result.candidates[0]?.candidateId ?? null;
|
||||
return result.candidates.find((item) => item.time === wanted)?.candidateId
|
||||
?? result.candidates[0]?.candidateId
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function RangeDeliveryContinueHint({ visible }: Readonly<{ visible: boolean }>) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<p className="rectification-continue-hint" role="status">
|
||||
<strong>{STEP_STATE_COPY.collect.headline}</strong>
|
||||
{" "}
|
||||
{RECTIFICATION_USER_COPY.continueCollectFallback}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
import { RectificationVerificationReport } from "@/components/rectification-verification-report";
|
||||
|
||||
export function RectificationRangeDelivery({
|
||||
result,
|
||||
acceptingCandidateId,
|
||||
readonly,
|
||||
onAccept,
|
||||
onContinue,
|
||||
}: Readonly<{
|
||||
result: RectificationCandidateResult;
|
||||
acceptingCandidateId: string | null;
|
||||
readonly: boolean;
|
||||
onAccept: (candidateId: string) => void;
|
||||
onContinue: () => void;
|
||||
}>) {
|
||||
const delivery: RangeDeliveryProjection | null = result.rangeDelivery;
|
||||
const [selectedColumnId, setSelectedColumnId] = useState<string | null>(null);
|
||||
const range = delivery?.range ?? result.credibleRange;
|
||||
const representative = delivery?.representative_time ?? result.representativeTime;
|
||||
const selected = Boolean(result.selectedTime);
|
||||
const eventCount = delivery?.event_count ?? 0;
|
||||
const selected = result.selectedTime;
|
||||
const busy = Boolean(acceptingCandidateId) || readonly;
|
||||
const panel = delivery?.divergence;
|
||||
const showPanel = panel?.eligible === true && panel.columns.length >= 2 && !selected;
|
||||
const candidateId = adoptCandidateId(result, selectedColumnId);
|
||||
const adopting = Boolean(acceptingCandidateId);
|
||||
const rows = delivery?.rows ?? [];
|
||||
const title = range
|
||||
? eventCount > 0
|
||||
? `${RECTIFICATION_USER_COPY.rangeDeliveryTitle} ${range[0]}–${range[1]} · ${rangeDeliveryEventCopy(eventCount)}`
|
||||
: `${RECTIFICATION_USER_COPY.rangeDeliveryTitle} ${range[0]}–${range[1]}`
|
||||
: RECTIFICATION_USER_COPY.rangeDeliveryTitle;
|
||||
const markdown = delivery?.verification_markdown
|
||||
?? result.verificationReportMarkdown;
|
||||
|
||||
return (
|
||||
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正区间">
|
||||
<div className="rectification-candidates-heading">
|
||||
<strong>{RECTIFICATION_USER_COPY.rangeDeliveryTitle}</strong>
|
||||
{range ? (
|
||||
<span className="rectification-range-delivery__range">{range[0]}–{range[1]}</span>
|
||||
) : null}
|
||||
<strong>{title}</strong>
|
||||
</div>
|
||||
<dl className="rectification-range-delivery__facts">
|
||||
{representative ? (
|
||||
<>
|
||||
<dt>{RECTIFICATION_USER_COPY.rangeDeliveryRepresentativeLabel}</dt>
|
||||
<dd>{representative}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{(delivery?.event_count ?? 0) > 0 ? (
|
||||
<>
|
||||
<dt>{RECTIFICATION_USER_COPY.rangeDeliveryEventsLabel}</dt>
|
||||
<dd>{rangeDeliveryEventCopy(delivery!.event_count)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{(delivery?.stable_themes.length ?? 0) > 0 ? (
|
||||
<>
|
||||
<dt>{RECTIFICATION_USER_COPY.rangeDeliveryStableLabel}</dt>
|
||||
<dd>{rangeDeliveryStableCopy(delivery!.stable_themes)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{(delivery?.sensitive_themes.length ?? 0) > 0 ? (
|
||||
<>
|
||||
<dt>{RECTIFICATION_USER_COPY.rangeDeliverySensitiveLabel}</dt>
|
||||
<dd>{rangeDeliverySensitiveCopy(delivery!.sensitive_themes)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
<ul className="rectification-range-delivery__rows">
|
||||
{rows.map((row) => {
|
||||
const adopted = selected === row.time;
|
||||
const adopting = acceptingCandidateId === row.candidate_id;
|
||||
return (
|
||||
<li key={row.candidate_id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`rectification-range-delivery__row${row.representative ? " is-representative" : ""}${adopted ? " is-adopted" : ""}`}
|
||||
aria-pressed={adopted}
|
||||
disabled={busy}
|
||||
onClick={() => onAccept(row.candidate_id)}
|
||||
>
|
||||
<span className="rectification-range-delivery__time">
|
||||
{row.time}
|
||||
{row.representative ? ` · ${RECTIFICATION_USER_COPY.rangeDeliveryRepresentativeLabel}` : ""}
|
||||
</span>
|
||||
<span className="rectification-range-delivery__diff">
|
||||
{row.difference_label}
|
||||
</span>
|
||||
<span className="rectification-range-delivery__status" role="status">
|
||||
{adopting
|
||||
? RECTIFICATION_USER_COPY.rangeDeliveryAdopting
|
||||
: adopted
|
||||
? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="rectification-range-delivery__boundary">
|
||||
{delivery?.boundary ?? REPRESENTATIVE_MINUTE_DISCLAIMER}
|
||||
</p>
|
||||
{showPanel && (
|
||||
<div className="rectification-divergence">
|
||||
<p className="rectification-divergence__title">{RECTIFICATION_USER_COPY.divergenceTitle}</p>
|
||||
<div className="rectification-divergence-list">
|
||||
{panel.columns.map((column) => {
|
||||
const id = column.representative_candidate_id;
|
||||
const active = selectedColumnId === id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${column.cluster_range[0]}-${column.cluster_range[1]}`}
|
||||
className={`rectification-divergence-column${active ? " is-selected" : ""}`}
|
||||
aria-pressed={active}
|
||||
disabled={busy}
|
||||
onClick={() => setSelectedColumnId(active ? null : id)}
|
||||
>
|
||||
<strong>{column.cluster_range[0]}–{column.cluster_range[1]} 这段</strong>
|
||||
{column.rows.map((row) => (
|
||||
<span key={`${row.layer}-${row.sign}`}>{row.label}</span>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rectification-divergence-clear">
|
||||
<button
|
||||
type="button"
|
||||
className="rectification-range-delivery__quiet"
|
||||
disabled={busy || selectedColumnId === null}
|
||||
onClick={() => setSelectedColumnId(null)}
|
||||
>
|
||||
{RECTIFICATION_USER_COPY.divergenceUnlike}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rectification-range-delivery__quiet"
|
||||
disabled={busy || selectedColumnId === null}
|
||||
onClick={() => setSelectedColumnId(null)}
|
||||
>
|
||||
{RECTIFICATION_USER_COPY.divergenceUnsure}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rectification-range-delivery__actions">
|
||||
{selected ? (
|
||||
<p className="rectification-range-delivery__adopted" role="status">已采用</p>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rectification-range-delivery__adopt"
|
||||
disabled={busy || !candidateId}
|
||||
onClick={() => candidateId && onAccept(candidateId)}
|
||||
>
|
||||
{adopting ? "正在采用…" : RECTIFICATION_USER_COPY.rangeDeliveryAdopt}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rectification-range-delivery__continue"
|
||||
disabled={busy}
|
||||
onClick={onContinue}
|
||||
>
|
||||
{RECTIFICATION_USER_COPY.rangeDeliveryContinue}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<RectificationVerificationReport markdown={markdown} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { renderChatMarkdown } from "@/components/chat-markdown-view";
|
||||
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
|
||||
|
||||
export function RectificationVerificationReport({
|
||||
markdown,
|
||||
}: Readonly<{ markdown: string | null | undefined }>) {
|
||||
const body = markdown?.trim() ?? "";
|
||||
if (!body) return null;
|
||||
return (
|
||||
<details className="rectification-verification-report">
|
||||
<summary>{RECTIFICATION_USER_COPY.verificationReportSummary}</summary>
|
||||
<div className="rectification-verification-report__body">
|
||||
{renderChatMarkdown(body)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user