Skip to content

Commit bb413be

Browse files
committed
🤖 feat: implement soft-interrupts with block boundary detection
1 parent 816297d commit bb413be

File tree

10 files changed

+167
-62
lines changed

10 files changed

+167
-62
lines changed

src/browser/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
1313
import { useResumeManager } from "./hooks/useResumeManager";
1414
import { useUnreadTracking } from "./hooks/useUnreadTracking";
1515
import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue";
16-
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
16+
import { useWorkspaceStoreRaw, useWorkspaceRecency, canInterrupt } from "./stores/WorkspaceStore";
1717
import { ChatInput } from "./components/ChatInput/index";
1818
import type { ChatInputAPI } from "./components/ChatInput/types";
1919

@@ -415,7 +415,7 @@ function AppInner() {
415415
const allStates = workspaceStore.getAllStates();
416416
const streamingModels = new Map<string, string>();
417417
for (const [workspaceId, state] of allStates) {
418-
if (state.canInterrupt && state.currentModel) {
418+
if (canInterrupt(state.interruptType) && state.currentModel) {
419419
streamingModels.set(workspaceId, state.currentModel);
420420
}
421421
}

src/browser/components/AIView.tsx

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
2020
import { useAutoScroll } from "@/browser/hooks/useAutoScroll";
2121
import { usePersistedState } from "@/browser/hooks/usePersistedState";
2222
import { useThinking } from "@/browser/contexts/ThinkingContext";
23-
import { useWorkspaceState, useWorkspaceAggregator } from "@/browser/stores/WorkspaceStore";
23+
import {
24+
useWorkspaceState,
25+
useWorkspaceAggregator,
26+
canInterrupt,
27+
} from "@/browser/stores/WorkspaceStore";
2428
import { WorkspaceHeader } from "./WorkspaceHeader";
2529
import { getModelName } from "@/common/utils/ai/models";
2630
import type { DisplayedMessage } from "@/common/types/message";
@@ -227,15 +231,15 @@ const AIViewInner: React.FC<AIViewProps> = ({
227231
// Track if last message was interrupted or errored (for RetryBarrier)
228232
// Uses same logic as useResumeManager for DRY
229233
const showRetryBarrier = workspaceState
230-
? !workspaceState.canInterrupt &&
234+
? !canInterrupt(workspaceState.interruptType) &&
231235
hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStartTime)
232236
: false;
233237

234238
// Handle keyboard shortcuts (using optional refs that are safe even if not initialized)
235239
useAIViewKeybinds({
236240
workspaceId,
237241
currentModel: workspaceState?.currentModel ?? null,
238-
canInterrupt: workspaceState?.canInterrupt ?? false,
242+
canInterrupt: canInterrupt(workspaceState.interruptType),
239243
showRetryBarrier,
240244
currentWorkspaceThinking,
241245
setThinkingLevel,
@@ -284,8 +288,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
284288
);
285289
}
286290

287-
// Extract state from workspace state
288-
const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState;
291+
const { messages, interruptType, isCompacting, loading, currentModel } = workspaceState;
289292

290293
// Get active stream message ID for token counting
291294
const activeStreamMessageId = aggregator.getActiveStreamMessageId();
@@ -297,6 +300,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
297300
// Merge consecutive identical stream errors
298301
const mergedMessages = mergeConsecutiveStreamErrors(messages);
299302

303+
const model = currentModel ? getModelName(currentModel) : "";
304+
const interrupting = interruptType === "hard";
305+
306+
const prefix = interrupting ? "⏸️ Interrupting " : "";
307+
const action = interrupting ? "" : isCompacting ? "compacting..." : "streaming...";
308+
309+
const statusText = `${prefix}${model} ${action}`.trim();
310+
300311
// When editing, find the cutoff point
301312
const editCutoffHistoryId = editingMessage
302313
? mergedMessages.find(
@@ -369,8 +380,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
369380
onTouchMove={markUserInteraction}
370381
onScroll={handleScroll}
371382
role="log"
372-
aria-live={canInterrupt ? "polite" : "off"}
373-
aria-busy={canInterrupt}
383+
aria-live={canInterrupt(interruptType) ? "polite" : "off"}
384+
aria-busy={canInterrupt(interruptType)}
374385
aria-label="Conversation transcript"
375386
tabIndex={0}
376387
className="h-full overflow-y-auto p-[15px] leading-[1.5] break-words whitespace-pre-wrap"
@@ -429,21 +440,13 @@ const AIViewInner: React.FC<AIViewProps> = ({
429440
</>
430441
)}
431442
<PinnedTodoList workspaceId={workspaceId} />
432-
{canInterrupt && (
443+
{canInterrupt(interruptType) && (
433444
<StreamingBarrier
434-
statusText={
435-
isCompacting
436-
? currentModel
437-
? `${getModelName(currentModel)} compacting...`
438-
: "compacting..."
439-
: currentModel
440-
? `${getModelName(currentModel)} streaming...`
441-
: "streaming..."
442-
}
445+
statusText={statusText}
443446
cancelText={
444447
isCompacting
445448
? `${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early`
446-
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`
449+
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to ${interruptType === "hard" ? "force" : ""} cancel`
447450
}
448451
tokenCount={
449452
activeStreamMessageId
@@ -480,7 +483,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
480483
editingMessage={editingMessage}
481484
onCancelEdit={handleCancelEdit}
482485
onEditLastUserMessage={handleEditLastUserMessage}
483-
canInterrupt={canInterrupt}
486+
canInterrupt={canInterrupt(interruptType)}
484487
onReady={handleChatInputReady}
485488
/>
486489
</div>

src/browser/components/WorkspaceStatusDot.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { cn } from "@/common/lib/utils";
2-
import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore";
2+
import { canInterrupt, useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore";
33
import { getStatusTooltip } from "@/browser/utils/ui/statusTooltip";
44
import { memo, useMemo } from "react";
55
import { Tooltip, TooltipWrapper } from "./Tooltip";
@@ -11,10 +11,10 @@ export const WorkspaceStatusDot = memo<{
1111
size?: number;
1212
}>(
1313
({ workspaceId, lastReadTimestamp, onClick, size = 8 }) => {
14-
const { canInterrupt, currentModel, agentStatus, recencyTimestamp } =
14+
const { interruptType, currentModel, agentStatus, recencyTimestamp } =
1515
useWorkspaceSidebarState(workspaceId);
1616

17-
const streaming = canInterrupt;
17+
const streaming = canInterrupt(interruptType);
1818

1919
// Compute unread status if lastReadTimestamp provided (sidebar only)
2020
const unread = useMemo(() => {
@@ -35,7 +35,7 @@ export const WorkspaceStatusDot = memo<{
3535
[streaming, currentModel, agentStatus, unread, recencyTimestamp]
3636
);
3737

38-
const bgColor = canInterrupt ? "bg-blue-400" : unread ? "bg-gray-300" : "bg-muted-dark";
38+
const bgColor = streaming ? "bg-blue-400" : unread ? "bg-gray-300" : "bg-muted-dark";
3939
const cursor = onClick && !streaming ? "cursor-pointer" : "cursor-default";
4040

4141
return (

src/browser/hooks/useResumeManager.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { useEffect, useRef } from "react";
2-
import { useWorkspaceStoreRaw, type WorkspaceState } from "@/browser/stores/WorkspaceStore";
2+
import {
3+
canInterrupt,
4+
useWorkspaceStoreRaw,
5+
type WorkspaceState,
6+
} from "@/browser/stores/WorkspaceStore";
37
import { CUSTOM_EVENTS, type CustomEventType } from "@/common/constants/events";
48
import { getAutoRetryKey, getRetryStateKey } from "@/common/constants/storage";
59
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
@@ -100,7 +104,7 @@ export function useResumeManager() {
100104
}
101105

102106
// 1. Must have interrupted stream that's eligible for auto-retry (not currently streaming)
103-
if (state.canInterrupt) return false; // Currently streaming
107+
if (canInterrupt(state.interruptType)) return false; // Currently streaming
104108

105109
if (!isEligibleForAutoRetry(state.messages, state.pendingStreamStartTime)) {
106110
return false;

src/browser/stores/WorkspaceStore.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ describe("WorkspaceStore", () => {
253253

254254
expect(state).toMatchObject({
255255
messages: [],
256-
canInterrupt: false,
256+
interruptType: "none",
257257
isCompacting: false,
258258
loading: true, // loading because not caught up
259259
muxMessages: [],
@@ -273,7 +273,7 @@ describe("WorkspaceStore", () => {
273273
// Object.is() comparison and skip re-renders for primitive values.
274274
// TODO: Optimize aggregator caching in Phase 2
275275
expect(state1).toEqual(state2);
276-
expect(state1.canInterrupt).toBe(state2.canInterrupt);
276+
expect(state1.interruptType).toBe(state2.interruptType);
277277
expect(state1.loading).toBe(state2.loading);
278278
});
279279
});
@@ -428,7 +428,7 @@ describe("WorkspaceStore", () => {
428428

429429
const state2 = store.getWorkspaceState("test-workspace");
430430
expect(state1).not.toBe(state2); // Cache should be invalidated
431-
expect(state2.canInterrupt).toBe(true); // Stream started, so can interrupt
431+
expect(state2.interruptType).toBeTruthy(); // Stream started, so can interrupt
432432
});
433433

434434
it("invalidates getAllStates() cache when workspace changes", async () => {

src/browser/stores/WorkspaceStore.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { createFreshRetryState } from "@/browser/utils/messages/retryState";
3232
export interface WorkspaceState {
3333
name: string; // User-facing workspace name (e.g., "feature-branch")
3434
messages: DisplayedMessage[];
35-
canInterrupt: boolean;
35+
interruptType: InterruptType; // Whether an interrupt is soft/hard or not possible
3636
isCompacting: boolean;
3737
loading: boolean;
3838
muxMessages: MuxMessage[];
@@ -43,12 +43,18 @@ export interface WorkspaceState {
4343
pendingStreamStartTime: number | null;
4444
}
4545

46+
export type InterruptType = "soft" | "hard" | "none";
47+
48+
export function canInterrupt(interruptible: InterruptType): boolean {
49+
return interruptible === "soft" || interruptible === "hard";
50+
}
51+
4652
/**
4753
* Subset of WorkspaceState needed for sidebar display.
4854
* Subscribing to only these fields prevents re-renders when messages update.
4955
*/
5056
export interface WorkspaceSidebarState {
51-
canInterrupt: boolean;
57+
interruptType: InterruptType;
5258
currentModel: string | null;
5359
recencyTimestamp: number | null;
5460
agentStatus: { emoji: string; message: string; url?: string } | undefined;
@@ -302,10 +308,15 @@ export class WorkspaceStore {
302308
const messages = aggregator.getAllMessages();
303309
const metadata = this.workspaceMetadata.get(workspaceId);
304310

311+
const hasHardInterrupt = activeStreams.some((c) => c.softInterruptPending);
312+
const hasSoftInterrupt = activeStreams.length > 0;
313+
314+
const interruptible = hasHardInterrupt ? "hard" : hasSoftInterrupt ? "soft" : "none";
315+
305316
return {
306317
name: metadata?.name ?? workspaceId, // Fall back to ID if metadata missing
307318
messages: aggregator.getDisplayedMessages(),
308-
canInterrupt: activeStreams.length > 0,
319+
interruptType: interruptible,
309320
isCompacting: aggregator.isCompacting(),
310321
loading: !hasMessages && !isCaughtUp,
311322
muxMessages: messages,
@@ -333,7 +344,7 @@ export class WorkspaceStore {
333344
// Return cached if values match
334345
if (
335346
cached &&
336-
cached.canInterrupt === fullState.canInterrupt &&
347+
cached.interruptType === fullState.interruptType &&
337348
cached.currentModel === fullState.currentModel &&
338349
cached.recencyTimestamp === fullState.recencyTimestamp &&
339350
cached.agentStatus === fullState.agentStatus
@@ -343,7 +354,7 @@ export class WorkspaceStore {
343354

344355
// Create and cache new state
345356
const newState: WorkspaceSidebarState = {
346-
canInterrupt: fullState.canInterrupt,
357+
interruptType: fullState.interruptType,
347358
currentModel: fullState.currentModel,
348359
recencyTimestamp: fullState.recencyTimestamp,
349360
agentStatus: fullState.agentStatus,

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface StreamingContext {
3737
startTime: number;
3838
isComplete: boolean;
3939
isCompacting: boolean;
40+
softInterruptPending: boolean;
4041
model: string;
4142
}
4243

@@ -292,6 +293,15 @@ export class StreamingMessageAggregator {
292293
return false;
293294
}
294295

296+
getSoftInterruptPending(): boolean {
297+
for (const context of this.activeStreams.values()) {
298+
if (context.softInterruptPending) {
299+
return true;
300+
}
301+
}
302+
return false;
303+
}
304+
295305
getCurrentModel(): string | undefined {
296306
// If there's an active stream, return its model
297307
for (const context of this.activeStreams.values()) {
@@ -357,6 +367,7 @@ export class StreamingMessageAggregator {
357367
startTime: Date.now(),
358368
isComplete: false,
359369
isCompacting,
370+
softInterruptPending: false,
360371
model: data.model,
361372
};
362373

@@ -379,12 +390,22 @@ export class StreamingMessageAggregator {
379390
const message = this.messages.get(data.messageId);
380391
if (!message) return;
381392

382-
// Append each delta as a new part (merging happens at display time)
383-
message.parts.push({
384-
type: "text",
385-
text: data.delta,
386-
timestamp: data.timestamp,
387-
});
393+
// Handle soft interrupt signal from backend
394+
if (data.softInterruptPending !== undefined) {
395+
const context = this.activeStreams.get(data.messageId);
396+
if (context) {
397+
context.softInterruptPending = data.softInterruptPending;
398+
}
399+
}
400+
401+
// Skip appending if this is an empty delta (e.g., just signaling interrupt)
402+
if (data.delta) {
403+
message.parts.push({
404+
type: "text",
405+
text: data.delta,
406+
timestamp: data.timestamp,
407+
});
408+
}
388409

389410
// Track delta for token counting and TPS calculation
390411
this.trackDelta(data.messageId, data.tokens, data.timestamp, "text");

src/common/types/stream.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface StreamDeltaEvent {
2727
delta: string;
2828
tokens: number; // Token count for this delta
2929
timestamp: number; // When delta was received (Date.now())
30+
softInterruptPending?: boolean; // Set to true when soft interrupt is triggered
3031
}
3132

3233
export interface StreamEndEvent {

0 commit comments

Comments
 (0)