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
@@ -1,7 +1,12 @@
"use client";
import { useEffect } from "react";
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
import { composerDraftSnapshot } from "@/lib/composer-draft";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { useQueuedMessage } from "@/hooks/use-queued-message";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle } from "@/lib/agent-reply";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
@@ -95,6 +100,7 @@ export type ConsultationRunParams = {
consultationReplayStarted: MutableRefObject<string | null>;
consultationStatusMissingCount: MutableRefObject<number>;
conversationAnchor: ConversationAnchor;
consultationPhase: "undo" | "streaming" | "recovering" | null;
isLoading: boolean;
modelCatalog: PublicLanguageModelCatalog | null;
onboardingJustCompleted: boolean;
@@ -115,6 +121,7 @@ export type ConsultationRunParams = {
setPendingRequestId: Dispatch<SetStateAction<string | null>>;
setPendingSessionId: Dispatch<SetStateAction<string | null>>;
setProfileNotice: Dispatch<SetStateAction<string>>;
replyOutcome: ReplyOutcome | null;
setReplyOutcome: Dispatch<SetStateAction<ReplyOutcome | null>>;
setRequestError: Dispatch<SetStateAction<RequestError | null>>;
setSessionFullPrompt: Dispatch<SetStateAction<{ question: string; theme: Theme } | null>>;
@@ -151,6 +158,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
consultationReplayStarted,
consultationStatusMissingCount,
conversationAnchor,
consultationPhase,
isLoading,
modelCatalog,
onboardingJustCompleted,
@@ -171,6 +179,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
setPendingRequestId,
setPendingSessionId,
setProfileNotice,
replyOutcome,
setReplyOutcome,
setRequestError,
setSessionFullPrompt,
@@ -189,6 +198,19 @@ export function useConsultationRun(params: ConsultationRunParams) {
openAccountDialog,
openRectificationFromHomepage,
} = params;
const queued = useQueuedMessage();
function enqueueQueuedDraft(incoming: string) {
if (queued.enqueue(incoming)) setDraft("");
}
function recallQueuedDraft() {
const recalled = queued.take();
if (!recalled) return;
setDraft(appendQueuedText(composerDraftSnapshot(), recalled));
composerInput.current?.focus();
}
function restoreConsultationRecovery(
session: ChatSession,
@@ -1066,6 +1088,24 @@ export function useConsultationRun(params: ConsultationRunParams) {
setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
});
};
useEffect(() => {
if (!queued.text) return;
const settlePhase = consultationPhase === "recovering"
? "recovering" as const
: replyOutcome?.phase === "completed" || replyOutcome?.phase === "stopped" || replyOutcome?.phase === "failed"
? replyOutcome.phase
: null;
if (!settlePhase) return;
if (settlePhase === "completed" && (isLoading || cancellationPending)) return;
const next = queued.take();
if (!next) return;
if (queuedDraftSettleAction(settlePhase) === "send") {
void send(next);
return;
}
setDraft(appendQueuedText(composerDraftSnapshot(), next));
}, [cancellationPending, consultationPhase, isLoading, queued.text, replyOutcome?.phase]);
return {
restoreConsultationRecovery,
requestCancellation,
@@ -1074,5 +1114,8 @@ export function useConsultationRun(params: ConsultationRunParams) {
completeConsultationInterface,
send,
regenerateLatestAnswer,
queuedDraft: queued.text,
enqueueQueuedDraft,
recallQueuedDraft,
};
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { appendQueuedText } from "@/lib/queued-draft";
export function useQueuedMessage() {
const [text, setText] = useState("");
const textRef = useRef("");
const enqueue = useCallback((incoming: string) => {
const nextIncoming = incoming.trim();
if (!nextIncoming) return false;
const next = appendQueuedText(textRef.current, nextIncoming);
textRef.current = next;
setText(next);
return true;
}, []);
const take = useCallback(() => {
const current = textRef.current;
if (!current) return "";
textRef.current = "";
setText("");
return current;
}, []);
return { text, enqueue, take };
}