From 1311db2afbc6427f46e16ada81bcecee42e7b060 Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 17 Nov 2025 14:40:30 +1100 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20feat:=20implement=20soft-int?= =?UTF-8?q?errupts=20with=20block=20boundary=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/App.tsx | 4 +- src/browser/components/AIView.tsx | 41 ++++---- src/browser/components/WorkspaceStatusDot.tsx | 8 +- src/browser/hooks/useResumeManager.ts | 8 +- src/browser/stores/WorkspaceStore.test.ts | 6 +- src/browser/stores/WorkspaceStore.ts | 21 ++++- .../messages/StreamingMessageAggregator.ts | 33 +++++-- src/common/types/stream.ts | 1 + src/node/services/streamManager.ts | 94 +++++++++++++++---- tests/ipcMain/sendMessage.test.ts | 13 ++- 10 files changed, 167 insertions(+), 62 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 6860fa693..7f2dfd170 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -13,7 +13,7 @@ import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue"; -import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; +import { useWorkspaceStoreRaw, useWorkspaceRecency, canInterrupt } from "./stores/WorkspaceStore"; import { ChatInput } from "./components/ChatInput/index"; import type { ChatInputAPI } from "./components/ChatInput/types"; @@ -415,7 +415,7 @@ function AppInner() { const allStates = workspaceStore.getAllStates(); const streamingModels = new Map(); for (const [workspaceId, state] of allStates) { - if (state.canInterrupt && state.currentModel) { + if (canInterrupt(state.interruptType) && state.currentModel) { streamingModels.set(workspaceId, state.currentModel); } } diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 6efd7c040..4ab5e83c4 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -20,7 +20,11 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useAutoScroll } from "@/browser/hooks/useAutoScroll"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useThinking } from "@/browser/contexts/ThinkingContext"; -import { useWorkspaceState, useWorkspaceAggregator } from "@/browser/stores/WorkspaceStore"; +import { + useWorkspaceState, + useWorkspaceAggregator, + canInterrupt, +} from "@/browser/stores/WorkspaceStore"; import { WorkspaceHeader } from "./WorkspaceHeader"; import { getModelName } from "@/common/utils/ai/models"; import type { DisplayedMessage } from "@/common/types/message"; @@ -248,7 +252,7 @@ const AIViewInner: React.FC = ({ // Track if last message was interrupted or errored (for RetryBarrier) // Uses same logic as useResumeManager for DRY const showRetryBarrier = workspaceState - ? !workspaceState.canInterrupt && + ? !canInterrupt(workspaceState.interruptType) && hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStartTime) : false; @@ -256,7 +260,7 @@ const AIViewInner: React.FC = ({ useAIViewKeybinds({ workspaceId, currentModel: workspaceState?.currentModel ?? null, - canInterrupt: workspaceState?.canInterrupt ?? false, + canInterrupt: canInterrupt(workspaceState.interruptType), showRetryBarrier, currentWorkspaceThinking, setThinkingLevel, @@ -305,8 +309,7 @@ const AIViewInner: React.FC = ({ ); } - // Extract state from workspace state - const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState; + const { messages, interruptType, isCompacting, loading, currentModel } = workspaceState; // Get active stream message ID for token counting const activeStreamMessageId = aggregator.getActiveStreamMessageId(); @@ -318,6 +321,14 @@ const AIViewInner: React.FC = ({ // Merge consecutive identical stream errors const mergedMessages = mergeConsecutiveStreamErrors(messages); + const model = currentModel ? getModelName(currentModel) : ""; + const interrupting = interruptType === "hard"; + + 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( @@ -390,8 +401,8 @@ const AIViewInner: React.FC = ({ onTouchMove={markUserInteraction} onScroll={handleScroll} role="log" - aria-live={canInterrupt ? "polite" : "off"} - aria-busy={canInterrupt} + aria-live={canInterrupt(interruptType) ? "polite" : "off"} + aria-busy={canInterrupt(interruptType)} aria-label="Conversation transcript" tabIndex={0} className="h-full overflow-y-auto p-[15px] leading-[1.5] break-words whitespace-pre-wrap" @@ -450,21 +461,13 @@ const AIViewInner: React.FC = ({ )} - {canInterrupt && ( + {canInterrupt(interruptType) && ( = ({ editingMessage={editingMessage} onCancelEdit={handleCancelEdit} onEditLastUserMessage={() => void handleEditLastUserMessage()} - canInterrupt={canInterrupt} + canInterrupt={canInterrupt(interruptType)} onReady={handleChatInputReady} /> diff --git a/src/browser/components/WorkspaceStatusDot.tsx b/src/browser/components/WorkspaceStatusDot.tsx index 40f495ab7..877344afe 100644 --- a/src/browser/components/WorkspaceStatusDot.tsx +++ b/src/browser/components/WorkspaceStatusDot.tsx @@ -1,5 +1,5 @@ import { cn } from "@/common/lib/utils"; -import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; +import { canInterrupt, useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; import { getStatusTooltip } from "@/browser/utils/ui/statusTooltip"; import { memo, useMemo } from "react"; import { Tooltip, TooltipWrapper } from "./Tooltip"; @@ -11,10 +11,10 @@ export const WorkspaceStatusDot = memo<{ size?: number; }>( ({ workspaceId, lastReadTimestamp, onClick, size = 8 }) => { - const { canInterrupt, currentModel, agentStatus, recencyTimestamp } = + const { interruptType, currentModel, agentStatus, recencyTimestamp } = useWorkspaceSidebarState(workspaceId); - const streaming = canInterrupt; + const streaming = canInterrupt(interruptType); // Compute unread status if lastReadTimestamp provided (sidebar only) const unread = useMemo(() => { @@ -35,7 +35,7 @@ export const WorkspaceStatusDot = memo<{ [streaming, currentModel, agentStatus, unread, recencyTimestamp] ); - const bgColor = canInterrupt ? "bg-blue-400" : unread ? "bg-gray-300" : "bg-muted-dark"; + const bgColor = streaming ? "bg-blue-400" : unread ? "bg-gray-300" : "bg-muted-dark"; const cursor = onClick && !streaming ? "cursor-pointer" : "cursor-default"; return ( diff --git a/src/browser/hooks/useResumeManager.ts b/src/browser/hooks/useResumeManager.ts index 507ab7523..aa3bbe66d 100644 --- a/src/browser/hooks/useResumeManager.ts +++ b/src/browser/hooks/useResumeManager.ts @@ -1,5 +1,9 @@ import { useEffect, useRef } from "react"; -import { useWorkspaceStoreRaw, type WorkspaceState } from "@/browser/stores/WorkspaceStore"; +import { + canInterrupt, + useWorkspaceStoreRaw, + type WorkspaceState, +} from "@/browser/stores/WorkspaceStore"; import { CUSTOM_EVENTS, type CustomEventType } from "@/common/constants/events"; import { getAutoRetryKey, getRetryStateKey } from "@/common/constants/storage"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; @@ -100,7 +104,7 @@ export function useResumeManager() { } // 1. Must have interrupted stream that's eligible for auto-retry (not currently streaming) - if (state.canInterrupt) return false; // Currently streaming + if (canInterrupt(state.interruptType)) return false; // Currently streaming if (!isEligibleForAutoRetry(state.messages, state.pendingStreamStartTime)) { return false; diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index e085810f4..dbdd55937 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -253,7 +253,7 @@ describe("WorkspaceStore", () => { expect(state).toMatchObject({ messages: [], - canInterrupt: false, + interruptType: "none", isCompacting: false, loading: true, // loading because not caught up muxMessages: [], @@ -273,7 +273,7 @@ describe("WorkspaceStore", () => { // Object.is() comparison and skip re-renders for primitive values. // TODO: Optimize aggregator caching in Phase 2 expect(state1).toEqual(state2); - expect(state1.canInterrupt).toBe(state2.canInterrupt); + expect(state1.interruptType).toBe(state2.interruptType); expect(state1.loading).toBe(state2.loading); }); }); @@ -428,7 +428,7 @@ describe("WorkspaceStore", () => { const state2 = store.getWorkspaceState("test-workspace"); expect(state1).not.toBe(state2); // Cache should be invalidated - expect(state2.canInterrupt).toBe(true); // Stream started, so can interrupt + expect(state2.interruptType).toBeTruthy(); // Stream started, so can interrupt }); it("invalidates getAllStates() cache when workspace changes", async () => { diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 8433834ae..76c83f32c 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -35,7 +35,7 @@ export interface WorkspaceState { name: string; // User-facing workspace name (e.g., "feature-branch") messages: DisplayedMessage[]; queuedMessage: QueuedMessage | null; - canInterrupt: boolean; + interruptType: InterruptType; // Whether an interrupt is soft/hard or not possible isCompacting: boolean; loading: boolean; muxMessages: MuxMessage[]; @@ -46,12 +46,18 @@ export interface WorkspaceState { pendingStreamStartTime: number | null; } +export type InterruptType = "soft" | "hard" | "none"; + +export function canInterrupt(interruptible: InterruptType): boolean { + return interruptible === "soft" || interruptible === "hard"; +} + /** * Subset of WorkspaceState needed for sidebar display. * Subscribing to only these fields prevents re-renders when messages update. */ export interface WorkspaceSidebarState { - canInterrupt: boolean; + interruptType: InterruptType; currentModel: string | null; recencyTimestamp: number | null; agentStatus: { emoji: string; message: string; url?: string } | undefined; @@ -336,11 +342,16 @@ export class WorkspaceStore { const messages = aggregator.getAllMessages(); const metadata = this.workspaceMetadata.get(workspaceId); + const hasHardInterrupt = activeStreams.some((c) => c.softInterruptPending); + const hasSoftInterrupt = activeStreams.length > 0; + + const interruptible = hasHardInterrupt ? "hard" : hasSoftInterrupt ? "soft" : "none"; + return { name: metadata?.name ?? workspaceId, // Fall back to ID if metadata missing messages: aggregator.getDisplayedMessages(), queuedMessage: this.queuedMessages.get(workspaceId) ?? null, - canInterrupt: activeStreams.length > 0, + interruptType: interruptible, isCompacting: aggregator.isCompacting(), loading: !hasMessages && !isCaughtUp, muxMessages: messages, @@ -368,7 +379,7 @@ export class WorkspaceStore { // Return cached if values match if ( cached && - cached.canInterrupt === fullState.canInterrupt && + cached.interruptType === fullState.interruptType && cached.currentModel === fullState.currentModel && cached.recencyTimestamp === fullState.recencyTimestamp && cached.agentStatus === fullState.agentStatus @@ -378,7 +389,7 @@ export class WorkspaceStore { // Create and cache new state const newState: WorkspaceSidebarState = { - canInterrupt: fullState.canInterrupt, + interruptType: fullState.interruptType, currentModel: fullState.currentModel, recencyTimestamp: fullState.recencyTimestamp, agentStatus: fullState.agentStatus, diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index fcdd02d91..19759c045 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -37,6 +37,7 @@ interface StreamingContext { startTime: number; isComplete: boolean; isCompacting: boolean; + softInterruptPending: boolean; model: string; } @@ -292,6 +293,15 @@ export class StreamingMessageAggregator { return false; } + getSoftInterruptPending(): boolean { + for (const context of this.activeStreams.values()) { + if (context.softInterruptPending) { + return true; + } + } + return false; + } + getCurrentModel(): string | undefined { // If there's an active stream, return its model for (const context of this.activeStreams.values()) { @@ -357,6 +367,7 @@ export class StreamingMessageAggregator { startTime: Date.now(), isComplete: false, isCompacting, + softInterruptPending: false, model: data.model, }; @@ -379,12 +390,22 @@ export class StreamingMessageAggregator { const message = this.messages.get(data.messageId); if (!message) return; - // Append each delta as a new part (merging happens at display time) - message.parts.push({ - type: "text", - text: data.delta, - timestamp: data.timestamp, - }); + // Handle soft interrupt signal from backend + if (data.softInterruptPending !== undefined) { + const context = this.activeStreams.get(data.messageId); + if (context) { + context.softInterruptPending = data.softInterruptPending; + } + } + + // Skip appending if this is an empty delta (e.g., just signaling interrupt) + if (data.delta) { + message.parts.push({ + type: "text", + text: data.delta, + timestamp: data.timestamp, + }); + } // Track delta for token counting and TPS calculation this.trackDelta(data.messageId, data.tokens, data.timestamp, "text"); diff --git a/src/common/types/stream.ts b/src/common/types/stream.ts index dcbf3547a..49e5c2804 100644 --- a/src/common/types/stream.ts +++ b/src/common/types/stream.ts @@ -27,6 +27,7 @@ export interface StreamDeltaEvent { delta: string; tokens: number; // Token count for this delta timestamp: number; // When delta was received (Date.now()) + softInterruptPending?: boolean; // Set to true when soft interrupt is triggered } export interface StreamEndEvent { diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 0faebea56..04942c6b3 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -107,6 +107,8 @@ interface WorkspaceStreamInfo { partialWritePromise?: Promise; // Track background processing promise for guaranteed cleanup processingPromise: Promise; + // Flag for soft-interrupt: when true, stream will end at next block boundary + softInterruptPending: boolean; // Temporary directory for tool outputs (auto-cleaned when stream ends) runtimeTempDir: string; // Runtime for temp directory cleanup @@ -412,30 +414,37 @@ export class StreamManager extends EventEmitter { ): Promise { try { streamInfo.state = StreamState.STOPPING; - // Flush any pending partial write immediately (preserves work on interruption) await this.flushPartialWrite(workspaceId, streamInfo); streamInfo.abortController.abort(); - // CRITICAL: Wait for processing to fully complete before cleanup - // This prevents race conditions where the old stream is still running - // while a new stream starts (e.g., old stream writing to partial.json) - await streamInfo.processingPromise; + await this.cleanupStream(workspaceId, streamInfo); + } catch (error) { + console.error("Error during stream cancellation:", error); + // Force cleanup even if cancellation fails + this.workspaceStreams.delete(workspaceId); + } + } - // Get usage and duration metadata (usage may be undefined if aborted early) - const { usage, duration } = await this.getStreamMetadata(streamInfo); + // Checks if a soft interrupt is necessary, and performs one if so + // Similar to cancelStreamSafely but performs cleanup without blocking + private async checkSoftCancelStream( + workspaceId: WorkspaceId, + streamInfo: WorkspaceStreamInfo + ): Promise { + if (!streamInfo.softInterruptPending) return; + try { + streamInfo.state = StreamState.STOPPING; - // Emit abort event with usage if available - this.emit("stream-abort", { - type: "stream-abort", - workspaceId: workspaceId as string, - messageId: streamInfo.messageId, - metadata: { usage, duration }, - }); + // Flush any pending partial write immediately (preserves work on interruption) + await this.flushPartialWrite(workspaceId, streamInfo); - // Clean up immediately - this.workspaceStreams.delete(workspaceId); + streamInfo.abortController.abort(); + + // Return back to the stream loop so we can wait for it to finish before + // sending the stream abort event. + void this.cleanupStream(workspaceId, streamInfo); } catch (error) { console.error("Error during stream cancellation:", error); // Force cleanup even if cancellation fails @@ -443,6 +452,28 @@ export class StreamManager extends EventEmitter { } } + private async cleanupStream( + workspaceId: WorkspaceId, + streamInfo: WorkspaceStreamInfo + ): Promise { + // CRITICAL: Wait for processing to fully complete before cleanup + // This prevents race conditions where the old stream is still running + // while a new stream starts (e.g., old stream writing to partial.json) + await streamInfo.processingPromise; + // Get usage and duration metadata (usage may be undefined if aborted early) + const { usage, duration } = await this.getStreamMetadata(streamInfo); + // Emit abort event with usage if available + this.emit("stream-abort", { + type: "stream-abort", + workspaceId: workspaceId as string, + messageId: streamInfo.messageId, + metadata: { usage, duration }, + }); + + // Clean up immediately + this.workspaceStreams.delete(workspaceId); + } + /** * Atomically creates a new stream with all necessary setup */ @@ -529,6 +560,7 @@ export class StreamManager extends EventEmitter { lastPartialWriteTime: 0, // Initialize to 0 to allow immediate first write partialWritePromise: undefined, // No write in flight initially processingPromise: Promise.resolve(), // Placeholder, overwritten in startStream + softInterruptPending: false, runtimeTempDir, // Stream-scoped temp directory for tool outputs runtime, // Runtime for temp directory cleanup }; @@ -692,6 +724,7 @@ export class StreamManager extends EventEmitter { workspaceId: workspaceId as string, messageId: streamInfo.messageId, }); + await this.checkSoftCancelStream(workspaceId, streamInfo); break; } @@ -746,6 +779,7 @@ export class StreamManager extends EventEmitter { strippedOutput ); } + await this.checkSoftCancelStream(workspaceId, streamInfo); break; } @@ -782,6 +816,7 @@ export class StreamManager extends EventEmitter { toolErrorPart.toolName, errorOutput ); + await this.checkSoftCancelStream(workspaceId, streamInfo); break; } @@ -827,9 +862,14 @@ export class StreamManager extends EventEmitter { case "start-step": case "text-start": case "finish": - case "finish-step": // These events can be logged or handled if needed break; + + case "finish-step": + case "text-end": + case "tool-input-end": + await this.checkSoftCancelStream(workspaceId, streamInfo); + break; } } @@ -1317,14 +1357,32 @@ export class StreamManager extends EventEmitter { /** * Stops an active stream for a workspace + * First call: Sets soft interrupt and emits delta event → frontend shows "Interrupting..." + * Second call: Hard aborts the stream immediately */ async stopStream(workspaceId: string): Promise> { const typedWorkspaceId = workspaceId as WorkspaceId; try { const streamInfo = this.workspaceStreams.get(typedWorkspaceId); - if (streamInfo) { + if (!streamInfo) { + return Ok(undefined); // No active stream + } + + if (streamInfo.softInterruptPending) { await this.cancelStreamSafely(typedWorkspaceId, streamInfo); + } else { + // First Escape: Soft interrupt - emit delta to notify frontend + streamInfo.softInterruptPending = true; + this.emit("stream-delta", { + type: "stream-delta", + workspaceId: workspaceId, + messageId: streamInfo.messageId, + delta: "", + tokens: 0, + timestamp: Date.now(), + softInterruptPending: true, // Signal to frontend + }); } return Ok(undefined); } catch (error) { diff --git a/tests/ipcMain/sendMessage.test.ts b/tests/ipcMain/sendMessage.test.ts index 61d4113d6..9040dc01c 100644 --- a/tests/ipcMain/sendMessage.test.ts +++ b/tests/ipcMain/sendMessage.test.ts @@ -105,14 +105,21 @@ describeIntegration("IpcMain sendMessage integration tests", () => { const collector = createEventCollector(env.sentEvents, workspaceId); await collector.waitForEvent("stream-start", 5000); - // Use interruptStream() to interrupt - const interruptResult = await env.mockIpcRenderer.invoke( + // Use interruptStream() to soft-interrupt + const softInterruptResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId ); // Should succeed (interrupt is not an error) - expect(interruptResult.success).toBe(true); + expect(softInterruptResult.success).toBe(true); + + // Then hard-interrupt + const hardInterruptResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + workspaceId + ); + expect(hardInterruptResult.success).toBe(true); // Wait for abort or end event const abortOrEndReceived = await waitFor(() => { From 192365cd5012a483b6ac9d1d3fde82637a230fe4 Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 17 Nov 2025 14:59:54 +1100 Subject: [PATCH 2/3] review --- src/browser/components/WorkspaceListItem.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/components/WorkspaceListItem.tsx b/src/browser/components/WorkspaceListItem.tsx index bb8e17db4..aa4cf3001 100644 --- a/src/browser/components/WorkspaceListItem.tsx +++ b/src/browser/components/WorkspaceListItem.tsx @@ -1,7 +1,7 @@ import { useRename } from "@/browser/contexts/WorkspaceRenameContext"; import { cn } from "@/common/lib/utils"; import { useGitStatus } from "@/browser/stores/GitStatusStore"; -import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; +import { canInterrupt, useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import React, { useCallback, useState } from "react"; import { GitStatusIndicator } from "./GitStatusIndicator"; @@ -97,7 +97,7 @@ const WorkspaceListItemInner: React.FC = ({ [onToggleUnread, workspaceId] ); - const { canInterrupt } = useWorkspaceSidebarState(workspaceId); + const { interruptType } = useWorkspaceSidebarState(workspaceId); return ( @@ -163,7 +163,7 @@ const WorkspaceListItemInner: React.FC = ({ }} title="Double-click to rename" > - {canInterrupt ? ( + {canInterrupt(interruptType) ? ( {displayName} From 385a16c19d0f6d550486e170e61d7d19b4e694fb Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 17 Nov 2025 20:53:36 +1100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20feat:=20use=20standard=20int?= =?UTF-8?q?errupted=20color=20for=20interrupting=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/AIView.tsx | 2 ++ .../components/Messages/ChatBarrier/StreamingBarrier.tsx | 5 ++++- src/node/services/streamManager.ts | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 4ab5e83c4..be1a65fe3 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -479,6 +479,8 @@ const AIViewInner: React.FC = ({ ? aggregator.getStreamingTPS(activeStreamMessageId) : undefined } + interrupting={interrupting + } /> )} {workspaceState?.queuedMessage && ( diff --git a/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx b/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx index 3ebff7b4b..e61e41751 100644 --- a/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx +++ b/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx @@ -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 = ({ @@ -15,11 +16,13 @@ export const StreamingBarrier: React.FC = ({ cancelText, tokenCount, tps, + interrupting, }) => { + const color = interrupting ? "var(--color-interrupted)" : "var(--color-assistant-border)"; return (
- + {tokenCount !== undefined && ( ~{tokenCount.toLocaleString()} tokens diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 04942c6b3..a407cd364 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -862,12 +862,12 @@ export class StreamManager extends EventEmitter { case "start-step": case "text-start": case "finish": + case "tool-input-end": // These events can be logged or handled if needed break; case "finish-step": case "text-end": - case "tool-input-end": await this.checkSoftCancelStream(workspaceId, streamInfo); break; }