From c0f1cfef690d69bc6a21b36415dfad2217cd5a18 Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:17:26 +0800 Subject: [PATCH] fix(frontend): hide stale follow-up chips while a turn is streaming (#3395) (#3396) * fix(frontend): hide stale follow-up chips while a turn is streaming (#3395) Follow-up suggestion chips are generated when a turn finishes streaming, but `showFollowups` did not exclude the streaming state. If a user sent a new message before the previous response finished, the old chips (and the lone close button) stayed mounted and overlapped the To-dos panel and the input box. Gate `showFollowups` on `status !== "streaming"` so stale chips are never shown while a response is in progress. * fix(frontend): suppress follow-up suggestions for user-interrupted turns (#3395) Gating showFollowups on status alone was not enough: stopping a streaming turn (or sending a new message mid-stream, which also stops it) flips status back to a non-streaming state and triggers the follow-up generation effect on that streaming->ready transition, producing chips for a half-finished, interrupted response. Track user interruption with a ref set in the stop path, and have the generation effect skip that transition (and clear/hide any pending chips), so follow-ups are only generated for turns that finished on their own. --- .../src/components/workspace/input-box.tsx | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 7c75ba605..dad469888 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -417,6 +417,9 @@ export function InputBox({ useState(null); const lastGeneratedForAiIdRef = useRef(null); const wasStreamingRef = useRef(false); + // Set when the user stops a streaming turn. Such a turn ends on a + // half-finished response, so we must NOT generate follow-up suggestions for it. + const stoppedByUserRef = useRef(false); const messagesRef = useRef(thread.messages); const clearVoiceRestartTimer = useCallback(() => { @@ -1148,6 +1151,16 @@ export function InputBox({ ], ); + const handleStopStreaming = useCallback(() => { + // Mark the in-progress turn as user-interrupted so the next + // streaming->ready transition does not suggest follow-ups for it. + stoppedByUserRef.current = true; + setFollowups([]); + setFollowupsHidden(true); + setFollowupsLoading(false); + onStop?.(); + }, [onStop]); + const handleSubmit = useCallback( async (message: PromptInputMessage) => { if (status === "streaming") { @@ -1200,7 +1213,7 @@ export function InputBox({ return handleCompactCommand(); } if (submitAction.kind === "stop") { - onStop?.(); + handleStopStreaming(); return; } if (submitAction.kind === "empty") { @@ -1215,7 +1228,7 @@ export function InputBox({ abortVoiceInput, handleCompactCommand, handleGoalCommand, - onStop, + handleStopStreaming, selectedSlashSkill, status, submitThreadMessage, @@ -1923,6 +1936,10 @@ export function InputBox({ !showSkillSuggestions && !selectedSlashSkill && !followupsHidden && + // Never show stale follow-up chips while a turn is streaming: a message + // sent before the previous response finished would otherwise leave the + // old chips (and the lone close button) overlapping the input box. + status !== "streaming" && (followupsLoading || followups.length > 0); useEffect(() => { @@ -1945,6 +1962,13 @@ export function InputBox({ return; } + // The turn was interrupted by the user, so skip generating follow-ups for + // this half-finished response. + if (stoppedByUserRef.current) { + stoppedByUserRef.current = false; + return; + } + if (disabled || isMock) { return; } @@ -2666,7 +2690,7 @@ export function InputBox({ onClick={(e) => { if (status === "streaming") { e.preventDefault(); - onStop?.(); + handleStopStreaming(); } }} />