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);
const lastGeneratedForAiIdRef = useRef<string | null>(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();
}
}}
/>