Skip to content

Commit c46a118

Browse files
committed
🤖 Use timestamp instead of boolean for pendingStreamStart
Replace boolean flag with timestamp to gracefully handle stale pending states. Instead of a 30s timeout with timer cleanup, check if timestamp is recent (<3s) when evaluating retry eligibility. Benefits: - No timer management/cleanup needed - UI naturally re-evaluates on re-render - More graceful - shows barrier after 3s if stream truly hung - Handles edge cases (app restart, etc.) better Changes: - StreamingMessageAggregator: Store timestamp instead of bool, remove timer - retryEligibility: Check if timestamp < 3s old to prevent flash - WorkspaceStore: Expose pendingStreamStartTime instead of bool - AIView/useResumeManager: Pass timestamp to hasInterruptedStream - Tests: Updated to verify timestamp-based logic (11 tests passing)
1 parent 78e0daf commit c46a118

File tree

6 files changed

+48
-42
lines changed

6 files changed

+48
-42
lines changed

src/components/AIView.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
212212
canInterrupt: workspaceState?.canInterrupt ?? false,
213213
showRetryBarrier: workspaceState
214214
? !workspaceState.canInterrupt &&
215-
hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStart)
215+
hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStartTime)
216216
: false,
217217
currentWorkspaceThinking,
218218
setThinkingLevel,
@@ -261,15 +261,15 @@ const AIViewInner: React.FC<AIViewProps> = ({
261261
}
262262

263263
// Extract state from workspace state
264-
const { messages, canInterrupt, isCompacting, loading, currentModel, pendingStreamStart } =
264+
const { messages, canInterrupt, isCompacting, loading, currentModel, pendingStreamStartTime } =
265265
workspaceState;
266266

267267
// Get active stream message ID for token counting
268268
const activeStreamMessageId = aggregator.getActiveStreamMessageId();
269269

270270
// Track if last message was interrupted or errored (for RetryBarrier)
271271
// Uses same logic as useResumeManager for DRY
272-
const showRetryBarrier = !canInterrupt && hasInterruptedStream(messages, pendingStreamStart);
272+
const showRetryBarrier = !canInterrupt && hasInterruptedStream(messages, pendingStreamStartTime);
273273

274274
// Note: We intentionally do NOT reset autoRetry when streams start.
275275
// If user pressed Ctrl+C, autoRetry stays false until they manually retry.

src/hooks/useResumeManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export function useResumeManager() {
9595
// 1. Must have interrupted stream (not currently streaming)
9696
if (state.canInterrupt) return false; // Currently streaming
9797

98-
if (!hasInterruptedStream(state.messages, state.pendingStreamStart)) {
98+
if (!hasInterruptedStream(state.messages, state.pendingStreamStartTime)) {
9999
return false;
100100
}
101101

src/stores/WorkspaceStore.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface WorkspaceState {
2828
currentModel: string | null;
2929
recencyTimestamp: number | null;
3030
todos: TodoItem[];
31-
pendingStreamStart: boolean;
31+
pendingStreamStartTime: number | null;
3232
}
3333

3434
/**
@@ -346,7 +346,7 @@ export class WorkspaceStore {
346346
currentModel: aggregator.getCurrentModel() ?? null,
347347
recencyTimestamp: aggregator.getRecencyTimestamp(),
348348
todos: aggregator.getCurrentTodos(),
349-
pendingStreamStart: aggregator.isPendingStreamStart(),
349+
pendingStreamStartTime: aggregator.getPendingStreamStartTime(),
350350
};
351351
});
352352
}

src/utils/messages/StreamingMessageAggregator.ts

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ export class StreamingMessageAggregator {
6262

6363
// Track when we're waiting for stream-start after user message
6464
// Prevents retry barrier flash during normal send flow
65-
private pendingStreamStart = false;
66-
private pendingStreamStartTimer: ReturnType<typeof setTimeout> | null = null;
65+
// Stores timestamp of when user message was sent (null = no pending stream)
66+
private pendingStreamStartTime: number | null = null;
6767

6868
// Workspace creation timestamp (used for recency calculation)
6969
private readonly createdAt?: string;
@@ -189,27 +189,12 @@ export class StreamingMessageAggregator {
189189
return this.messages.size > 0;
190190
}
191191

192-
isPendingStreamStart(): boolean {
193-
return this.pendingStreamStart;
192+
getPendingStreamStartTime(): number | null {
193+
return this.pendingStreamStartTime;
194194
}
195195

196-
private setPendingStreamStart(pending: boolean): void {
197-
// Clear existing timer if any
198-
if (this.pendingStreamStartTimer) {
199-
clearTimeout(this.pendingStreamStartTimer);
200-
this.pendingStreamStartTimer = null;
201-
}
202-
203-
this.pendingStreamStart = pending;
204-
205-
if (pending) {
206-
// Set 30s timeout - if stream hasn't started by then, something is wrong
207-
this.pendingStreamStartTimer = setTimeout(() => {
208-
this.pendingStreamStart = false;
209-
this.pendingStreamStartTimer = null;
210-
this.invalidateCache(); // Trigger re-render to show retry barrier
211-
}, 30000);
212-
}
196+
private setPendingStreamStartTime(time: number | null): void {
197+
this.pendingStreamStartTime = time;
213198
}
214199

215200
getActiveStreams(): StreamingContext[] {
@@ -282,8 +267,8 @@ export class StreamingMessageAggregator {
282267

283268
// Unified event handlers that encapsulate all complex logic
284269
handleStreamStart(data: StreamStartEvent): void {
285-
// Clear pending stream start flag - stream has started
286-
this.setPendingStreamStart(false);
270+
// Clear pending stream start timestamp - stream has started
271+
this.setPendingStreamStartTime(null);
287272

288273
// Detect if this stream is compacting by checking if last user message is a compaction-request
289274
const messages = this.getAllMessages();
@@ -606,9 +591,9 @@ export class StreamingMessageAggregator {
606591
// Now add the new message
607592
this.addMessage(incomingMessage);
608593

609-
// If this is a user message, set pendingStreamStart flag and start timeout
594+
// If this is a user message, record timestamp for pending stream detection
610595
if (incomingMessage.role === "user") {
611-
this.setPendingStreamStart(true);
596+
this.setPendingStreamStartTime(Date.now());
612597
}
613598
}
614599
}

src/utils/messages/retryEligibility.test.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,10 @@ describe("hasInterruptedStream", () => {
157157
historySequence: 3,
158158
},
159159
];
160-
expect(hasInterruptedStream(messages, false)).toBe(true);
160+
expect(hasInterruptedStream(messages, null)).toBe(true);
161161
});
162162

163-
it("returns false when pendingStreamStart is true", () => {
163+
it("returns false when message was sent very recently (< 3s)", () => {
164164
const messages: DisplayedMessage[] = [
165165
{
166166
type: "user",
@@ -189,7 +189,9 @@ describe("hasInterruptedStream", () => {
189189
historySequence: 3,
190190
},
191191
];
192-
expect(hasInterruptedStream(messages, true)).toBe(false);
192+
// Message sent 1 second ago - still within 3s window
193+
const recentTimestamp = Date.now() - 1000;
194+
expect(hasInterruptedStream(messages, recentTimestamp)).toBe(false);
193195
});
194196

195197
it("returns true when user message has no response (slow model scenario)", () => {
@@ -202,10 +204,10 @@ describe("hasInterruptedStream", () => {
202204
historySequence: 1,
203205
},
204206
];
205-
expect(hasInterruptedStream(messages, false)).toBe(true);
207+
expect(hasInterruptedStream(messages, null)).toBe(true);
206208
});
207209

208-
it("returns false when user message just sent and pendingStreamStart is true", () => {
210+
it("returns false when user message just sent (< 3s ago)", () => {
209211
const messages: DisplayedMessage[] = [
210212
{
211213
type: "user",
@@ -215,6 +217,21 @@ describe("hasInterruptedStream", () => {
215217
historySequence: 1,
216218
},
217219
];
218-
expect(hasInterruptedStream(messages, true)).toBe(false);
220+
const justSent = Date.now() - 500; // 0.5s ago
221+
expect(hasInterruptedStream(messages, justSent)).toBe(false);
222+
});
223+
224+
it("returns true when message sent over 3s ago (stream likely hung)", () => {
225+
const messages: DisplayedMessage[] = [
226+
{
227+
type: "user",
228+
id: "user-1",
229+
historyId: "user-1",
230+
content: "Hello",
231+
historySequence: 1,
232+
},
233+
];
234+
const longAgo = Date.now() - 4000; // 4s ago - past 3s threshold
235+
expect(hasInterruptedStream(messages, longAgo)).toBe(true);
219236
});
220237
});

src/utils/messages/retryEligibility.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,21 @@ import type { DisplayedMessage } from "@/types/message";
1515
* 3. Last message is a user message (indicating we sent it but never got a response)
1616
* - This handles app restarts during slow model responses (models can take 30-60s to first token)
1717
* - User messages are only at the end when response hasn't started/completed
18-
* - EXCEPT: Not if pendingStreamStart is true (waiting for stream-start event)
18+
* - EXCEPT: Not if recently sent (<3s ago) - prevents flash during normal send flow
1919
*/
2020
export function hasInterruptedStream(
2121
messages: DisplayedMessage[],
22-
pendingStreamStart = false
22+
pendingStreamStartTime: number | null = null
2323
): boolean {
2424
if (messages.length === 0) return false;
2525

26-
// Don't show retry barrier if we're waiting for stream-start
27-
// This prevents flash during normal send flow
28-
if (pendingStreamStart) return false;
26+
// Don't show retry barrier if user message was sent very recently (< 3s)
27+
// This prevents flash during normal send flow while stream-start event arrives
28+
// After 3s, we assume something is wrong and show the barrier
29+
if (pendingStreamStartTime !== null) {
30+
const elapsed = Date.now() - pendingStreamStartTime;
31+
if (elapsed < 3000) return false;
32+
}
2933

3034
const lastMessage = messages[messages.length - 1];
3135

0 commit comments

Comments
 (0)