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