From 91f6becd9b50be6008b4185bd9de28bd20c6eccf Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Wed, 28 Oct 2026 06:06:34 +0100 Subject: [PATCH 1/2] ui: show interrupted state for tool calls that never returned The chat-level streaming flag was applied to every assistant message, so a tool call left without a result in an earlier message kept spinning while a newer reply streamed. Scope streaming to the last assistant message and mark finished calls without a result as interrupted. Assisted-by: Claude (Opus 5.5 Medium) --- ...essageToolCallBlockExecShellCommand.svelte | 8 +++- .../ChatMessageToolCall/ToolCallBlock.svelte | 11 +++++ .../ChatMessageAgenticContent.svelte | 15 +++--- ...xec-shell-command-streaming.svelte.test.ts | 46 +++++++++++++++++++ .../tool-call-in-progress.svelte.test.ts | 9 ++++ 5 files changed, 81 insertions(+), 8 deletions(-) diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte index de72054430c6..cd09d6000c7f 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte @@ -8,7 +8,7 @@ import { parseExecShellCommandMeta } from './parsers/exec-shell-command'; import ToolCallBlock from './ToolCallBlock.svelte'; - import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte'; + import { AlertTriangle, Check, CircleStop, Loader2, XCircle } from '@lucide/svelte'; import { CollapsibleTerminalBlock } from '$lib/components/app'; import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants'; import { AgenticSectionType, AttachmentType } from '$lib/enums'; @@ -248,6 +248,12 @@ Executing command... + {:else if ctx.isInterrupted} +
+ + + Command interrupted before it returned output +
{:else if execShellError}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte index 16dd6e108837..f5e82ea2f5db 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte @@ -25,6 +25,7 @@ isPending: boolean; isStreamingCall: boolean; isCodeStreaming: boolean; + isInterrupted: boolean; } interface Props { @@ -81,6 +82,13 @@ const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING); const showSpinner = $derived(isPending || (isStreamingCall && isStreaming) || extraLiveStreaming); const isCodeStreaming = $derived(isStreaming && (isPending || isStreamingCall)); + // A finished call with no result message was stopped before it returned + // (user abort or the page went away mid-execution). + const isInterrupted = $derived( + section.type === AgenticSectionType.TOOL_CALL && + section.toolResult === undefined && + !showSpinner + ); const toolUi: ToolUiEntry | null = $derived(getToolUi(section.toolName)); const toolIcon: Component = $derived( @@ -107,6 +115,8 @@ if (isStreamingCall && !isStreaming) return 'incomplete'; + if (isInterrupted) return 'interrupted'; + return undefined; } @@ -126,6 +136,7 @@ > {@render children(meta, { isCodeStreaming, + isInterrupted, isPending, isStreaming, isStreamingCall diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte index b6138dfc0879..c2a8188ec798 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte @@ -26,11 +26,16 @@ let { isLastAssistantMessage = false, - isStreaming = false, + isStreaming: isChatStreaming = false, message, toolMessages = [] }: Props = $props(); + // The chat-level streaming flag is shared by every message; only the last + // assistant message is actually live, so earlier ones must not keep their + // unresolved tool calls spinning while a newer reply streams. + const isStreaming = $derived(isChatStreaming && isLastAssistantMessage); + let expandedStates: Record = $state({}); const showThoughtInProgress = $derived(Boolean(settingsStore.config.showThoughtInProgress)); @@ -48,9 +53,7 @@ ); const pendingPermission = $derived( - isStreaming && isLastAssistantMessage - ? agenticStore.getPendingPermissionRequest(message.convId) - : null + isStreaming ? agenticStore.getPendingPermissionRequest(message.convId) : null ); // dismissal applies to the request object, so the next request ( new @@ -67,9 +70,7 @@ } const pendingContinue = $derived( - isStreaming && isLastAssistantMessage - ? agenticStore.getPendingContinueRequest(message.convId) - : false + isStreaming ? agenticStore.getPendingContinueRequest(message.convId) : false ); let continueDismissed = $state(false); diff --git a/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts b/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts index 87668c0e30b6..879075ee4036 100644 --- a/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts +++ b/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts @@ -81,6 +81,7 @@ describe('exec_shell_command streaming title', () => { it('streams the partial command into the title with a cursor', async () => { const { container } = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: messageWithArgs('{"command":"git sta'), toolMessages: [] @@ -98,6 +99,7 @@ describe('exec_shell_command streaming title', () => { it('drops the cursor once the args JSON is complete', async () => { const screen = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: messageWithArgs('{"command":"git sta'), toolMessages: [] @@ -108,6 +110,7 @@ describe('exec_shell_command streaming title', () => { expect(hasCursor(screen.container)).toBe(true); screen.rerender({ + isLastAssistantMessage: true, isStreaming: true, message: messageWithArgs('{"command":"git status"}'), toolMessages: [] @@ -120,6 +123,7 @@ describe('exec_shell_command streaming title', () => { it('switches the prompt from the conversation cwd to the persisted one', async () => { const screen = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: messageWithArgs('{"command":"git status"}'), toolMessages: [] @@ -131,6 +135,7 @@ describe('exec_shell_command streaming title', () => { // Result message lands: its x-tool-cwd takes over the prompt. screen.rerender({ + isLastAssistantMessage: true, isStreaming: true, message: messageWithArgs('{"command":"git status"}'), toolMessages: [toolResultMessage('', '/other/dir')] @@ -143,6 +148,7 @@ describe('exec_shell_command streaming title', () => { it('shows the executing label only after the command finishes streaming', async () => { const screen = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: messageWithArgs('{"command":"git sta'), toolMessages: [] @@ -154,6 +160,7 @@ describe('exec_shell_command streaming title', () => { expect(screen.container.textContent).not.toContain('Executing command...'); screen.rerender({ + isLastAssistantMessage: true, isStreaming: true, message: messageWithArgs('{"command":"git status"}'), toolMessages: [] @@ -192,6 +199,7 @@ describe('exec_shell_command streaming title', () => { conversationsStore.activeMessages = [messageWithArgs('')]; const screen = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: conversationsStore.activeMessages[0]!, toolMessages: [] @@ -214,3 +222,41 @@ describe('exec_shell_command streaming title', () => { } }); }); + +describe('exec_shell_command interrupted call', () => { + beforeEach(() => { + settingsStore.updateMultipleConfig({ + [SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT]: true, + [SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]: true + }); + }); + + it('marks a call without a result as interrupted once streaming stops', async () => { + const { container } = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, + isStreaming: false, + message: messageWithArgs('{"command":"git status"}'), + toolMessages: [] + }); + + await tick(); + + expect(triggerText(container)).toContain('interrupted'); + expect(container.textContent).toContain('Command interrupted'); + expect(container.textContent).not.toContain('Executing command...'); + }); + + it('does not keep spinning in an earlier message while a newer reply streams', async () => { + const { container } = render(ChatMessageAgenticContent, { + isLastAssistantMessage: false, + isStreaming: true, + message: messageWithArgs('{"command":"git status"}'), + toolMessages: [] + }); + + await tick(); + + expect(triggerText(container)).toContain('interrupted'); + expect(container.textContent).not.toContain('Executing command...'); + }); +}); diff --git a/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts b/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts index e51fcb795dc5..ffb28e369a43 100644 --- a/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts +++ b/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts @@ -95,6 +95,7 @@ describe('Show tool call in progress', () => { setSettings({ alwaysShow: false, inProgress: true }); const { container } = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: assistantMessage(), toolMessages: [] @@ -107,6 +108,7 @@ describe('Show tool call in progress', () => { it('keeps the call collapsed while executing when the setting is off', async () => { const { container } = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: assistantMessage(), toolMessages: [] @@ -123,6 +125,7 @@ describe('Show tool call in progress', () => { setSettings({ alwaysShow: false, inProgress: true }); const screen = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: assistantMessage(), toolMessages: [] @@ -135,6 +138,7 @@ describe('Show tool call in progress', () => { // Same block, now finished: it must close on its own, no toggle given. screen.rerender({ + isLastAssistantMessage: true, isStreaming: true, message: assistantMessage(), toolMessages: [toolResultMessage()] @@ -150,6 +154,7 @@ describe('Show tool call in progress', () => { setSettings({ alwaysShow: false, inProgress: true }); const screen = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: assistantMessage(), toolMessages: [] @@ -161,6 +166,7 @@ describe('Show tool call in progress', () => { await collapseBlock(screen); screen.rerender({ + isLastAssistantMessage: true, isStreaming: true, message: assistantMessage(), toolMessages: [toolResultMessage()] @@ -175,6 +181,7 @@ describe('Show tool call in progress', () => { setSettings({ alwaysShow: true, inProgress: false }); const { container } = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: assistantMessage(), toolMessages: [toolResultMessage()] @@ -189,6 +196,7 @@ describe('Show tool call in progress', () => { setSettings({ alwaysShow: false, inProgress: true }); const screen = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: assistantMessage(), toolMessages: [] @@ -206,6 +214,7 @@ describe('Show tool call in progress', () => { it('lets a manual expand win over the collapsed default', async () => { const screen = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, isStreaming: true, message: assistantMessage(), toolMessages: [] From 39d6e3f57cdfee599e95af5cbbb2d6db6b320c25 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 29 Oct 2026 12:21:08 +0100 Subject: [PATCH 2/2] ui: mark a shell command stopped before any output as interrupted exec_shell_command creates its result message before running, so stopping it mid-run leaves an empty result instead of none. Assisted-by: Claude (Opus 5.5 Medium) --- ...atMessageToolCallBlockExecShellCommand.svelte | 1 + .../ChatMessageToolCall/ToolCallBlock.svelte | 13 ++++++++++--- .../exec-shell-command-streaming.svelte.test.ts | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte index cd09d6000c7f..771eb6f4a462 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte @@ -229,6 +229,7 @@ {/snippet} { expect(container.textContent).not.toContain('Executing command...'); }); + it('marks a command stopped before any output as interrupted', async () => { + // exec_shell_command creates its result message before running, so a + // stop mid-run leaves an empty result rather than none. + const { container } = render(ChatMessageAgenticContent, { + isLastAssistantMessage: true, + isStreaming: false, + message: messageWithArgs('{"command":"sleep 40"}'), + toolMessages: [toolResultMessage('', CWD)] + }); + + await tick(); + + expect(triggerText(container)).toContain('interrupted'); + expect(container.textContent).toContain('Command interrupted'); + }); + it('does not keep spinning in an earlier message while a newer reply streams', async () => { const { container } = render(ChatMessageAgenticContent, { isLastAssistantMessage: false,