feat(desktop): add chat abort support with partial content preservation

Add stop button to interrupt agent generation mid-stream. The send button
toggles to a stop icon during loading. Abort propagates from UI through
IPC to the Agent layer (PiAgentCore.abort()), preserving all partial
content in the agent's context so users can follow up immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-12 14:33:06 +08:00
parent b5f63f7f7c
commit a33df19bef
9 changed files with 87 additions and 5 deletions

View file

@ -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

View file

@ -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.
*/

View file

@ -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),

View file

@ -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}

View file

@ -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,

View file

@ -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;

View file

@ -92,6 +92,7 @@ export class Agent {
// Internal run state
private _internalRun = false;
private _isRunning = false;
private _aborted = false;
private _runMutex: Promise<void> = 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

View file

@ -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<ChatInputRef, ChatInputProps>(
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<ChatInputRef, ChatInputProps>(
editor.commands.clearContent();
};
const handleButtonClick = () => {
if (isLoading && onAbort) {
onAbort();
} else {
handleSubmit();
}
};
const showStop = isLoading && !!onAbort;
return (
<div className={cn(
"chat-input-editor bg-card rounded-xl p-2 border border-border transition-colors",
@ -116,8 +128,8 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
)}>
<EditorContent className="min-h-12" editor={editor} />
<div className="flex items-center justify-end pt-2">
<Button size="icon" onClick={handleSubmit} disabled={disabled}>
<HugeiconsIcon strokeWidth={2.5} icon={ArrowUp02Icon} />
<Button size="icon" onClick={handleButtonClick} disabled={disabled && !showStop}>
<HugeiconsIcon strokeWidth={2.5} icon={showStop ? StopIcon : ArrowUp02Icon} />
</Button>
</div>
</div>

View file

@ -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({
<footer className="container px-4 pb-3 pt-1">
<ChatInput
onSubmit={sendMessage}
disabled={isLoading || (!!error && error.code !== 'AGENT_ERROR')}
onAbort={onAbort}
isLoading={isLoading}
disabled={!!error && error.code !== 'AGENT_ERROR'}
placeholder={error && error.code !== 'AGENT_ERROR' ? "Connection error" : "Ask your Agent..."}
/>
</footer>