diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index 04eb0643..3c2bc150 100644 --- a/apps/desktop/src/main/electron-env.d.ts +++ b/apps/desktop/src/main/electron-env.d.ts @@ -222,6 +222,7 @@ interface ElectronAPI { unsubscribe: (agentId: string) => Promise<{ ok: boolean }> getHistory: (agentId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number }> send: (agentId: string, content: string) => Promise<{ ok?: boolean; error?: string }> + abort: (agentId: string) => Promise<{ ok?: boolean; error?: string }> resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }> onEvent: (callback: (event: LocalChatEvent) => void) => void offEvent: () => void diff --git a/apps/desktop/src/main/ipc/hub.ts b/apps/desktop/src/main/ipc/hub.ts index 6654ccb8..4e6fd755 100644 --- a/apps/desktop/src/main/ipc/hub.ts +++ b/apps/desktop/src/main/ipc/hub.ts @@ -394,6 +394,20 @@ export function registerHubIpcHandlers(): void { return { ok: true } }) + /** + * Abort the current agent run for local chat. + */ + ipcMain.handle('localChat:abort', async (_event, agentId: string) => { + const h = getHub() + const agent = h.getAgent(agentId) + if (!agent) { + return { error: `Agent not found: ${agentId}` } + } + agent.abort() + safeLog(`[IPC] Abort sent to agent: ${agentId}`) + return { ok: true } + }) + /** * Resolve an exec approval request for local chat. */ diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 528da18a..322ebb18 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -268,6 +268,8 @@ const electronAPI = { ipcRenderer.invoke('localChat:getHistory', agentId, options), /** Send message to agent via direct IPC (no Gateway) */ send: (agentId: string, content: string) => ipcRenderer.invoke('localChat:send', agentId, content), + /** Abort the current agent run */ + abort: (agentId: string) => ipcRenderer.invoke('localChat:abort', agentId), /** Resolve an exec approval request */ resolveExecApproval: (approvalId: string, decision: string) => ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision), diff --git a/apps/desktop/src/renderer/src/components/local-chat.tsx b/apps/desktop/src/renderer/src/components/local-chat.tsx index ade9db6c..4faf28df 100644 --- a/apps/desktop/src/renderer/src/components/local-chat.tsx +++ b/apps/desktop/src/renderer/src/components/local-chat.tsx @@ -19,6 +19,7 @@ export function LocalChat() { error, pendingApprovals, sendMessage, + abortGeneration, loadMore, resolveApproval, clearError, @@ -90,6 +91,7 @@ export function LocalChat() { error={error} pendingApprovals={pendingApprovals} sendMessage={sendMessage} + onAbort={abortGeneration} loadMore={loadMore} resolveApproval={resolveApproval} errorAction={errorAction} diff --git a/apps/desktop/src/renderer/src/hooks/use-local-chat.ts b/apps/desktop/src/renderer/src/hooks/use-local-chat.ts index 749ee071..ec3113ae 100644 --- a/apps/desktop/src/renderer/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/renderer/src/hooks/use-local-chat.ts @@ -109,6 +109,12 @@ export function useLocalChat() { [agentId], ) + const abortGeneration = useCallback(() => { + if (!agentId) return + window.electronAPI.localChat.abort(agentId).catch(() => {}) + setIsLoading(false) + }, [agentId]) + const loadMore = useCallback(async () => { const currentOffset = offsetRef.current if (!agentId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return @@ -158,6 +164,7 @@ export function useLocalChat() { error: chat.error, pendingApprovals: chat.pendingApprovals, sendMessage, + abortGeneration, loadMore, resolveApproval, clearError, diff --git a/packages/core/src/agent/async-agent.ts b/packages/core/src/agent/async-agent.ts index e7ff1957..0d9d6e54 100644 --- a/packages/core/src/agent/async-agent.ts +++ b/packages/core/src/agent/async-agent.ts @@ -242,6 +242,15 @@ export class AsyncAgent { return this.agent.hasQueuedMessages(); } + /** + * Abort the currently running prompt. + * Bypasses the serial queue — directly interrupts the running PiAgentCore prompt. + * Partial content is preserved in agent context. Safe to call when idle (no-op). + */ + abort(): void { + this.agent.abort(); + } + private shouldForwardEvent(event: AgentEvent | MulticaEvent): boolean { if (!this.agent.isInternalRun) return true; if (!this.forwardInternalAssistant) return false; diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index 636a263d..81b7fc26 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -92,6 +92,7 @@ export class Agent { // Internal run state private _internalRun = false; private _isRunning = false; + private _aborted = false; private _runMutex: Promise = Promise.resolve(); private currentUserDisplayPrompt: string | undefined; @@ -429,6 +430,7 @@ export class Agent { this.output.state.lastAssistantText = ""; this.currentUserDisplayPrompt = options?.displayPrompt; this._isRunning = true; + this._aborted = false; try { // Early validation: check API key before calling PiAgentCore.prompt(), @@ -489,9 +491,27 @@ export class Agent { const thinking = this.reasoningMode !== "off" ? this.output.state.lastAssistantThinking || undefined : undefined; + + // On abort: clear the error so it doesn't propagate as an agent error, + // and return partial text without an error flag. + if (this._aborted) { + this.agent.state.error = undefined; + return { text: this.output.state.lastAssistantText, thinking, error: undefined }; + } + return { text: this.output.state.lastAssistantText, thinking, error: this.agent.state.error }; } finally { + // On abort, persist any partial messages that pi-agent-core appended + // via appendMessage() (no message_end event fires for those). + if (this._aborted) { + const messages = this.agent.state.messages; + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.role === "assistant") { + this.session.saveMessage(lastMsg); + } + } this._isRunning = false; + this._aborted = false; this.currentUserDisplayPrompt = undefined; } } @@ -701,6 +721,17 @@ export class Agent { return this.agent.hasQueuedMessages(); } + /** + * Abort the currently running prompt. + * Triggers PiAgentCore's internal AbortController. The running prompt() + * will resolve (not throw), partial content stays in state.messages. + * Safe to call when no run is active (no-op). + */ + abort(): void { + this._aborted = true; + this.agent.abort(); + } + /** * Persist a synthetic assistant message into both in-memory state and session JSONL. * Used after an internal run to keep the LLM summary visible in future turns diff --git a/packages/ui/src/components/chat-input.tsx b/packages/ui/src/components/chat-input.tsx index fafbdfb2..9219e177 100644 --- a/packages/ui/src/components/chat-input.tsx +++ b/packages/ui/src/components/chat-input.tsx @@ -4,7 +4,7 @@ import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import { Button } from "@multica/ui/components/ui/button"; -import { ArrowUp02Icon } from "@hugeicons/core-free-icons"; +import { ArrowUp02Icon, StopIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { cn } from "@multica/ui/lib/utils"; import "./chat-input.css"; @@ -18,12 +18,14 @@ export interface ChatInputRef { interface ChatInputProps { onSubmit?: (value: string) => void; + onAbort?: () => void; + isLoading?: boolean; disabled?: boolean; placeholder?: string; } export const ChatInput = forwardRef( - function ChatInput({ onSubmit, disabled, placeholder = "Type a message..." }, ref) { + function ChatInput({ onSubmit, onAbort, isLoading, disabled, placeholder = "Type a message..." }, ref) { // Use ref to avoid stale closure in Tiptap keydown handler const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit; @@ -109,6 +111,16 @@ export const ChatInput = forwardRef( editor.commands.clearContent(); }; + const handleButtonClick = () => { + if (isLoading && onAbort) { + onAbort(); + } else { + handleSubmit(); + } + }; + + const showStop = isLoading && !!onAbort; + return (
( )}>
-
diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index f5094090..c18fbd1a 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -36,6 +36,7 @@ export interface ChatViewProps { error: ChatViewError | null; pendingApprovals: ChatViewApproval[]; sendMessage: (text: string) => void; + onAbort?: () => void; loadMore?: () => void; resolveApproval: (approvalId: string, decision: "allow-once" | "allow-always" | "deny") => void; onDisconnect?: () => void; @@ -53,6 +54,7 @@ export function ChatView({ error, pendingApprovals, sendMessage, + onAbort, loadMore, resolveApproval, onDisconnect, @@ -257,7 +259,9 @@ export function ChatView({