mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 17:06:23 +02:00
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:
parent
9828ea8301
commit
a3e681a7c4
13 changed files with 642 additions and 54 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue