fix(web): keep composer enabled while generating and treat stop as neutral (BUG-551, BUG-552)

Enter now queues one follow-up instead of dropping it, and a rectification stop leaves the streamed reply with a grey notice instead of an error alert.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 13:08:43 +08:00
co-authored by Cursor
parent f5566c06ef
commit 055b7adca9
18 changed files with 534 additions and 74 deletions
+46 -30
View File
@@ -11,6 +11,7 @@ import {
serverComposerDraftSnapshot,
subscribeComposerDraft,
} from "@/lib/composer-draft";
import { COMPOSER_QUEUE_LABEL, COMPOSER_QUEUE_RECALL_LABEL } from "@/lib/queued-draft";
export const composerRemainingId = "composer-character-remaining";
@@ -41,6 +42,7 @@ type ChatComposerProps = {
readonly stopVisible: boolean;
readonly stopLabel: string;
readonly stopTitle: string;
readonly queued?: { text: string; onRecall: () => void };
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
readonly onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
@@ -61,6 +63,7 @@ export function ChatComposer({
stopVisible,
stopLabel,
stopTitle,
queued,
onSubmit,
onChange,
onKeyDown,
@@ -72,35 +75,48 @@ export function ChatComposer({
const describedByIds = [describedBy, showRemaining ? remainingId : undefined].filter(Boolean).join(" ") || undefined;
return (
<form className="composer" onSubmit={onSubmit}>
<Textarea
ref={inputRef}
aria-label={inputLabel}
aria-describedby={describedByIds}
placeholder={placeholder}
rows={1}
maxLength={maxLength}
disabled={inputDisabled}
value={draft}
onChange={onChange}
onKeyDown={onKeyDown}
/>
{stopVisible ? (
<Button
className="composer-stop"
aria-label={stopLabel}
title={stopTitle}
size="icon"
type="button"
onClick={onStop}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
<>
{queued ? (
<div className="composer-queue" role="status">
<div className="composer-queue__body">
<p className="composer-queue__label">{COMPOSER_QUEUE_LABEL}</p>
<p className="composer-queue__text">{queued.text}</p>
</div>
<button className="composer-queue__recall" type="button" onClick={queued.onRecall}>
{COMPOSER_QUEUE_RECALL_LABEL}
</button>
</div>
) : null}
<form className="composer" onSubmit={onSubmit}>
<Textarea
ref={inputRef}
aria-label={inputLabel}
aria-describedby={describedByIds}
placeholder={placeholder}
rows={1}
maxLength={maxLength}
disabled={inputDisabled}
value={draft}
onChange={onChange}
onKeyDown={onKeyDown}
/>
{stopVisible ? (
<Button
className="composer-stop"
aria-label={stopLabel}
title={stopTitle}
size="icon"
type="button"
onClick={onStop}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
</>
);
}
@@ -40,11 +40,13 @@ export function ChatMessageRow({
showActivity = message.state !== "settled",
vargaSentence,
afterAnswer,
stoppedNotice,
}: Readonly<{
message: ChatMessageView;
showActivity?: boolean;
vargaSentence?: string | null;
afterAnswer?: ReactNode;
stoppedNotice?: string;
}>) {
const messageRow = useRef<HTMLElement>(null);
const assistantLabel = message.state === "thinking"
@@ -163,6 +165,7 @@ export function ChatMessageRow({
</>
)
) : <><p>{message.text}</p>{afterAnswer}</>}
{stoppedNotice ? <p className="message-stopped-notice">{stoppedNotice}</p> : null}
</div>
</div>
</article>
@@ -1,6 +1,8 @@
"use client";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { useQueuedMessage } from "@/hooks/use-queued-message";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply";
import { nextActivityView, type AgentActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
@@ -56,6 +58,7 @@ import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
RECTIFICATION_STOPPED_NOTICE,
isAbortError,
rectificationConversationState,
rectificationInitialLiveLabel,
rectificationQuestionGapState,
@@ -248,6 +251,7 @@ type RenderMessage = ChatMessageView & {
renderKey: string;
completedReceipt?: CompletedActivityReceiptView;
failed?: boolean;
stopped?: boolean;
turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[];
question?: TurnQuestion;
@@ -481,6 +485,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
const [draft, setDraft] = useState("");
const queued = useQueuedMessage();
const lastRunOutcome = useRef<"succeeded" | "stopped" | "failed" | "readonly" | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [savedTime, setSavedTime] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null);
@@ -761,9 +767,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: []),
liveRow,
]));
setDraft("");
let raw = "";
let runOutcome: "succeeded" | "stopped" | "failed" = "failed";
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
@@ -997,6 +1002,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setError((current) => current || userFacingRunFailure("run_failed"));
}
if (succeeded) {
runOutcome = "succeeded";
const snapshot = await loadCaseSnapshot();
if (snapshot?.turns.length) {
setMessages((current) => mergeTurnQuestions(current, snapshot.turns));
@@ -1009,28 +1015,23 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
} catch (caught) {
frames.settle();
const aborted = caught instanceof DOMException
? caught.name === "AbortError"
: caught instanceof Error && caught.name === "AbortError";
const aborted = isAbortError(caught);
if (aborted) {
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
if (raw.trim() || hasActivityReceipt(completedReceipt)) {
return [{
...message,
text: raw,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
failed: true,
turnId: completedTurnId,
}];
}
return [];
runOutcome = "stopped";
setMessages((current) => current.map((message) => {
if (message.renderKey !== assistantRenderKey) return message;
return {
...message,
text: raw,
activityTrace: completeActivityTrace(activityTrace),
state: "settled" as const,
completedReceipt,
failed: false,
stopped: true,
turnId: completedTurnId,
activity: undefined,
};
}));
// 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("生时校正暂时不可用,请稍后再试。");
@@ -1049,10 +1050,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
} finally {
frames.dispose();
if (runAbort.current === abortController) runAbort.current = null;
lastRunOutcome.current = readonly ? "readonly" : runOutcome;
setPending(false);
}
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (busy) return;
const outcome = lastRunOutcome.current;
lastRunOutcome.current = null;
if (!outcome || !queued.text) return;
const next = queued.take();
if (!next) return;
if (queuedDraftSettleAction(outcome) === "send" && !readonly) {
void send("message", next);
return;
}
setDraft((current) => appendQueuedText(current, next));
}, [busy, readonly, send]);
const actionIdForChoice = useCallback((focusId: string, optionId: ChoiceOptionId) => {
const key = stableChoiceActionKey(focusId, optionId);
const existing = choiceActionIds.current.get(key);
@@ -1105,10 +1121,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
},
]);
setPending(true);
const abortController = new AbortController();
runAbort.current = abortController;
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
signal: abortController.signal,
body: JSON.stringify({
caseId,
sessionId,
@@ -1175,11 +1194,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{ role: "assistant", text: narration },
]);
}
} catch {
} catch (caught) {
if (isAbortError(caught)) {
lastRunOutcome.current = "stopped";
setMessages((current) => current.map((message) => (
message.renderKey !== assistantRenderKey ? message : {
...message,
state: "settled" as const,
failed: false,
stopped: true,
activity: undefined,
}
)));
return;
}
lastRunOutcome.current = "failed";
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setChoiceNonce((current) => current + 1);
setError("选择题处理失败,请稍后重试。");
} finally {
if (runAbort.current === abortController) runAbort.current = null;
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
setPending(false);
}
}, [
@@ -1230,12 +1265,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
},
]);
const abortController = new AbortController();
runAbort.current = abortController;
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
{
method: "POST",
headers: { "content-type": "application/json" },
signal: abortController.signal,
body: JSON.stringify({
sessionId,
resultId: candidateResult.resultId,
@@ -1279,9 +1317,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
await loadCaseSnapshot();
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel });
} catch (caught) {
if (isAbortError(caught)) {
lastRunOutcome.current = "stopped";
setMessages((current) => current.map((message) => (
message.renderKey !== assistantRenderKey ? message : {
...message,
state: "settled" as const,
failed: false,
stopped: true,
activity: undefined,
}
)));
return;
}
lastRunOutcome.current = "failed";
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
if (runAbort.current === abortController) runAbort.current = null;
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
setAcceptingCandidateId(null);
setPending(false);
}
@@ -1336,7 +1390,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
async function submit(event: React.FormEvent) {
event.preventDefault();
await send("message", draft);
if (readonly) return;
if (busy || regeneratingMessageKey) {
if (queued.enqueue(draft)) setDraft("");
return;
}
const text = draft;
setDraft("");
await send("message", text);
}
const latestRegeneratableKey = [...messages]
@@ -1427,8 +1488,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
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
@@ -1604,6 +1663,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
showActivity={displayedMessage.state !== "settled"}
vargaSentence={vargaSentence}
afterAnswer={afterAnswer}
stoppedNotice={message.stopped ? RECTIFICATION_STOPPED_NOTICE : undefined}
/>
)}
{showActions && !regenerating && (
@@ -1701,12 +1761,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? "请回答上面的问题…"
: "继续说你记得的人生经历,或回答刚才的问题…"}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
inputDisabled={!canSend}
inputDisabled={readonly}
submitLabel="发送"
submitBlocked={!canSend}
submitBlocked={readonly || Boolean(queued.text)}
stopVisible={busy}
stopLabel="停止回答"
stopTitle="停止当前推理,已生成内容会保留"
queued={queued.text ? { text: queued.text, onRecall: () => {
const recalled = queued.take();
if (!recalled) return;
setDraft(appendQueuedText(recalled, draft));
composer.current?.focus();
} } : undefined}
onSubmit={submit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {