Skip to content
Merged
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
16 changes: 9 additions & 7 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,20 @@ const AIViewInner: React.FC<AIViewProps> = ({
// 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,
Expand Down Expand Up @@ -265,10 +271,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
// 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.
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useResumeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions src/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface WorkspaceState {
currentModel: string | null;
recencyTimestamp: number | null;
todos: TodoItem[];
pendingStreamStartTime: number | null;
}

/**
Expand Down Expand Up @@ -362,6 +363,7 @@ export class WorkspaceStore {
currentModel: aggregator.getCurrentModel() ?? null,
recencyTimestamp: aggregator.getRecencyTimestamp(),
todos: aggregator.getCurrentTodos(),
pendingStreamStartTime: aggregator.getPendingStreamStartTime(),
};
});
}
Expand Down
21 changes: 21 additions & 0 deletions src/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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());
}
}
}

Expand Down
66 changes: 64 additions & 2 deletions src/utils/messages/retryEligibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand All @@ -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);
});
});
16 changes: 14 additions & 2 deletions src/utils/messages/retryEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down