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.
This commit is contained in:
Ryker_Feng 2026-08-02 09:17:26 +08:00 committed by GitHub
parent 4795452102
commit c0f1cfef69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -417,6 +417,9 @@ export function InputBox({
useState<string | null>(null); useState<string | null>(null);
const lastGeneratedForAiIdRef = useRef<string | null>(null); const lastGeneratedForAiIdRef = useRef<string | null>(null);
const wasStreamingRef = useRef(false); 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 messagesRef = useRef(thread.messages);
const clearVoiceRestartTimer = useCallback(() => { 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( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (status === "streaming") { if (status === "streaming") {
@ -1200,7 +1213,7 @@ export function InputBox({
return handleCompactCommand(); return handleCompactCommand();
} }
if (submitAction.kind === "stop") { if (submitAction.kind === "stop") {
onStop?.(); handleStopStreaming();
return; return;
} }
if (submitAction.kind === "empty") { if (submitAction.kind === "empty") {
@ -1215,7 +1228,7 @@ export function InputBox({
abortVoiceInput, abortVoiceInput,
handleCompactCommand, handleCompactCommand,
handleGoalCommand, handleGoalCommand,
onStop, handleStopStreaming,
selectedSlashSkill, selectedSlashSkill,
status, status,
submitThreadMessage, submitThreadMessage,
@ -1923,6 +1936,10 @@ export function InputBox({
!showSkillSuggestions && !showSkillSuggestions &&
!selectedSlashSkill && !selectedSlashSkill &&
!followupsHidden && !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); (followupsLoading || followups.length > 0);
useEffect(() => { useEffect(() => {
@ -1945,6 +1962,13 @@ export function InputBox({
return; 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) { if (disabled || isMock) {
return; return;
} }
@ -2666,7 +2690,7 @@ export function InputBox({
onClick={(e) => { onClick={(e) => {
if (status === "streaming") { if (status === "streaming") {
e.preventDefault(); e.preventDefault();
onStop?.(); handleStopStreaming();
} }
}} }}
/> />