fix(chat): keep rectification sessions findable and keep the delivery card
Independent Staging Quality Gate / validate (push) Failing after 6m45s
Independent Staging Quality Gate / publish (push) Skipped

Rectification answers now advance chat_sessions.updated_at (BUG-704).
A ?c= id missing from the loaded page is fetched before anyone may call
it deleted (BUG-705). The range card no longer has a post-card tie-break
button; live questions leave the card visible with adopt locked
(BUG-706/708). Spoken copy bans 相对支持度 (BUG-709).

Task docs assigned 700-704; qizheng already took 700-703.
This commit is contained in:
jesse-ux
2026-09-15 17:10:53 +08:00
parent d3a2c48ba5
commit f51e494c5a
37 changed files with 799 additions and 177 deletions
@@ -97,7 +97,7 @@ import {
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { RECTIFICATION_USER_COPY, postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
import { 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";
@@ -1343,45 +1343,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
const requestTieBreak = useCallback(async () => {
if (!candidateResult || acceptingCandidateId || busy || readonly) return;
if (!candidateResult.rangeDelivery?.tie_break_available) return;
setError("");
setPending(true);
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/tie-break`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sessionId,
requestId: globalThis.crypto.randomUUID(),
}),
},
);
const payload = await response.json().catch(() => null);
if (payload?.code === "tie_break_unavailable") {
await loadCaseSnapshot();
return;
}
if (!response.ok || payload?.ok !== true) {
throw new Error(payload?.error || payload?.message || "暂时无法开始参考题");
}
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法开始参考题");
} finally {
setPending(false);
}
}, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, readonly, sessionId, setPending]);
async function copyMessage(message: RenderMessage) {
try {
await navigator.clipboard.writeText(copyTextForMessage(message.text, message.question));
@@ -1509,15 +1470,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const keepSelectionCardsWhileBusy = Boolean(
acceptingCandidateId || candidateResult?.selectedTime,
);
const liveInterviewBlockingAdopt = Boolean(
candidateResult
&& !candidateResult.selectedTime
&& messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, false)
)),
);
const showSelectionCards = Boolean(
candidateResult
&& caseSnapshotLoaded
&& (!busy || keepSelectionCardsWhileBusy)
&& selectionCardMessageKey
&& (canOfferCards || Boolean(candidateResult.selectedTime))
&& !messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, Boolean(candidateResult.selectedTime))
)),
);
const showReadonlyRange = Boolean(
canShowRectificationReadonlyRange(candidateResult)
@@ -1843,7 +1808,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
acceptingCandidateId={acceptingCandidateId}
readonly={readonly || regeneratingMessageKey !== null || (busy && !acceptingCandidateId)}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
onTieBreak={() => void requestTieBreak()}
adoptLocked={liveInterviewBlockingAdopt}
/>
{verifiedIdleCopy && (
<p className="rectification-pending-note" role="status">
@@ -21,19 +21,19 @@ export function RectificationRangeDelivery({
acceptingCandidateId,
readonly,
onAccept,
onTieBreak,
adoptLocked,
}: Readonly<{
result: RectificationCandidateResult;
acceptingCandidateId: string | null;
readonly: boolean;
onAccept: (candidateId: string) => void;
onTieBreak?: () => void;
adoptLocked?: boolean;
}>) {
const delivery: RangeDeliveryProjection | null = result.rangeDelivery;
const range = delivery?.range ?? result.credibleRange;
const eventCount = delivery?.event_count ?? 0;
const selected = result.selectedTime;
const busy = Boolean(acceptingCandidateId) || readonly;
const busy = Boolean(acceptingCandidateId) || readonly || adoptLocked;
const columns = delivery?.columns ?? [];
const title = range
? eventCount > 0
@@ -68,15 +68,10 @@ export function RectificationRangeDelivery({
{delivery?.provenance_line ? (
<p className="rectification-range-delivery__provenance">{delivery.provenance_line}</p>
) : null}
{delivery?.tie_break_available && onTieBreak && !readonly ? (
<button
type="button"
className="rectification-range-delivery__tie-break"
disabled={busy}
onClick={onTieBreak}
>
{RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry}
</button>
{adoptLocked ? (
<p className="rectification-range-delivery__narrow">
{RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked}
</p>
) : null}
{delivery?.tie_break_note ? (
<p className="rectification-range-delivery__narrow">{delivery.tie_break_note}</p>