fix(rectification): make every gap a live row with retries, give an empty Case a start, and say why a run stopped

The chat used to show "当前没有可回答的问题,正在等待服务端更新" / "当前问题暂时无法显示"
/ "题目加载失败,请刷新" as bare copy above the composer. The gap between a
settled turn and its next question is now a state: `preparing` renders the
same timeline live row the reply uses ("正在准备下一个问题…") and refetches the
Case on a 2s timer up to two retries; `unavailable` renders one line and a
44px reload button. A snapshot that never arrived at hydration is the same
gap. A resumed Case with no turns and no server-started opening shows
"这段校正还没有开始。" with one 开始提问 action. Stopping a run keeps what
streamed and says so instead of "暂时不可用"; the opening turn's live row
names what is happening; a 402 explains itself for 600ms before leaving.

BUG-505, BUG-507

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-03 06:56:17 +00:00
parent 53017d4153
commit 360989ca2c
6 changed files with 169 additions and 50 deletions
+28 -4
View File
@@ -2931,11 +2931,35 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-weight: 600;
line-height: 1.55;
}
.rectification-composer-status {
margin: 0 0 var(--space-2);
color: var(--color-ink-secondary);
font-size: var(--type-caption);
/* The gap between a settled turn and its next question, as the last entry of the
transcript: a live timeline row while refetching, then copy and a reload. */
.rectification-question-gap {
display: grid;
gap: var(--space-2);
justify-items: start;
margin: var(--space-2) 0 var(--space-3);
margin-inline-start: var(--assistant-content-inset);
}
.rectification-question-gap__copy {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
/* The standalone live row has no summary above it. */
.consultation-run-timeline__list.is-standalone { margin-top: 0; }
/* A Case with nothing to show and no automatic opening: one line and one way to start. */
.rectification-empty-state {
display: grid;
gap: var(--space-3);
justify-items: start;
margin: var(--space-4) 0;
margin-inline-start: var(--assistant-content-inset);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.rectification-empty-state p { margin: 0; }
.rectification-snapshot {
display: grid;
gap: var(--space-3);
@@ -83,6 +83,20 @@ export function ConsultationRunTimeline({
);
}
/**
* One live step outside a reply's timeline the rectification surface uses it
* for the gap between a settled turn and its next question. It is the same
* row as inside the timeline (marker, spinner, shimmer label), not a second
* waiting vocabulary.
*/
export function ConsultationTimelineLiveRow({ label, id = "live" }: Readonly<{ label: string; id?: string }>) {
return (
<ol className="agent-thinking-timeline consultation-run-timeline__list is-standalone" aria-label="进行中">
<TimelineRow row={{ id, kind: "write", status: "live", label }} />
</ol>
);
}
function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
const KindIcon = KIND_ICONS[row.kind];
const expandable = Boolean(
@@ -43,7 +43,22 @@ import {
} from "@/lib/rectification-board-model";
import { membershipHref } from "@/lib/membership";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import type { RectificationCaseSnapshotPayload } from "@/lib/rectification-surface-state";
import {
RECTIFICATION_EMPTY_ACTION_LABEL,
RECTIFICATION_EMPTY_COPY,
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS,
RECTIFICATION_QUESTION_PREPARING_LABEL,
RECTIFICATION_QUESTION_RELOAD_LABEL,
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
RECTIFICATION_STOPPED_NOTICE,
rectificationConversationState,
rectificationInitialLiveLabel,
rectificationQuestionGapState,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
isPublicRectificationActivity,
@@ -63,9 +78,11 @@ import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseSta
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";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
import { CharacterRemaining } from "./character-remaining";
import { ChatComposer } from "./chat-composer";
import { ChatMessageRow } from "./chat-message-row";
import { ConsultationTimelineLiveRow } from "./consultation-run-timeline";
import { JumpToLatestButton } from "./jump-to-latest-button";
import {
ChatMessageActions,
@@ -451,6 +468,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [openingRequested, setOpeningRequested] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
@@ -525,6 +544,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const setPending = useCallback((value: boolean) => {
setBusy(value);
// A turn starting is a fresh chance for the next question to arrive.
if (value) setQuestionRetryAttempts(0);
onPendingChange?.(value);
}, [onPendingChange]);
@@ -622,6 +643,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
if (nextQuestion !== null) setQuestionRetryAttempts(0);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
@@ -685,7 +707,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if ((action === "message" && !trimmed) || busy || readonly) return;
setError("");
setPending(true);
beginLiveRun("正在处理…");
const initialLabel = rectificationInitialLiveLabel(action);
beginLiveRun(initialLabel);
keyCounter.current += 1;
const requestId = globalThis.crypto.randomUUID();
@@ -703,7 +726,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: {
phase: "evidence-validation",
label: "正在处理…",
label: initialLabel,
startedAt: Date.now(),
} },
]);
@@ -716,7 +739,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
let completedTurnId: string | undefined;
let currentActivity: AgentActivityView | undefined = {
phase: "evidence-validation",
label: "正在处理…",
label: initialLabel,
startedAt: Date.now(),
};
// Every stream event lands in `frames`; it commits at most once per animation
@@ -765,7 +788,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return;
}
if (response.status === 402) {
window.location.assign(membershipHref("rectification"));
// Say why before the page leaves, instead of vanishing mid-chat.
setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE);
window.setTimeout(() => {
window.location.assign(membershipHref("rectification"));
}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS);
return;
}
if (response.status === 401) setError("请先登录。");
@@ -970,6 +997,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
return [];
}));
// The reader pressed stop: what streamed stays, and the notice says so
// rather than blaming the service.
if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE);
return;
}
setError("生时校正暂时不可用,请稍后再试。");
@@ -1328,33 +1358,63 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& message.question.focus_id === currentQuestion.focus_id
&& !questionIsAnswered(message.question)
));
const questionLoadFailed = questionSource === "unavailable";
const showMissingQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& currentQuestion === null
&& !questionLoadFailed,
);
const showUnavailableQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& currentQuestion !== null
&& !liveQuestionOnMessages
&& !questionLoadFailed,
);
const showQuestionLoadFailed = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& questionLoadFailed,
);
// The gap between a settled turn and its next question has two visible
// states: `preparing` (one live timeline row, timed refetches) and
// `unavailable` (copy and a reload button) — never bare copy telling the
// reader to wait for the server.
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable",
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
busy,
readonly,
regenerating: regeneratingMessageKey !== null,
snapshotLoaded: caseSnapshotLoaded,
resumableCase,
retryAttempts: questionRetryAttempts,
retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT,
});
const conversationState = rectificationConversationState({
messageCount: messages.length,
busy,
readonly,
shouldStartOpening,
openingStarted: openingRequested,
});
const canSend = !busy && !readonly && !regeneratingMessageKey;
// Question recovery: the turn already refetched once on completion; while
// the gap is `preparing`, refetch on a timer up to the retry limit, then
// hand the reader a reload button. Attempts reset once a question arrives
// or another turn starts (both in handlers, never in an effect).
useVisibilityAwarePoll({
enabled: questionGap === "preparing",
intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
refreshOnVisible: false,
onPoll: () => {
setQuestionRetryAttempts((current) => current + 1);
void refetchQuestion();
},
});
async function refetchQuestion() {
await loadCaseSnapshot();
}
function reloadQuestion() {
setQuestionRetryAttempts(0);
void refetchQuestion();
}
// A Case with no turns and no automatic first turn (the server never starts
// an opening for a resumed Case) needs a way to begin; the server suppresses
// a duplicate opening, so this is safe to press twice.
function startOpeningManually() {
if (readonly || busy || openingStarted.current) return;
openingStarted.current = true;
setOpeningRequested(true);
void send("opening", "");
}
function submitChoice(key: ChoiceKey) {
if (!choiceCard) return;
void submitStructuredChoice(CHOICE_ACTION, key);
@@ -1403,6 +1463,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{pendingConsultationQuestion.trim()}
</p>
)}
{conversationState === "empty" && (
<div className="rectification-empty-state">
<p>{RECTIFICATION_EMPTY_COPY}</p>
<Button type="button" onClick={startOpeningManually}>{RECTIFICATION_EMPTY_ACTION_LABEL}</Button>
</div>
)}
{messages.map((message) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
@@ -1527,6 +1593,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
);
})}
{questionGap === "preparing" && (
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap">
<ConsultationTimelineLiveRow id="question-preparing" label={RECTIFICATION_QUESTION_PREPARING_LABEL} />
</div>
)}
{questionGap === "unavailable" && (
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
<Button type="button" variant="outline" onClick={reloadQuestion}>
{RECTIFICATION_QUESTION_RELOAD_LABEL}
</Button>
</div>
)}
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
{savedTime}
@@ -1564,15 +1643,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<span className="rectification-adopt-status__hint"></span>
</div>
)}
{(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && (
<p className="rectification-composer-status" role="status">
{showQuestionLoadFailed
? "题目加载失败,请刷新。"
: showMissingQuestion
? "当前没有可回答的问题,正在等待服务端更新。"
: "当前问题暂时无法显示,请等待服务端更新。"}
</p>
)}
<ChatComposer
inputRef={composer}
value={draft}
@@ -228,6 +228,8 @@ export type RectificationQuestionGapInput = Readonly<{
questionMissing: boolean;
/** The server said the question could not be loaded (`question_source: "unavailable"`). */
questionLoadFailed: boolean;
/** Candidate cards are offered and not yet adopted: the reader, not the server, holds the next move. */
offerAwaitingReader?: boolean;
busy: boolean;
readonly: boolean;
regenerating: boolean;
@@ -251,7 +253,7 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
const retryGate = input.retryAttempts < limit ? "preparing" : "unavailable";
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible) return "idle";
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
if (input.questionLoadFailed) return "unavailable";
// Either the snapshot names no question, or it names one that no settled
// message carries live yet: both are a gap the next read may close.
@@ -133,8 +133,12 @@ test("one assistant article owns the stem and options; the slot class is gone",
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
assert.match(chat, /role="status"/);
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
assert.match(chat, /题目加载失败,请刷新/);
// Was: /当前没有可回答的问题,正在等待服务端更新/ and /题目加载失败,请刷新/ — bare copy
// that told the reader to wait for the server with nothing happening. The gap is
// now a live timeline row with timed refetches, then copy and a reload (BUG-505).
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
assert.match(chat, /RECTIFICATION_QUESTION_PREPARING_LABEL/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /question_source/);
});
@@ -144,12 +144,17 @@ test("collect_spoken stem lives on turn.question inside the same assistant artic
test("missing current_question is explicit only for resumable cases", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/);
// Was: `const showMissingQuestion = Boolean(... caseSnapshotLoaded ... resumableCase ...
// currentQuestion === null` plus the copy /当前没有可回答的问题,正在等待服务端更新/ and
// /题目加载失败,请刷新/. Those were bare waiting sentences; the same inputs now feed
// `rectificationQuestionGapState`, which yields a live row with retries and then a
// reload button, and is still gated on a resumable case (BUG-505).
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{[\s\S]*questionMissing: currentQuestion === null,[\s\S]*snapshotLoaded: caseSnapshotLoaded,[\s\S]*resumableCase,/);
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
assert.match(chat, /题目加载失败,请刷新/);
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /readonly && \(/);
assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
assert.doesNotMatch(chat, /questionGap[\s\S]*caseStatus.*TERMINAL/);
});
test("choice options render inside the same assistant message, not a sibling slot", () => {