diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 6151eec5d..a2a63e019 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -205,14 +205,20 @@ const AIViewInner: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspaceId, workspaceState?.loading]); + // Compute showRetryBarrier once for both keybinds and UI + // 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) + : false; + // Handle keyboard shortcuts (using optional refs that are safe even if not initialized) useAIViewKeybinds({ workspaceId, currentModel: workspaceState?.currentModel ?? null, canInterrupt: workspaceState?.canInterrupt ?? false, - showRetryBarrier: workspaceState - ? !workspaceState.canInterrupt && hasInterruptedStream(workspaceState.messages) - : false, + showRetryBarrier, currentWorkspaceThinking, setThinkingLevel, setAutoRetry, @@ -265,10 +271,6 @@ const AIViewInner: React.FC = ({ // Get active stream message ID for token counting const activeStreamMessageId = aggregator.getActiveStreamMessageId(); - // Track if last message was interrupted or errored (for RetryBarrier) - // Uses same logic as useResumeManager for DRY - const showRetryBarrier = !canInterrupt && hasInterruptedStream(messages); - // Note: We intentionally do NOT reset autoRetry when streams start. // If user pressed Ctrl+C, autoRetry stays false until they manually retry. // This makes state transitions explicit and predictable. diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index dbf4f38d0..48f8cd1d3 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -95,7 +95,7 @@ export function useResumeManager() { // 1. Must have interrupted stream (not currently streaming) if (state.canInterrupt) return false; // Currently streaming - if (!hasInterruptedStream(state.messages)) { + if (!hasInterruptedStream(state.messages, state.pendingStreamStartTime)) { return false; } diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 692de2c24..609fbbe5f 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -29,6 +29,7 @@ export interface WorkspaceState { currentModel: string | null; recencyTimestamp: number | null; todos: TodoItem[]; + pendingStreamStartTime: number | null; } /** @@ -362,6 +363,7 @@ export class WorkspaceStore { currentModel: aggregator.getCurrentModel() ?? null, recencyTimestamp: aggregator.getRecencyTimestamp(), todos: aggregator.getCurrentTodos(), + pendingStreamStartTime: aggregator.getPendingStreamStartTime(), }; }); } diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index b71c09fe1..5446b49e7 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -60,6 +60,11 @@ export class StreamingMessageAggregator { timestamp: number; } | null = null; + // Track when we're waiting for stream-start after user message + // Prevents retry barrier flash during normal send flow + // Stores timestamp of when user message was sent (null = no pending stream) + private pendingStreamStartTime: number | null = null; + // Workspace creation timestamp (used for recency calculation) // REQUIRED: Backend guarantees every workspace has createdAt via config.ts private readonly createdAt: string; @@ -181,6 +186,14 @@ export class StreamingMessageAggregator { return this.messages.size > 0; } + getPendingStreamStartTime(): number | null { + return this.pendingStreamStartTime; + } + + private setPendingStreamStartTime(time: number | null): void { + this.pendingStreamStartTime = time; + } + getActiveStreams(): StreamingContext[] { return Array.from(this.activeStreams.values()); } @@ -251,6 +264,9 @@ export class StreamingMessageAggregator { // Unified event handlers that encapsulate all complex logic handleStreamStart(data: StreamStartEvent): void { + // Clear pending stream start timestamp - stream has started + this.setPendingStreamStartTime(null); + // Detect if this stream is compacting by checking if last user message is a compaction-request const messages = this.getAllMessages(); const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); @@ -571,6 +587,11 @@ export class StreamingMessageAggregator { // Now add the new message this.addMessage(incomingMessage); + + // If this is a user message, record timestamp for pending stream detection + if (incomingMessage.role === "user") { + this.setPendingStreamStartTime(Date.now()); + } } } diff --git a/src/utils/messages/retryEligibility.test.ts b/src/utils/messages/retryEligibility.test.ts index 3760cebf2..b716f6626 100644 --- a/src/utils/messages/retryEligibility.test.ts +++ b/src/utils/messages/retryEligibility.test.ts @@ -157,7 +157,41 @@ describe("hasInterruptedStream", () => { historySequence: 3, }, ]; - expect(hasInterruptedStream(messages)).toBe(true); + expect(hasInterruptedStream(messages, null)).toBe(true); + }); + + it("returns false when message was sent very recently (< 3s)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "assistant", + id: "assistant-1", + historyId: "assistant-1", + content: "Complete response", + historySequence: 2, + streamSequence: 0, + isStreaming: false, + isPartial: false, + isLastPartOfMessage: true, + isCompacted: false, + }, + { + type: "user", + id: "user-2", + historyId: "user-2", + content: "Another question", + historySequence: 3, + }, + ]; + // Message sent 1 second ago - still within 3s window + const recentTimestamp = Date.now() - 1000; + expect(hasInterruptedStream(messages, recentTimestamp)).toBe(false); }); it("returns true when user message has no response (slow model scenario)", () => { @@ -170,6 +204,34 @@ describe("hasInterruptedStream", () => { historySequence: 1, }, ]; - expect(hasInterruptedStream(messages)).toBe(true); + expect(hasInterruptedStream(messages, null)).toBe(true); + }); + + it("returns false when user message just sent (< 3s ago)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + ]; + const justSent = Date.now() - 500; // 0.5s ago + expect(hasInterruptedStream(messages, justSent)).toBe(false); + }); + + it("returns true when message sent over 3s ago (stream likely hung)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + ]; + const longAgo = Date.now() - 4000; // 4s ago - past 3s threshold + expect(hasInterruptedStream(messages, longAgo)).toBe(true); }); }); diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index c7da3cfea..f222b52c1 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -15,15 +15,27 @@ import type { DisplayedMessage } from "@/types/message"; * 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 */ -export function hasInterruptedStream(messages: DisplayedMessage[]): boolean { +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) + // This prevents flash during normal send flow while stream-start event arrives + // After 3s, we assume something is wrong and show the barrier + if (pendingStreamStartTime !== null) { + const elapsed = Date.now() - pendingStreamStartTime; + if (elapsed < 3000) return false; + } + const lastMessage = messages[messages.length - 1]; return ( lastMessage.type === "stream-error" || // Stream errored out - lastMessage.type === "user" || // No response received yet (e.g., app restarted during slow model) + lastMessage.type === "user" || // No response received yet (app restart during slow model) (lastMessage.type === "assistant" && lastMessage.isPartial === true) || (lastMessage.type === "tool" && lastMessage.isPartial === true) || (lastMessage.type === "reasoning" && lastMessage.isPartial === true)