Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -229,6 +229,7 @@
{/snippet}

<ToolCallBlock
emptyResultIsInterrupted
extraLiveStreaming={isLive}
{isStreaming}
meta={execShellMeta ? { errorMessage: execShellError } : null}
Expand All @@ -248,6 +249,12 @@
<Loader2 class="h-3 w-3 animate-spin" />
Executing command...
</div>
{:else if ctx.isInterrupted}
<div class="flex items-start gap-2 text-xs text-muted-foreground/70 italic">
<CircleStop class="mt-0.5 h-3 w-3 shrink-0" />

<span>Command interrupted before it returned output</span>
</div>
{:else if execShellError}
<div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
isPending: boolean;
isStreamingCall: boolean;
isCodeStreaming: boolean;
isInterrupted: boolean;
}

interface Props {
Expand All @@ -50,6 +51,12 @@
* inline within the body.
*/
spinIconWhenActive?: boolean;
/**
* Treat an empty result as interrupted too. For tools that create
* their result message before running (`exec_shell_command`), so a
* stop mid-run leaves an empty result rather than none.
*/
emptyResultIsInterrupted?: boolean;
/**
* Wrapper component that renders the title row and the body
* children. Defaults to CollapsibleContentBlock;
Expand All @@ -65,6 +72,7 @@

let {
children,
emptyResultIsInterrupted = false,
extraLiveStreaming = false,
isStreaming,
meta,
Expand All @@ -81,6 +89,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 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 || (emptyResultIsInterrupted && !section.toolResult)) &&
!showSpinner
);

const toolUi: ToolUiEntry | null = $derived(getToolUi(section.toolName));
const toolIcon: Component = $derived(
Expand All @@ -107,6 +122,8 @@

if (isStreamingCall && !isStreaming) return 'incomplete';

if (isInterrupted) return 'interrupted';

return undefined;
}

Expand All @@ -126,6 +143,7 @@
>
{@render children(meta, {
isCodeStreaming,
isInterrupted,
isPending,
isStreaming,
isStreamingCall
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, boolean> = $state({});

const showThoughtInProgress = $derived(Boolean(settingsStore.config.showThoughtInProgress));
Expand All @@ -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
Expand All @@ -67,9 +70,7 @@
}

const pendingContinue = $derived(
isStreaming && isLastAssistantMessage
? agenticStore.getPendingContinueRequest(message.convId)
: false
isStreaming ? agenticStore.getPendingContinueRequest(message.convId) : false
);

let continueDismissed = $state(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Expand All @@ -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: []
Expand All @@ -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: []
Expand All @@ -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: []
Expand All @@ -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')]
Expand All @@ -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: []
Expand All @@ -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: []
Expand Down Expand Up @@ -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: []
Expand All @@ -214,3 +222,57 @@ 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('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,
isStreaming: true,
message: messageWithArgs('{"command":"git status"}'),
toolMessages: []
});

await tick();

expect(triggerText(container)).toContain('interrupted');
expect(container.textContent).not.toContain('Executing command...');
});
});
9 changes: 9 additions & 0 deletions tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Expand All @@ -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: []
Expand All @@ -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: []
Expand All @@ -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()]
Expand All @@ -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: []
Expand All @@ -161,6 +166,7 @@ describe('Show tool call in progress', () => {
await collapseBlock(screen);

screen.rerender({
isLastAssistantMessage: true,
isStreaming: true,
message: assistantMessage(),
toolMessages: [toolResultMessage()]
Expand All @@ -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()]
Expand All @@ -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: []
Expand All @@ -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: []
Expand Down
Loading