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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user