From 11a3acccd0db1b1c8408e9b7eabdf7a1148cbee2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:58:27 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prevent=20retry=20ban?= =?UTF-8?q?ner=20flicker=20during=20auto-retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/AIView.tsx | 28 ++++- .../utils/messages/retryEligibility.test.ts | 106 ++++++++++++++++++ .../utils/messages/retryEligibility.ts | 33 ++++++ 3 files changed, 163 insertions(+), 4 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 955d6053ab..6840222542 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -16,6 +16,7 @@ import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier"; import { PinnedTodoList } from "./PinnedTodoList"; import { getAutoRetryKey, + getRetryStateKey, VIM_ENABLED_KEY, RIGHT_SIDEBAR_WIDTH_KEY, } from "@/common/constants/storage"; @@ -30,7 +31,10 @@ import { getEditableUserMessageText, } from "@/browser/utils/messages/messageUtils"; import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator"; -import { hasInterruptedStream } from "@/browser/utils/messages/retryEligibility"; +import { + hasInterruptedStream, + shouldKeepRetryBarrierVisibleDuringRetry, +} from "@/browser/utils/messages/retryEligibility"; import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; import { WorkspaceModeAISync } from "@/browser/components/WorkspaceModeAISync"; import { ModeProvider } from "@/browser/contexts/ModeContext"; @@ -40,6 +44,7 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useAutoScroll } from "@/browser/hooks/useAutoScroll"; import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor"; +import type { RetryState } from "@/browser/hooks/useResumeManager"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { @@ -88,6 +93,11 @@ interface AIViewProps { status?: "creating"; } +const defaultRetryState: RetryState = { + attempt: 0, + retryStartTime: 0, +}; + const AIViewInner: React.FC = ({ workspaceId, projectPath, @@ -238,6 +248,16 @@ const AIViewInner: React.FC = ({ } ); + // Retry state - used to stabilize RetryBarrier UI during auto-retry loops + const [retryState] = usePersistedState( + getRetryStateKey(workspaceId), + defaultRetryState, + { + listener: true, + } + ); + const retryAttempt = (retryState ?? defaultRetryState).attempt; + // Vim mode state - needed for keybind selection (Ctrl+C in vim, Esc otherwise) const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true }); @@ -440,8 +460,8 @@ const AIViewInner: React.FC = ({ // Track if last message was interrupted or errored (for RetryBarrier) // Uses same logic as useResumeManager for DRY const showRetryBarrier = workspaceState - ? !workspaceState.canInterrupt && - hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStartTime) + ? hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStartTime) || + shouldKeepRetryBarrierVisibleDuringRetry(workspaceState.messages, retryAttempt) : false; // Handle keyboard shortcuts (using optional refs that are safe even if not initialized) @@ -688,7 +708,7 @@ const AIViewInner: React.FC = ({ )} - + {!showRetryBarrier && } {shouldShowQueuedAgentTaskPrompt && ( { }); }); +describe("shouldKeepRetryBarrierVisibleDuringRetry", () => { + it("returns false when attempt is 0", () => { + const messages: DisplayedMessage[] = [ + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Connection failed", + errorType: "network", + historySequence: 1, + }, + { + type: "assistant", + id: "assistant-2", + historyId: "assistant-2", + content: "", + historySequence: 2, + isStreaming: true, + isPartial: false, + isCompacted: false, + isIdleCompacted: false, + }, + ]; + + expect(shouldKeepRetryBarrierVisibleDuringRetry(messages, 0)).toBe(false); + }); + + it("returns true when attempt > 0 and stream has started but no content yet", () => { + const messages: DisplayedMessage[] = [ + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Connection failed", + errorType: "network", + historySequence: 1, + }, + { + type: "assistant", + id: "assistant-2", + historyId: "assistant-2", + content: "", + historySequence: 2, + isStreaming: true, + isPartial: false, + isCompacted: false, + isIdleCompacted: false, + }, + ]; + + expect(shouldKeepRetryBarrierVisibleDuringRetry(messages, 1)).toBe(true); + }); + + it("returns false once the streaming assistant has visible content", () => { + const messages: DisplayedMessage[] = [ + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Connection failed", + errorType: "network", + historySequence: 1, + }, + { + type: "assistant", + id: "assistant-2", + historyId: "assistant-2", + content: "Hello", + historySequence: 2, + isStreaming: true, + isPartial: false, + isCompacted: false, + isIdleCompacted: false, + }, + ]; + + expect(shouldKeepRetryBarrierVisibleDuringRetry(messages, 1)).toBe(false); + }); + + it("returns false when the stream was started by a fresh user message", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "assistant", + id: "assistant-2", + historyId: "assistant-2", + content: "", + historySequence: 2, + isStreaming: true, + isPartial: false, + isCompacted: false, + isIdleCompacted: false, + }, + ]; + + expect(shouldKeepRetryBarrierVisibleDuringRetry(messages, 1)).toBe(false); + }); +}); + describe("isEligibleForAutoRetry", () => { it("returns false for empty messages", () => { expect(isEligibleForAutoRetry([])).toBe(false); diff --git a/src/browser/utils/messages/retryEligibility.ts b/src/browser/utils/messages/retryEligibility.ts index 0bb1339761..47120d88ae 100644 --- a/src/browser/utils/messages/retryEligibility.ts +++ b/src/browser/utils/messages/retryEligibility.ts @@ -126,6 +126,39 @@ export function hasInterruptedStream( ); } +/** + * During auto-retry loops we can reach stream-start and then fail again before the first token. + * + * In that window, AIView previously hid RetryBarrier because canInterrupt flipped to true, + * causing the banner to flicker on every retry attempt. + * + * This helper keeps RetryBarrier visible while the new stream is active but has not produced + * any visible assistant content yet. + */ +export function shouldKeepRetryBarrierVisibleDuringRetry( + messages: DisplayedMessage[], + retryAttempt: number +): boolean { + if (retryAttempt <= 0) return false; + if (messages.length < 2) return false; + + const lastMessage = messages[messages.length - 1]; + if (lastMessage.type !== "assistant") return false; + if (!lastMessage.isStreaming) return false; + if (lastMessage.content.trim().length > 0) return false; + + const previousMessage = messages[messages.length - 2]; + + // Only keep the banner sticky when this stream is a retry/resume attempt. + // If the previous message is a fresh user message, we want normal streaming UX. + return ( + previousMessage.type === "stream-error" || + (previousMessage.type === "assistant" && previousMessage.isPartial === true) || + (previousMessage.type === "tool" && previousMessage.isPartial === true) || + (previousMessage.type === "reasoning" && previousMessage.isPartial === true) + ); +} + /** * Check if messages are eligible for automatic retry * From 3bfb8747d0f990b7e84aab6c6a5874ed74ad3ffc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:10:11 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20retry=20banne?= =?UTF-8?q?r=20during=20normal=20TTFT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/AIView.tsx | 3 +- .../utils/messages/retryEligibility.test.ts | 45 ++++++---------- .../utils/messages/retryEligibility.ts | 51 +++++++++++++------ 3 files changed, 53 insertions(+), 46 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 6840222542..f24ad68828 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -460,7 +460,8 @@ const AIViewInner: React.FC = ({ // Track if last message was interrupted or errored (for RetryBarrier) // Uses same logic as useResumeManager for DRY const showRetryBarrier = workspaceState - ? hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStartTime) || + ? (!workspaceState.canInterrupt && + hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStartTime)) || shouldKeepRetryBarrierVisibleDuringRetry(workspaceState.messages, retryAttempt) : false; diff --git a/src/browser/utils/messages/retryEligibility.test.ts b/src/browser/utils/messages/retryEligibility.test.ts index 548ed448cf..8b2e758a0f 100644 --- a/src/browser/utils/messages/retryEligibility.test.ts +++ b/src/browser/utils/messages/retryEligibility.test.ts @@ -328,23 +328,12 @@ describe("shouldKeepRetryBarrierVisibleDuringRetry", () => { errorType: "network", historySequence: 1, }, - { - type: "assistant", - id: "assistant-2", - historyId: "assistant-2", - content: "", - historySequence: 2, - isStreaming: true, - isPartial: false, - isCompacted: false, - isIdleCompacted: false, - }, ]; expect(shouldKeepRetryBarrierVisibleDuringRetry(messages, 0)).toBe(false); }); - it("returns true when attempt > 0 and stream has started but no content yet", () => { + it("returns true when attempt > 0 and we're still displaying the interrupted message", () => { const messages: DisplayedMessage[] = [ { type: "stream-error", @@ -354,14 +343,21 @@ describe("shouldKeepRetryBarrierVisibleDuringRetry", () => { errorType: "network", historySequence: 1, }, + ]; + + expect(shouldKeepRetryBarrierVisibleDuringRetry(messages, 1)).toBe(true); + }); + + it("returns true for partial assistant messages", () => { + const messages: DisplayedMessage[] = [ { type: "assistant", - id: "assistant-2", - historyId: "assistant-2", - content: "", - historySequence: 2, - isStreaming: true, - isPartial: false, + id: "assistant-1", + historyId: "assistant-1", + content: "Incomplete response", + historySequence: 1, + isStreaming: false, + isPartial: true, isCompacted: false, isIdleCompacted: false, }, @@ -396,7 +392,7 @@ describe("shouldKeepRetryBarrierVisibleDuringRetry", () => { expect(shouldKeepRetryBarrierVisibleDuringRetry(messages, 1)).toBe(false); }); - it("returns false when the stream was started by a fresh user message", () => { + it("returns false for fresh user messages", () => { const messages: DisplayedMessage[] = [ { type: "user", @@ -405,17 +401,6 @@ describe("shouldKeepRetryBarrierVisibleDuringRetry", () => { content: "Hello", historySequence: 1, }, - { - type: "assistant", - id: "assistant-2", - historyId: "assistant-2", - content: "", - historySequence: 2, - isStreaming: true, - isPartial: false, - isCompacted: false, - isIdleCompacted: false, - }, ]; expect(shouldKeepRetryBarrierVisibleDuringRetry(messages, 1)).toBe(false); diff --git a/src/browser/utils/messages/retryEligibility.ts b/src/browser/utils/messages/retryEligibility.ts index 47120d88ae..186f2af23b 100644 --- a/src/browser/utils/messages/retryEligibility.ts +++ b/src/browser/utils/messages/retryEligibility.ts @@ -132,31 +132,52 @@ export function hasInterruptedStream( * In that window, AIView previously hid RetryBarrier because canInterrupt flipped to true, * causing the banner to flicker on every retry attempt. * - * This helper keeps RetryBarrier visible while the new stream is active but has not produced - * any visible assistant content yet. + * Note: A new stream-start doesn't always immediately produce a new assistant DisplayedMessage + * (we don't render empty assistant messages). During TTFT gaps, the last displayed message can + * still be the prior interruption. */ export function shouldKeepRetryBarrierVisibleDuringRetry( messages: DisplayedMessage[], retryAttempt: number ): boolean { if (retryAttempt <= 0) return false; - if (messages.length < 2) return false; + if (messages.length === 0) return false; const lastMessage = messages[messages.length - 1]; - if (lastMessage.type !== "assistant") return false; - if (!lastMessage.isStreaming) return false; - if (lastMessage.content.trim().length > 0) return false; - const previousMessage = messages[messages.length - 2]; + // If we're still showing the interruption message while a new retry stream is active, + // keep the banner visible so it doesn't flicker away on stream-start. + if (lastMessage.type === "stream-error") { + return true; + } - // Only keep the banner sticky when this stream is a retry/resume attempt. - // If the previous message is a fresh user message, we want normal streaming UX. - return ( - previousMessage.type === "stream-error" || - (previousMessage.type === "assistant" && previousMessage.isPartial === true) || - (previousMessage.type === "tool" && previousMessage.isPartial === true) || - (previousMessage.type === "reasoning" && previousMessage.isPartial === true) - ); + if ( + (lastMessage.type === "assistant" || + lastMessage.type === "tool" || + lastMessage.type === "reasoning") && + lastMessage.isPartial === true + ) { + return true; + } + + // If we do have a streaming assistant block but it hasn't produced any visible content yet, + // keep the banner sticky until the first non-empty delta arrives. + if ( + lastMessage.type === "assistant" && + lastMessage.isStreaming && + lastMessage.content.trim().length === 0 && + messages.length >= 2 + ) { + const previousMessage = messages[messages.length - 2]; + return ( + previousMessage.type === "stream-error" || + (previousMessage.type === "assistant" && previousMessage.isPartial === true) || + (previousMessage.type === "tool" && previousMessage.isPartial === true) || + (previousMessage.type === "reasoning" && previousMessage.isPartial === true) + ); + } + + return false; } /**