Skip to content

Commit 86d4d4f

Browse files
committed
Improve / simplify interruption
1 parent fddf767 commit 86d4d4f

17 files changed

+615
-364
lines changed

src/components/AIView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,9 @@ const AIViewInner: React.FC<AIViewProps> = ({ workspaceId, projectName, branch,
407407
[aggregator, displayVersion] // displayVersion is needed to detect internal state changes
408408
);
409409

410+
// Check if we can interrupt (streaming is active)
411+
const canInterrupt = aggregator.getActiveStreams().length > 0;
412+
410413
if (loading) {
411414
return (
412415
<ViewContainer className={className}>
@@ -523,6 +526,7 @@ const AIViewInner: React.FC<AIViewProps> = ({ workspaceId, projectName, branch,
523526
isCompacting={isCompacting}
524527
editingMessage={editingMessage}
525528
onCancelEdit={handleCancelEdit}
529+
canInterrupt={canInterrupt}
526530
/>
527531
</ChatArea>
528532

src/components/ChatInput.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,16 @@ const InputControls = styled.div`
2727
align-items: flex-end;
2828
`;
2929

30-
const InputField = styled.textarea<{ isEditing?: boolean }>`
30+
const InputField = styled.textarea<{ isEditing?: boolean; canInterrupt?: boolean }>`
3131
flex: 1;
3232
background: ${(props) => (props.isEditing ? "var(--color-editing-mode-alpha)" : "#1e1e1e")};
33-
border: 1px solid ${(props) => (props.isEditing ? "var(--color-editing-mode)" : "#3e3e42")};
33+
border: 1px solid
34+
${(props) =>
35+
props.isEditing
36+
? "var(--color-editing-mode)"
37+
: props.canInterrupt
38+
? "var(--color-interrupted)"
39+
: "#3e3e42"};
3440
color: #d4d4d4;
3541
padding: 8px 12px;
3642
border-radius: 4px;
@@ -44,7 +50,12 @@ const InputField = styled.textarea<{ isEditing?: boolean }>`
4450
4551
&:focus {
4652
outline: none;
47-
border-color: ${(props) => (props.isEditing ? "var(--color-editing-mode)" : "#569cd6")};
53+
border-color: ${(props) =>
54+
props.isEditing
55+
? "var(--color-editing-mode)"
56+
: props.canInterrupt
57+
? "var(--color-interrupted)"
58+
: "#569cd6"};
4859
}
4960
5061
&::placeholder {
@@ -101,6 +112,7 @@ export interface ChatInputProps {
101112
isCompacting?: boolean;
102113
editingMessage?: { id: string; content: string };
103114
onCancelEdit?: () => void;
115+
canInterrupt?: boolean; // Whether Esc can be used to interrupt streaming
104116
}
105117

106118
// Helper function to convert parsed command to display toast
@@ -245,6 +257,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
245257
isCompacting = false,
246258
editingMessage,
247259
onCancelEdit,
260+
canInterrupt = false,
248261
}) => {
249262
const [input, setInput] = usePersistedState("input:" + workspaceId, "");
250263
const [isSending, setIsSending] = useState(false);
@@ -373,12 +386,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
373386
setIsSending(true);
374387

375388
try {
376-
const result = await window.api.workspace.sendMessage(
377-
workspaceId,
378-
messageText,
379-
editingMessage?.id,
380-
thinkingLevel
381-
);
389+
const result = await window.api.workspace.sendMessage(workspaceId, messageText, {
390+
editMessageId: editingMessage?.id,
391+
thinkingLevel,
392+
});
382393

383394
if (!result.success) {
384395
// Log error for debugging
@@ -422,10 +433,23 @@ export const ChatInput: React.FC<ChatInputProps> = ({
422433
};
423434

424435
const handleKeyDown = (e: React.KeyboardEvent) => {
425-
// Handle Escape key to cancel editing
426-
if (e.key === "Escape" && editingMessage && onCancelEdit) {
436+
// Handle Escape key
437+
if (e.key === "Escape") {
427438
e.preventDefault();
428-
onCancelEdit();
439+
440+
// Priority 1: Cancel editing if in edit mode
441+
if (editingMessage && onCancelEdit) {
442+
onCancelEdit();
443+
return;
444+
}
445+
446+
// Priority 2: Interrupt streaming if active
447+
if (canInterrupt) {
448+
// Send empty message to trigger interrupt
449+
void window.api.workspace.sendMessage(workspaceId, "");
450+
return;
451+
}
452+
429453
return;
430454
}
431455

@@ -479,9 +503,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
479503
? "Edit your message... (Esc to cancel, Enter to send)"
480504
: isCompacting
481505
? "Compacting conversation..."
482-
: "Type a message... (Enter to send, Shift+Enter for newline)"
506+
: canInterrupt
507+
? "Type a message... (Esc to interrupt, Enter to send, Shift+Enter for newline)"
508+
: "Type a message... (Enter to send, Shift+Enter for newline)"
483509
}
484510
disabled={disabled || isSending || isCompacting}
511+
canInterrupt={canInterrupt}
485512
/>
486513
</InputControls>
487514
<ModeToggles>

src/components/Messages/InterruptedBarrier.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ const BarrierLine = styled.div`
1616
background: linear-gradient(
1717
to right,
1818
transparent,
19-
var(--color-warning, #f59e0b) 20%,
20-
var(--color-warning, #f59e0b) 80%,
19+
var(--color-interrupted) 20%,
20+
var(--color-interrupted) 80%,
2121
transparent
2222
);
2323
opacity: 0.3;
@@ -26,7 +26,7 @@ const BarrierLine = styled.div`
2626
const BarrierText = styled.div`
2727
font-family: var(--font-monospace);
2828
font-size: 10px;
29-
color: var(--color-warning, #f59e0b);
29+
color: var(--color-interrupted);
3030
text-transform: uppercase;
3131
letter-spacing: 1px;
3232
white-space: nowrap;

src/main.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,10 @@ import type {
1717
ToolCallStartEvent,
1818
ToolCallDeltaEvent,
1919
ToolCallEndEvent,
20-
} from "./types/aiEvents";
20+
} from "./types/stream";
2121
import { IPC_CHANNELS, getChatChannel } from "./constants/ipc-constants";
2222
import type { SendMessageError } from "./types/errors";
23-
import type { StreamErrorMessage } from "./types/ipc";
24-
import type { ThinkingLevel } from "./types/thinking";
23+
import type { StreamErrorMessage, SendMessageOptions } from "./types/ipc";
2524

2625
const config = new Config();
2726
const historyService = new HistoryService(config);
@@ -205,20 +204,30 @@ ipcMain.handle(IPC_CHANNELS.WORKSPACE_GET_INFO, async (_event, workspaceId: stri
205204

206205
ipcMain.handle(
207206
IPC_CHANNELS.WORKSPACE_SEND_MESSAGE,
208-
async (
209-
_event,
210-
workspaceId: string,
211-
message: string,
212-
editMessageId?: string,
213-
thinkingLevel?: ThinkingLevel
214-
) => {
207+
async (_event, workspaceId: string, message: string, options?: SendMessageOptions) => {
208+
const { editMessageId, thinkingLevel } = options ?? {};
215209
log.debug("sendMessage handler: Received", {
216210
workspaceId,
217211
messagePreview: message.substring(0, 50),
218212
editMessageId,
219213
thinkingLevel,
220214
});
221215
try {
216+
// Early exit: empty message during streaming = interrupt
217+
// This allows Esc key to interrupt without creating empty user messages
218+
if (!message.trim() && aiService.isStreaming(workspaceId)) {
219+
log.debug("sendMessage handler: Empty message during streaming, interrupting");
220+
const stopResult = await aiService.stopStream(workspaceId);
221+
if (!stopResult.success) {
222+
log.error("Failed to stop stream:", stopResult.error);
223+
return {
224+
success: false,
225+
error: createUnknownSendMessageError(stopResult.error),
226+
};
227+
}
228+
return { success: true };
229+
}
230+
222231
// If editing, truncate history after the message being edited
223232
if (editMessageId) {
224233
const truncateResult = await historyService.truncateAfterMessage(

src/preload.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,8 @@ const api: IPCApi = {
4242
create: (projectPath, branchName) =>
4343
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName),
4444
remove: (workspaceId: string) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId),
45-
sendMessage: (workspaceId, message, editMessageId, thinkingLevel) =>
46-
ipcRenderer.invoke(
47-
IPC_CHANNELS.WORKSPACE_SEND_MESSAGE,
48-
workspaceId,
49-
message,
50-
editMessageId,
51-
thinkingLevel
52-
),
45+
sendMessage: (workspaceId, message, options) =>
46+
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options),
5347
clearHistory: (workspaceId) =>
5448
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CLEAR_HISTORY, workspaceId),
5549
getInfo: (workspaceId) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId),

src/services/aiService.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
transformModelMessages,
1919
validateAnthropicCompliance,
2020
addInterruptedSentinel,
21+
filterEmptyAssistantMessages,
2122
} from "../utils/modelMessageTransform";
2223
import { applyCacheControl } from "../utils/cacheStrategy";
2324
import type { HistoryService } from "./historyService";
@@ -195,6 +196,12 @@ export class AIService extends EventEmitter {
195196
abortSignal?: AbortSignal
196197
): Promise<Result<void, SendMessageError>> {
197198
try {
199+
// DEBUG: Log streamMessage call
200+
const lastMessage = messages[messages.length - 1];
201+
log.debug(
202+
`[STREAM MESSAGE] workspaceId=${workspaceId} messageCount=${messages.length} lastRole=${lastMessage?.role}`
203+
);
204+
198205
// Before starting a new stream, commit any existing partial to history
199206
// This is idempotent - won't double-commit if already in chat.jsonl
200207
await this.partialService.commitToHistory(workspaceId);
@@ -208,8 +215,13 @@ export class AIService extends EventEmitter {
208215
// Dump original messages for debugging
209216
log.debug_obj(`${workspaceId}/1_original_messages.json`, messages);
210217

218+
// Filter out assistant messages with only reasoning (no text/tools)
219+
const filteredMessages = filterEmptyAssistantMessages(messages);
220+
log.debug(`Filtered ${messages.length - filteredMessages.length} empty assistant messages`);
221+
log.debug_obj(`${workspaceId}/1a_filtered_messages.json`, filteredMessages);
222+
211223
// Add [INTERRUPTED] sentinel to partial messages (for model context)
212-
const messagesWithSentinel = addInterruptedSentinel(messages);
224+
const messagesWithSentinel = addInterruptedSentinel(filteredMessages);
213225

214226
// Convert CmuxMessage to ModelMessage format using Vercel AI SDK utility
215227
// Type assertion needed because CmuxMessage has custom tool parts for interrupted tools

src/services/historyService.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Ok, Err } from "../types/result";
55
import type { CmuxMessage } from "../types/message";
66
import type { Config } from "../config";
77
import { MutexMap } from "../utils/mutexMap";
8+
import { log } from "./log";
89

910
/**
1011
* HistoryService - Manages chat history persistence and sequence numbering
@@ -122,6 +123,13 @@ export class HistoryService {
122123
await fs.mkdir(workspaceDir, { recursive: true });
123124
const historyPath = this.getChatHistoryPath(workspaceId);
124125

126+
// DEBUG: Log message append with caller stack trace
127+
const stack = new Error().stack?.split("\n").slice(2, 6).join("\n") ?? "no stack";
128+
log.debug(
129+
`[HISTORY APPEND] workspaceId=${workspaceId} role=${message.role} id=${message.id}`
130+
);
131+
log.debug(`[HISTORY APPEND] Call stack:\n${stack}`);
132+
125133
// Ensure message has a history sequence number
126134
if (!message.metadata) {
127135
// Create metadata with history sequence
@@ -156,6 +164,11 @@ export class HistoryService {
156164
workspaceId,
157165
};
158166

167+
// DEBUG: Log assigned sequence number
168+
log.debug(
169+
`[HISTORY APPEND] Assigned historySequence=${message.metadata.historySequence} role=${message.role}`
170+
);
171+
159172
await fs.appendFile(historyPath, JSON.stringify(historyEntry) + "\n");
160173
return Ok(undefined);
161174
} catch (error) {

0 commit comments

Comments
 (0)