Skip to content
Open
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
1 change: 1 addition & 0 deletions bun.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@coder/cmux",
Expand Down
45 changes: 33 additions & 12 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
}) => {
const chatAreaRef = useRef<HTMLDivElement>(null);

// Track whether user has interrupted once (for soft vs hard interrupt)
const [hasInterruptedOnce, setHasInterruptedOnce] = useState(false);

// Track active tab to conditionally enable resize functionality
// RightSidebar notifies us of tab changes via onTabChange callback
const [activeTab, setActiveTab] = useState<TabType>("costs");
Expand Down Expand Up @@ -188,6 +191,16 @@ const AIViewInner: React.FC<AIViewProps> = ({
setEditingMessage(undefined);
}, []);

const handleInterrupt = useCallback(async () => {
const soft = !hasInterruptedOnce; // First press = soft, second = hard

if (soft) {
setHasInterruptedOnce(true); // Mark for next press
}

await window.api.workspace.interruptStream(workspaceId, { soft });
}, [workspaceId, hasInterruptedOnce]);

const handleMessageSent = useCallback(() => {
// Enable auto-scroll when user sends a message
setAutoScroll(true);
Expand Down Expand Up @@ -246,6 +259,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, workspaceState?.loading]);

// Reset interrupt flag when stream ends (allows fresh start for next stream)
useEffect(() => {
if (workspaceState && !workspaceState.canInterrupt) {
setHasInterruptedOnce(false);
}
}, [workspaceState, workspaceState?.canInterrupt]);
// 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
Expand All @@ -258,7 +277,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
useAIViewKeybinds({
workspaceId,
currentModel: workspaceState?.currentModel ?? null,
canInterrupt: workspaceState?.canInterrupt ?? false,
canInterrupt: workspaceState.canInterrupt,
showRetryBarrier,
currentWorkspaceThinking,
setThinkingLevel,
Expand All @@ -269,6 +288,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
aggregator,
setEditingMessage,
vimEnabled,
onInterrupt: handleInterrupt,
});

// Clear editing state if the message being edited no longer exists
Expand Down Expand Up @@ -307,7 +327,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
);
}

// Extract state from workspace state
const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState;

// Get active stream message ID for token counting
Expand All @@ -320,6 +339,15 @@ const AIViewInner: React.FC<AIViewProps> = ({
// Merge consecutive identical stream errors
const mergedMessages = mergeConsecutiveStreamErrors(messages);

const model = currentModel ? getModelName(currentModel) : "";
// Determine if we're in "interrupting" state (second press, waiting for hard abort)
const interrupting = canInterrupt && hasInterruptedOnce;

const prefix = interrupting ? "⏸️ Interrupting " : "";
const action = interrupting ? "" : isCompacting ? "compacting..." : "streaming...";

const statusText = `${prefix}${model} ${action}`.trim();

// When editing, find the cutoff point
const editCutoffHistoryId = editingMessage
? mergedMessages.find(
Expand Down Expand Up @@ -454,19 +482,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
<PinnedTodoList workspaceId={workspaceId} />
{canInterrupt && (
<StreamingBarrier
statusText={
isCompacting
? currentModel
? `${getModelName(currentModel)} compacting...`
: "compacting..."
: currentModel
? `${getModelName(currentModel)} streaming...`
: "streaming..."
}
interrupting={interrupting}
statusText={statusText}
cancelText={
isCompacting
? `${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early`
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to ${interrupting ? "force" : ""} cancel`
}
tokenCount={
activeStreamMessageId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface StreamingBarrierProps {
cancelText: string; // e.g., "hit Esc to cancel"
tokenCount?: number;
tps?: number;
interrupting?: boolean;
}

export const StreamingBarrier: React.FC<StreamingBarrierProps> = ({
Expand All @@ -15,11 +16,13 @@ export const StreamingBarrier: React.FC<StreamingBarrierProps> = ({
cancelText,
tokenCount,
tps,
interrupting,
}) => {
const color = interrupting ? "var(--color-interrupted)" : "var(--color-assistant-border)";
return (
<div className={`flex items-center justify-between gap-4 ${className ?? ""}`}>
<div className="flex flex-1 items-center gap-2">
<BaseBarrier text={statusText} color="var(--color-assistant-border)" animate />
<BaseBarrier text={statusText} color={color} animate />
{tokenCount !== undefined && (
<span className="text-assistant-border font-mono text-[11px] whitespace-nowrap select-none">
~{tokenCount.toLocaleString()} tokens
Expand Down
7 changes: 5 additions & 2 deletions src/browser/hooks/useAIViewKeybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface UseAIViewKeybindsParams {
aggregator: StreamingMessageAggregator; // For compaction detection
setEditingMessage: (editing: { id: string; content: string } | undefined) => void;
vimEnabled: boolean; // For vim-aware interrupt keybind
onInterrupt: () => Promise<void>; // Callback to handle interrupt
}

/**
Expand Down Expand Up @@ -52,6 +53,7 @@ export function useAIViewKeybinds({
aggregator,
setEditingMessage,
vimEnabled,
onInterrupt,
}: UseAIViewKeybindsParams): void {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -81,7 +83,7 @@ export function useAIViewKeybinds({
if (canInterrupt || showRetryBarrier) {
e.preventDefault();
setAutoRetry(false); // User explicitly stopped - don't auto-retry
void window.api.workspace.interruptStream(workspaceId);
void onInterrupt();
return;
}
}
Expand All @@ -95,7 +97,7 @@ export function useAIViewKeybinds({
// No flag set - handleCompactionAbort will perform compaction with [truncated]
e.preventDefault();
setAutoRetry(false);
void window.api.workspace.interruptStream(workspaceId);
void onInterrupt();
}
// Let browser handle Ctrl+A (select all) when not compacting
return;
Expand Down Expand Up @@ -175,5 +177,6 @@ export function useAIViewKeybinds({
aggregator,
setEditingMessage,
vimEnabled,
onInterrupt,
]);
}
2 changes: 1 addition & 1 deletion src/browser/utils/commands/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
title: "Interrupt Streaming",
section: section.chat,
run: async () => {
await window.api.workspace.interruptStream(id);
await window.api.workspace.interruptStream(id, { soft: false }); // hard interrupt
},
});
list.push({
Expand Down
2 changes: 1 addition & 1 deletion src/common/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ export interface IPCApi {
): Promise<Result<void, SendMessageError>>;
interruptStream(
workspaceId: string,
options?: { abandonPartial?: boolean }
options?: { soft?: boolean; abandonPartial?: boolean }
): Promise<Result<void, string>>;
clearQueue(workspaceId: string): Promise<Result<void, string>>;
truncateHistory(workspaceId: string, percentage?: number): Promise<Result<void, string>>;
Expand Down
2 changes: 1 addition & 1 deletion src/common/utils/compaction/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export async function cancelCompaction(
// Interrupt stream with abandonPartial flag
// This tells backend to DELETE the partial instead of committing it
// Result: history ends with the compaction-request user message (which is fine - just a user message)
await window.api.workspace.interruptStream(workspaceId, { abandonPartial: true });
await window.api.workspace.interruptStream(workspaceId, { soft: false, abandonPartial: true });

// Enter edit mode on the compaction-request message with original command
// This lets user immediately edit the message or delete it
Expand Down
2 changes: 1 addition & 1 deletion src/desktop/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const api: IPCApi = {
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options),
resumeStream: (workspaceId, options) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options),
interruptStream: (workspaceId: string, options?: { abandonPartial?: boolean }) =>
interruptStream: (workspaceId, options) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options),
clearQueue: (workspaceId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_QUEUE_CLEAR, workspaceId),
Expand Down
7 changes: 5 additions & 2 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,14 +337,17 @@ export class AgentSession {
return this.streamWithHistory(model, options);
}

async interruptStream(): Promise<Result<void>> {
async interruptStream(options?: {
soft?: boolean;
abandonPartial?: boolean;
}): Promise<Result<void>> {
this.assertNotDisposed("interruptStream");

if (!this.aiService.isStreaming(this.workspaceId)) {
return Ok(undefined);
}

const stopResult = await this.aiService.stopStream(this.workspaceId);
const stopResult = await this.aiService.stopStream(this.workspaceId, options);
if (!stopResult.success) {
return Err(stopResult.error);
}
Expand Down
7 changes: 5 additions & 2 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,12 +896,15 @@ export class AIService extends EventEmitter {
}
}

async stopStream(workspaceId: string): Promise<Result<void>> {
async stopStream(
workspaceId: string,
options?: { soft?: boolean; abandonPartial?: boolean }
): Promise<Result<void>> {
if (this.mockModeEnabled && this.mockScenarioPlayer) {
this.mockScenarioPlayer.stop(workspaceId);
return Ok(undefined);
}
return this.streamManager.stopStream(workspaceId);
return this.streamManager.stopStream(workspaceId, options);
}

/**
Expand Down
8 changes: 6 additions & 2 deletions src/node/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,11 +975,15 @@ export class IpcMain {

ipcMain.handle(
IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM,
async (_event, workspaceId: string, options?: { abandonPartial?: boolean }) => {
async (
_event,
workspaceId: string,
options?: { soft?: boolean; abandonPartial?: boolean }
) => {
log.debug("interruptStream handler: Received", { workspaceId, options });
try {
const session = this.getOrCreateSession(workspaceId);
const stopResult = await session.interruptStream();
const stopResult = await session.interruptStream(options);
if (!stopResult.success) {
log.error("Failed to stop stream:", stopResult.error);
return { success: false, error: stopResult.error };
Expand Down
Loading