Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -88,6 +93,11 @@ interface AIViewProps {
status?: "creating";
}

const defaultRetryState: RetryState = {
attempt: 0,
retryStartTime: 0,
};

const AIViewInner: React.FC<AIViewProps> = ({
workspaceId,
projectPath,
Expand Down Expand Up @@ -238,6 +248,16 @@ const AIViewInner: React.FC<AIViewProps> = ({
}
);

// Retry state - used to stabilize RetryBarrier UI during auto-retry loops
const [retryState] = usePersistedState<RetryState>(
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<boolean>(VIM_ENABLED_KEY, false, { listener: true });

Expand Down Expand Up @@ -440,8 +460,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
// 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)
Expand Down Expand Up @@ -688,7 +709,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
</>
)}
<PinnedTodoList workspaceId={workspaceId} />
<StreamingBarrier workspaceId={workspaceId} />
{!showRetryBarrier && <StreamingBarrier workspaceId={workspaceId} />}
{shouldShowQueuedAgentTaskPrompt && (
<QueuedMessage
message={{
Expand Down
91 changes: 91 additions & 0 deletions src/browser/utils/messages/retryEligibility.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from "@jest/globals";
import {
hasInterruptedStream,
shouldKeepRetryBarrierVisibleDuringRetry,
isEligibleForAutoRetry,
isNonRetryableSendError,
PENDING_STREAM_START_GRACE_PERIOD_MS,
Expand Down Expand Up @@ -316,6 +317,96 @@ describe("hasInterruptedStream", () => {
});
});

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);
Expand Down
54 changes: 54 additions & 0 deletions src/browser/utils/messages/retryEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Loading