feat: add stop execution with hybrid graceful/force abort

Implement a stop execution feature that allows users to abort ongoing LLM
streaming, kill running tool calls, and clear pending permission/human input
requests. Uses a hybrid approach: first click sends graceful SIGTERM, second
click within 2s sends SIGKILL and force-closes MCP clients.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-01-30 06:53:50 +05:30
parent 9828ea8301
commit a3e681a7c4
13 changed files with 642 additions and 54 deletions

View file

@ -295,7 +295,7 @@ export function setupIpcHandlers() {
return { success: true };
},
'runs:stop': async (_event, args) => {
await runsCore.stop(args.runId);
await runsCore.stop(args.runId, args.force);
return { success: true };
},
'runs:fetch': async (_event, args) => {

View file

@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { Button } from './components/ui/button';
import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen } from 'lucide-react';
import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor';
import { ChatInputBar } from './components/chat-button';
@ -279,7 +279,9 @@ const collectFilePaths = (nodes: TreeNode[]): string[] =>
// Inner component that uses the controller to access mentions
interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
presetMessage?: string
onPresetMessageConsumed?: () => void
runId?: string | null
@ -287,7 +289,9 @@ interface ChatInputInnerProps {
function ChatInputInner({
onSubmit,
onStop,
isProcessing,
isStopping,
presetMessage,
onPresetMessageConsumed,
runId,
@ -327,19 +331,39 @@ function ChatInputInner({
focusTrigger={runId}
className="min-h-6 py-0 border-0 shadow-none focus-visible:ring-0 rounded-none"
/>
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
canSubmit
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
{isProcessing ? (
<Button
size="icon"
onClick={onStop}
title={isStopping ? "Click again to force stop" : "Stop generation"}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
isStopping
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
canSubmit
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
)
}
@ -350,7 +374,9 @@ interface ChatInputWithMentionsProps {
recentFiles: string[]
visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
presetMessage?: string
onPresetMessageConsumed?: () => void
runId?: string | null
@ -361,7 +387,9 @@ function ChatInputWithMentions({
recentFiles,
visibleFiles,
onSubmit,
onStop,
isProcessing,
isStopping,
presetMessage,
onPresetMessageConsumed,
runId,
@ -370,7 +398,9 @@ function ChatInputWithMentions({
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
<ChatInputInner
onSubmit={onSubmit}
onStop={onStop}
isProcessing={isProcessing}
isStopping={isStopping}
presetMessage={presetMessage}
onPresetMessageConsumed={onPresetMessageConsumed}
runId={runId}
@ -414,6 +444,8 @@ function App() {
const [runId, setRunId] = useState<string | null>(null)
const runIdRef = useRef<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [isStopping, setIsStopping] = useState(false)
const [stopClickedAt, setStopClickedAt] = useState<number | null>(null)
const [agentId] = useState<string>('copilot')
const [presetMessage, setPresetMessage] = useState<string | undefined>(undefined)
@ -758,6 +790,8 @@ function App() {
case 'run-processing-end':
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
break
case 'start':
@ -936,8 +970,32 @@ function App() {
break
}
case 'run-stopped':
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
// Clear pending requests since they've been aborted
setPendingPermissionRequests(new Map())
setPendingAskHumanRequests(new Map())
// Flush any streaming content as a message
setCurrentAssistantMessage(currentMsg => {
if (currentMsg) {
setConversation(prev => [...prev, {
id: `assistant-stopped-${Date.now()}`,
role: 'assistant',
content: currentMsg,
timestamp: Date.now(),
}])
}
return ''
})
setCurrentReasoning('')
break
case 'error':
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
console.error('Run error:', event.error)
break
}
@ -1009,6 +1067,21 @@ function App() {
}
}
const handleStop = useCallback(async () => {
if (!runId) return
const now = Date.now()
const isForce = isStopping && stopClickedAt !== null && (now - stopClickedAt) < 2000
setStopClickedAt(now)
setIsStopping(true)
try {
await window.ipc.invoke('runs:stop', { runId, force: isForce })
} catch (error) {
console.error('Failed to stop run:', error)
}
}, [runId, isStopping, stopClickedAt])
const handlePermissionResponse = useCallback(async (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => {
if (!runId) return
@ -1779,7 +1852,9 @@ function App() {
recentFiles={recentWikiFiles}
visibleFiles={visibleKnowledgeFiles}
onSubmit={handlePromptSubmit}
onStop={handleStop}
isProcessing={isProcessing}
isStopping={isStopping}
presetMessage={presetMessage}
onPresetMessageConsumed={() => setPresetMessage(undefined)}
runId={runId}
@ -1801,6 +1876,8 @@ function App() {
currentAssistantMessage={currentAssistantMessage}
currentReasoning={currentReasoning}
isProcessing={isProcessing}
isStopping={isStopping}
onStop={handleStop}
message={message}
onMessageChange={setMessage}
onSubmit={handlePromptSubmit}

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUp, Expand, Plus } from 'lucide-react'
import { ArrowUp, Expand, LoaderIcon, Plus, Square } from 'lucide-react'
import type { ToolUIPart } from 'ai'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@ -115,6 +115,8 @@ interface ChatSidebarProps {
currentAssistantMessage: string
currentReasoning: string
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
message: string
onMessageChange: (message: string) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
@ -139,6 +141,8 @@ export function ChatSidebar({
currentAssistantMessage,
currentReasoning,
isProcessing,
isStopping,
onStop,
message,
onMessageChange,
onSubmit,
@ -595,19 +599,39 @@ export function ChatSidebar({
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
</div>
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
canSubmit
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
{isProcessing ? (
<Button
size="icon"
onClick={onStop}
title={isStopping ? "Click again to force stop" : "Stop generation"}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
isStopping
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
canSubmit
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
{knowledgeFiles.length > 0 && (
<MentionPopover