diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 9feca20..ca477f7 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -1454,11 +1454,13 @@ async fn run_conversation_loop<'a>( let cx = session.connection(); let sid = session.session_id().clone(); let prompt_request = PromptRequest::new(sid.clone(), prompt_blocks); - let prompt_response = cx - .clone() - .send_request_to(Agent, prompt_request) - .block_task(); - tokio::pin!(prompt_response); + // Use Box::pin (heap) instead of tokio::pin! (stack) so the + // future can be moved into a background task on cancel. + let mut prompt_response = Box::pin( + cx.clone() + .send_request_to(Agent, prompt_request) + .block_task(), + ); let mut tracked_terminal_tool_calls: HashMap = HashMap::new(); let mut terminal_poll_interval = tokio::time::interval( @@ -1659,6 +1661,25 @@ async fn run_conversation_loop<'a>( RequestPermissionOutcome::Cancelled, )); } + // Immediately emit TurnComplete so the frontend + // transitions out of "prompting" and the user can + // send new messages. Don't wait for the agent — + // it may be slow to respond or not respond at all. + let _ = handle.emit( + "acp://event", + AcpEvent::TurnComplete { + connection_id: conn_id.into(), + session_id: sid.0.to_string(), + stop_reason: "cancelled".into(), + }, + ); + // Drain the prompt response in the background so + // the SACP library doesn't log "receiver dropped" + // errors when the agent eventually responds. + tokio::spawn(async move { + let _ = prompt_response.await; + }); + break; } Some(ConnectionCommand::Disconnect) | None => { eprintln!( diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 9cf8474..74da482 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -209,6 +209,10 @@ const ConversationTabView = memo(function ConversationTabView({ const statusUpdatedRef = useRef(false) const selectedAgentRef = useRef(selectedAgent) const createConversationPendingRef = useRef(false) + // When the turn finishes (cancel / complete) before createConversation + // resolves, we can't update the DB status yet. This ref records the + // desired status so the createConversation callback can apply it. + const deferredStatusRef = useRef(null) const externalIdSavedRef = useRef(false) const sessionIdRef = useRef(null) const syncCancelRef = useRef<(() => void) | null>(null) @@ -333,8 +337,21 @@ const ConversationTabView = memo(function ConversationTabView({ syncCancelRef.current?.() syncCancelRef.current = null + const targetStatus = + connStatus === "disconnected" || connStatus === "error" + ? null + : "pending_review" + const persistedId = dbConvIdRef.current - if (!persistedId) return + if (!persistedId) { + // Conversation hasn't been persisted yet (createConversation still + // in flight). Record the desired status so the create callback + // can apply it once the DB row exists. + if (targetStatus) { + deferredStatusRef.current = targetStatus + } + return + } // Async patch metadata (usage, duration_ms, model, session_stats) if (persistedId > 0) { @@ -344,8 +361,8 @@ const ConversationTabView = memo(function ConversationTabView({ ) } - if (connStatus !== "disconnected" && connStatus !== "error") { - updateConversationStatus(persistedId, "pending_review") + if (targetStatus) { + updateConversationStatus(persistedId, targetStatus) .then(() => refreshConversations()) .catch((e: unknown) => console.error("[ConversationTabView] update status:", e) @@ -573,7 +590,12 @@ const ConversationTabView = memo(function ConversationTabView({ buildConversationDraftStorageKey(selectedAgent, newConversationId) ) statusUpdatedRef.current = false - updateConversationStatus(newConversationId, "in_progress") + // If the turn already finished while we were creating the + // conversation, apply the deferred status directly instead + // of setting "in_progress" (which would never be updated). + const initialStatus = deferredStatusRef.current ?? "in_progress" + deferredStatusRef.current = null + updateConversationStatus(newConversationId, initialStatus) .then(() => refreshConversations()) .catch((e: unknown) => console.error("[ConversationTabView] update status:", e) diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 5c59b5b..f6216d9 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -1893,22 +1893,11 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { [] ) - const cancel = useCallback( - async (contextKey: string) => { - const conn = storeRef.current.connections.get(contextKey) - if (!conn) return - await acpCancel(conn.connectionId) - // Optimistically transition status so the UI stops showing - // "responding" immediately. If the agent was slow to respond to - // the CancelNotification the frontend would otherwise stay stuck - // in the "prompting" state indefinitely. - const current = storeRef.current.connections.get(contextKey) - if (current?.status === "prompting") { - dispatch({ type: "STATUS_CHANGED", contextKey, status: "connected" }) - } - }, - [dispatch] - ) + const cancel = useCallback(async (contextKey: string) => { + const conn = storeRef.current.connections.get(contextKey) + if (!conn) return + await acpCancel(conn.connectionId) + }, []) const respondPermission = useCallback( async (contextKey: string, requestId: string, optionId: string) => {