Skip to content

Commit 1311db2

Browse files
committed
🤖 feat: implement soft-interrupts with block boundary detection
1 parent 7d2f8cc commit 1311db2

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";
@@ -248,15 +252,15 @@ const AIViewInner: React.FC<AIViewProps> = ({
248252
// Track if last message was interrupted or errored (for RetryBarrier)
249253
// Uses same logic as useResumeManager for DRY
250254
const showRetryBarrier = workspaceState
251-
? !workspaceState.canInterrupt &&
255+
? !canInterrupt(workspaceState.interruptType) &&
252256
hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStartTime)
253257
: false;
254258

255259
// Handle keyboard shortcuts (using optional refs that are safe even if not initialized)
256260
useAIViewKeybinds({
257261
workspaceId,
258262
currentModel: workspaceState?.currentModel ?? null,
259-
canInterrupt: workspaceState?.canInterrupt ?? false,
263+
canInterrupt: canInterrupt(workspaceState.interruptType),
260264
showRetryBarrier,
261265
currentWorkspaceThinking,
262266
setThinkingLevel,
@@ -305,8 +309,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
305309
);
306310
}
307311

308-
// Extract state from workspace state
309-
const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState;
312+
const { messages, interruptType, isCompacting, loading, currentModel } = workspaceState;
310313

311314
// Get active stream message ID for token counting
312315
const activeStreamMessageId = aggregator.getActiveStreamMessageId();
@@ -318,6 +321,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
318321
// Merge consecutive identical stream errors
319322
const mergedMessages = mergeConsecutiveStreamErrors(messages);
320323

324+
const model = currentModel ? getModelName(currentModel) : "";
325+
const interrupting = interruptType === "hard";
326+
327+
const prefix = interrupting ? "⏸️ Interrupting " : "";
328+
const action = interrupting ? "" : isCompacting ? "compacting..." : "streaming...";
329+
330+
const statusText = `${prefix}${model} ${action}`.trim();
331+
321332
// When editing, find the cutoff point
322333
const editCutoffHistoryId = editingMessage
323334
? mergedMessages.find(
@@ -390,8 +401,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
390401
onTouchMove={markUserInteraction}
391402
onScroll={handleScroll}
392403
role="log"
393-
aria-live={canInterrupt ? "polite" : "off"}
394-
aria-busy={canInterrupt}
404+
aria-live={canInterrupt(interruptType) ? "polite" : "off"}
405+
aria-busy={canInterrupt(interruptType)}
395406
aria-label="Conversation transcript"
396407
tabIndex={0}
397408
className="h-full overflow-y-auto p-[15px] leading-[1.5] break-words whitespace-pre-wrap"
@@ -450,21 +461,13 @@ const AIViewInner: React.FC<AIViewProps> = ({
450461
</>
451462
)}
452463
<PinnedTodoList workspaceId={workspaceId} />
453-
{canInterrupt && (
464+
{canInterrupt(interruptType) && (
454465
<StreamingBarrier
455-
statusText={
456-
isCompacting
457-
? currentModel
458-
? `${getModelName(currentModel)} compacting...`
459-
: "compacting..."
460-
: currentModel
461-
? `${getModelName(currentModel)} streaming...`
462-
: "streaming..."
463-
}
466+
statusText={statusText}
464467
cancelText={
465468
isCompacting
466469
? `${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early`
467-
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`
470+
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to ${interruptType === "hard" ? "force" : ""} cancel`
468471
}
469472
tokenCount={
470473
activeStreamMessageId
@@ -507,7 +510,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
507510
editingMessage={editingMessage}
508511
onCancelEdit={handleCancelEdit}
509512
onEditLastUserMessage={() => void handleEditLastUserMessage()}
510-
canInterrupt={canInterrupt}
513+
canInterrupt={canInterrupt(interruptType)}
511514
onReady={handleChatInputReady}
512515
/>
513516
</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
@@ -35,7 +35,7 @@ export interface WorkspaceState {
3535
name: string; // User-facing workspace name (e.g., "feature-branch")
3636
messages: DisplayedMessage[];
3737
queuedMessage: QueuedMessage | null;
38-
canInterrupt: boolean;
38+
interruptType: InterruptType; // Whether an interrupt is soft/hard or not possible
3939
isCompacting: boolean;
4040
loading: boolean;
4141
muxMessages: MuxMessage[];
@@ -46,12 +46,18 @@ export interface WorkspaceState {
4646
pendingStreamStartTime: number | null;
4747
}
4848

49+
export type InterruptType = "soft" | "hard" | "none";
50+
51+
export function canInterrupt(interruptible: InterruptType): boolean {
52+
return interruptible === "soft" || interruptible === "hard";
53+
}
54+
4955
/**
5056
* Subset of WorkspaceState needed for sidebar display.
5157
* Subscribing to only these fields prevents re-renders when messages update.
5258
*/
5359
export interface WorkspaceSidebarState {
54-
canInterrupt: boolean;
60+
interruptType: InterruptType;
5561
currentModel: string | null;
5662
recencyTimestamp: number | null;
5763
agentStatus: { emoji: string; message: string; url?: string } | undefined;
@@ -336,11 +342,16 @@ export class WorkspaceStore {
336342
const messages = aggregator.getAllMessages();
337343
const metadata = this.workspaceMetadata.get(workspaceId);
338344

345+
const hasHardInterrupt = activeStreams.some((c) => c.softInterruptPending);
346+
const hasSoftInterrupt = activeStreams.length > 0;
347+
348+
const interruptible = hasHardInterrupt ? "hard" : hasSoftInterrupt ? "soft" : "none";
349+
339350
return {
340351
name: metadata?.name ?? workspaceId, // Fall back to ID if metadata missing
341352
messages: aggregator.getDisplayedMessages(),
342353
queuedMessage: this.queuedMessages.get(workspaceId) ?? null,
343-
canInterrupt: activeStreams.length > 0,
354+
interruptType: interruptible,
344355
isCompacting: aggregator.isCompacting(),
345356
loading: !hasMessages && !isCaughtUp,
346357
muxMessages: messages,
@@ -368,7 +379,7 @@ export class WorkspaceStore {
368379
// Return cached if values match
369380
if (
370381
cached &&
371-
cached.canInterrupt === fullState.canInterrupt &&
382+
cached.interruptType === fullState.interruptType &&
372383
cached.currentModel === fullState.currentModel &&
373384
cached.recencyTimestamp === fullState.recencyTimestamp &&
374385
cached.agentStatus === fullState.agentStatus
@@ -378,7 +389,7 @@ export class WorkspaceStore {
378389

379390
// Create and cache new state
380391
const newState: WorkspaceSidebarState = {
381-
canInterrupt: fullState.canInterrupt,
392+
interruptType: fullState.interruptType,
382393
currentModel: fullState.currentModel,
383394
recencyTimestamp: fullState.recencyTimestamp,
384395
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)