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:
parent
b5f63f7f7c
commit
a33df19bef
9 changed files with 87 additions and 5 deletions
1
apps/desktop/src/main/electron-env.d.ts
vendored
1
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue