diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 955d6053ab..f24ad68828 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,9 @@ 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) + ? (!workspaceState.canInterrupt && + 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 +709,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, + }, + ]; + + expect(shouldKeepRetryBarrierVisibleDuringRetry(messages, 0)).toBe(false); + }); + + it("returns true when attempt > 0 and we're still displaying the interrupted message", () => { + const messages: DisplayedMessage[] = [ + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Connection failed", + errorType: "network", + historySequence: 1, + }, + ]; + + expect(shouldKeepRetryBarrierVisibleDuringRetry(messages, 1)).toBe(true); + }); + + it("returns true for partial assistant messages", () => { + const messages: DisplayedMessage[] = [ + { + type: "assistant", + id: "assistant-1", + historyId: "assistant-1", + content: "Incomplete response", + historySequence: 1, + isStreaming: false, + isPartial: true, + 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 for fresh user messages", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + ]; + + 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..186f2af23b 100644 --- a/src/browser/utils/messages/retryEligibility.ts +++ b/src/browser/utils/messages/retryEligibility.ts @@ -126,6 +126,60 @@ 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. + * + * 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 === 0) return false; + + const lastMessage = messages[messages.length - 1]; + + // 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; + } + + 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; +} + /** * Check if messages are eligible for automatic retry *