Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,5 @@ gh pr view <number> --json mergeable,mergeStateStatus | jq '.'

## Mode: Plan

- When Plan Mode is requested, assume the user wants the actual completed plan; do not merely describe how you would devise one.
- Attach a net LoC estimate (product code only) to each recommended approach.
10 changes: 10 additions & 0 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { setTelemetryEnabled } from "@/common/telemetry";
import { getTokenCountPromise } from "@/browser/utils/tokenizer/rendererClient";
import { CreationCenterContent } from "./CreationCenterContent";
import { CreationControls } from "./CreationControls";
import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
import { useCreationWorkspace } from "./useCreationWorkspace";

type TokenCountReader = () => number;
Expand Down Expand Up @@ -136,6 +137,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
const [mode, setMode] = useMode();
const { recentModels, addModel, evictModel } = useModelLRU();
const commandListId = useId();
const workspaceStore = useWorkspaceStoreRaw();
const telemetry = useTelemetry();
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
listener: true,
Expand Down Expand Up @@ -443,6 +445,12 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
// Workspace variant: full command handling + message send
if (variant !== "workspace") return; // Type guard

const notifyPendingSendFailed = () => {
setTimeout(() => {
workspaceStore.markPendingStreamFailed(props.workspaceId);
}, 0);
};

try {
// Parse command
const parsed = parseCommand(messageText);
Expand Down Expand Up @@ -716,6 +724,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
setToast(createErrorToast(result.error));
// Restore input and images on error so user can try again
setInput(messageText);
notifyPendingSendFailed();
setImageAttachments(previousImageAttachments);
} else {
// Track telemetry for successful message send
Expand All @@ -736,6 +745,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
raw: error instanceof Error ? error.message : "Failed to send message",
})
);
notifyPendingSendFailed();
setInput(messageText);
setImageAttachments(previousImageAttachments);
} finally {
Expand Down
14 changes: 14 additions & 0 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,20 @@ export class WorkspaceStore {
return allStates;
}

/**
* Notify the aggregator that a pending stream never started (renderer send failure).
*/
markPendingStreamFailed(workspaceId: string): void {
const aggregator = this.aggregators.get(workspaceId);
if (!aggregator) {
return;
}

if (aggregator.markPendingStreamStartFailed()) {
this.states.bump(workspaceId);
}
}

/**
* Get recency timestamps for all workspaces (for sorting in command palette).
* Derived on-demand from individual workspace states.
Expand Down
12 changes: 12 additions & 0 deletions src/browser/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,18 @@ export class StreamingMessageAggregator {
return this.pendingStreamStartTime;
}


/**
* Clear pending stream-start tracking when send fails before we ever receive stream-start.
* Returns true when the internal state changed so callers can trigger updates.
*/
markPendingStreamStartFailed(): boolean {
if (this.pendingStreamStartTime === null) {
return false;
}
this.pendingStreamStartTime = null;
return true;
}
private setPendingStreamStartTime(time: number | null): void {
this.pendingStreamStartTime = time;
}
Expand Down
19 changes: 10 additions & 9 deletions src/browser/utils/messages/retryEligibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
hasInterruptedStream,
isEligibleForAutoRetry,
isNonRetryableSendError,
PENDING_STREAM_START_GRACE_PERIOD_MS,
} from "./retryEligibility";
import type { DisplayedMessage } from "@/common/types/message";
import type { SendMessageError } from "@/common/types/errors";
Expand Down Expand Up @@ -165,7 +166,7 @@ describe("hasInterruptedStream", () => {
expect(hasInterruptedStream(messages, null)).toBe(true);
});

it("returns false when message was sent very recently (< 3s)", () => {
it("returns false when message was sent very recently (within grace period)", () => {
const messages: DisplayedMessage[] = [
{
type: "user",
Expand Down Expand Up @@ -194,8 +195,8 @@ describe("hasInterruptedStream", () => {
historySequence: 3,
},
];
// Message sent 1 second ago - still within 3s window
const recentTimestamp = Date.now() - 1000;
// Message sent 1 second ago - still within grace window
const recentTimestamp = Date.now() - (PENDING_STREAM_START_GRACE_PERIOD_MS - 1000);
expect(hasInterruptedStream(messages, recentTimestamp)).toBe(false);
});

Expand All @@ -212,7 +213,7 @@ describe("hasInterruptedStream", () => {
expect(hasInterruptedStream(messages, null)).toBe(true);
});

it("returns false when user message just sent (< 3s ago)", () => {
it("returns false when user message just sent (within grace period)", () => {
const messages: DisplayedMessage[] = [
{
type: "user",
Expand All @@ -222,11 +223,11 @@ describe("hasInterruptedStream", () => {
historySequence: 1,
},
];
const justSent = Date.now() - 500; // 0.5s ago
const justSent = Date.now() - (PENDING_STREAM_START_GRACE_PERIOD_MS - 500);
expect(hasInterruptedStream(messages, justSent)).toBe(false);
});

it("returns true when message sent over 3s ago (stream likely hung)", () => {
it("returns true when message sent beyond grace period (stream likely hung)", () => {
const messages: DisplayedMessage[] = [
{
type: "user",
Expand All @@ -236,7 +237,7 @@ describe("hasInterruptedStream", () => {
historySequence: 1,
},
];
const longAgo = Date.now() - 4000; // 4s ago - past 3s threshold
const longAgo = Date.now() - (PENDING_STREAM_START_GRACE_PERIOD_MS + 1000);
expect(hasInterruptedStream(messages, longAgo)).toBe(true);
});

Expand Down Expand Up @@ -545,7 +546,7 @@ describe("isEligibleForAutoRetry", () => {
expect(isEligibleForAutoRetry(messages, null)).toBe(true);
});

it("returns false when user message sent very recently (< 3s)", () => {
it("returns false when user message sent very recently (within grace period)", () => {
const messages: DisplayedMessage[] = [
{
type: "user",
Expand All @@ -555,7 +556,7 @@ describe("isEligibleForAutoRetry", () => {
historySequence: 1,
},
];
const justSent = Date.now() - 500; // 0.5s ago
const justSent = Date.now() - (PENDING_STREAM_START_GRACE_PERIOD_MS - 500);
expect(isEligibleForAutoRetry(messages, justSent)).toBe(false);
});
});
Expand Down
10 changes: 6 additions & 4 deletions src/browser/utils/messages/retryEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ declare global {
}
}

export const PENDING_STREAM_START_GRACE_PERIOD_MS = 15000; // 15 seconds

/**
* Check if the debug flag to force all errors to be retryable is enabled
*/
Expand Down Expand Up @@ -69,20 +71,20 @@ export function isNonRetryableSendError(error: SendMessageError): boolean {
* 3. Last message is a user message (indicating we sent it but never got a response)
* - This handles app restarts during slow model responses (models can take 30-60s to first token)
* - User messages are only at the end when response hasn't started/completed
* - EXCEPT: Not if recently sent (<3s ago) - prevents flash during normal send flow
* - EXCEPT: Not if recently sent (within PENDING_STREAM_START_GRACE_PERIOD_MS) - prevents flash during normal send flow
*/
export function hasInterruptedStream(
messages: DisplayedMessage[],
pendingStreamStartTime: number | null = null
): boolean {
if (messages.length === 0) return false;

// Don't show retry barrier if user message was sent very recently (< 3s)
// Don't show retry barrier if user message was sent very recently (within the grace period)
// This prevents flash during normal send flow while stream-start event arrives
// After 3s, we assume something is wrong and show the barrier
// After the grace period, assume something is wrong and show the barrier
if (pendingStreamStartTime !== null) {
const elapsed = Date.now() - pendingStreamStartTime;
if (elapsed < 3000) return false;
if (elapsed < PENDING_STREAM_START_GRACE_PERIOD_MS) return false;
}

const lastMessage = messages[messages.length - 1];
Expand Down
Loading