diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 64481bd..8d92dd3 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -964,7 +964,33 @@ export function ConversationDetailPanel() { }) }, [onPreviewTabReplaced, disconnectByKey]) - // Background turn_complete handler: for conversations not open in tabs + // Refs for background turn_complete handler so the listener + // can be registered once and always read the latest values. + const getConversationIdByExternalIdRef = useRef(getConversationIdByExternalId) + const getSessionRef = useRef(getSession) + const runtimeCompleteTurnRef = useRef(runtimeCompleteTurn) + const runtimeRemoveConversationRef = useRef(runtimeRemoveConversation) + const refreshConversationsRef = useRef(refreshConversations) + useEffect(() => { + getConversationIdByExternalIdRef.current = getConversationIdByExternalId + }, [getConversationIdByExternalId]) + useEffect(() => { + getSessionRef.current = getSession + }, [getSession]) + useEffect(() => { + runtimeCompleteTurnRef.current = runtimeCompleteTurn + }, [runtimeCompleteTurn]) + useEffect(() => { + runtimeRemoveConversationRef.current = runtimeRemoveConversation + }, [runtimeRemoveConversation]) + useEffect(() => { + refreshConversationsRef.current = refreshConversations + }, [refreshConversations]) + + // Background turn_complete handler: for conversations not open in tabs. + // Registered once — uses refs to avoid re-creating the listener on every + // state change, which would cause "Couldn't find callback id" warnings + // due to the async gap between unlisten and the new listen(). useEffect(() => { let cancelled = false let unlisten: (() => void | Promise) | null = null @@ -975,9 +1001,8 @@ export function ConversationDetailPanel() { const payload = event.payload if (payload.type !== "turn_complete") return - const runtimeConversationId = getConversationIdByExternalId( - payload.session_id - ) + const runtimeConversationId = + getConversationIdByExternalIdRef.current(payload.session_id) const summary = conversationsRef.current.find( (item) => item.external_id === payload.session_id ) @@ -997,12 +1022,12 @@ export function ConversationDetailPanel() { if (isOpenInTabs) return // Promote liveMessage + optimisticTurns to localTurns immediately - runtimeCompleteTurn(matchedConversationId) + runtimeCompleteTurnRef.current(matchedConversationId) // If tab was closed while agent was responding, clean up now - const session = getSession(matchedConversationId) + const session = getSessionRef.current(matchedConversationId) if (session?.pendingCleanup) { - runtimeRemoveConversation(matchedConversationId) + runtimeRemoveConversationRef.current(matchedConversationId) } // Update conversation status — use the DB summary (found by @@ -1013,7 +1038,7 @@ export function ConversationDetailPanel() { (matchedConversationId > 0 ? matchedConversationId : null) if (dbId && (!summary || summary.status === "in_progress")) { updateConversationStatus(dbId, "pending_review") - .then(() => refreshConversations()) + .then(() => refreshConversationsRef.current()) .catch((error: unknown) => console.error( "[ConversationDetailPanel] background update status:", @@ -1044,13 +1069,7 @@ export function ConversationDetailPanel() { "ConversationDetailPanel.backgroundRefresh" ) } - }, [ - getConversationIdByExternalId, - getSession, - runtimeCompleteTurn, - runtimeRemoveConversation, - refreshConversations, - ]) + }, []) const hasNoTabs = tabs.length === 0 && !activeTabId const activeConversationTab = useMemo( diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 9241805..1f923ed 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -1149,6 +1149,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { // Guard against concurrent connect() calls const connectingKeysRef = useRef(new Set()) + // Keys whose disconnect was requested while connect was still in flight + const abandonedKeysRef = useRef(new Set()) type AutoLinkBlockState = | { kind: "none"; reason: "" } @@ -1772,6 +1774,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { await waitForListenerReady() const connectionId = await acpConnect(agentType, workingDir, sessionId) + + // If disconnect was requested while connect was in flight, + // tear down immediately instead of registering the connection. + if (abandonedKeysRef.current.delete(contextKey)) { + acpDisconnect(connectionId).catch(() => {}) + return + } + reverseMapRef.current.set(connectionId, contextKey) lastActivityRef.current.set(contextKey, Date.now()) dispatch({ @@ -1800,6 +1810,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { throw err } finally { connectingKeysRef.current.delete(contextKey) + abandonedKeysRef.current.delete(contextKey) } }, [ @@ -1816,7 +1827,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { const disconnect = useCallback( async (contextKey: string) => { const conn = storeRef.current.connections.get(contextKey) - if (!conn) return + if (!conn) { + // connect() is still in flight — mark as abandoned so it + // tears down immediately when acpConnect returns. + if (connectingKeysRef.current.has(contextKey)) { + abandonedKeysRef.current.add(contextKey) + } + return + } await acpDisconnect(conn.connectionId) reverseMapRef.current.delete(conn.connectionId) lastActivityRef.current.delete(contextKey) diff --git a/src/hooks/use-connection-lifecycle.ts b/src/hooks/use-connection-lifecycle.ts index b18e924..6dfe0ae 100644 --- a/src/hooks/use-connection-lifecycle.ts +++ b/src/hooks/use-connection-lifecycle.ts @@ -55,6 +55,7 @@ export function useConnectionLifecycle({ status, selectorsReady, connect: connConnect, + disconnect: connDisconnect, sendPrompt, setMode: connSetMode, setConfigOption: connSetConfigOption, @@ -256,9 +257,18 @@ export function useConnectionLifecycle({ t, ]) - // Clean up lingering task on unmount (e.g. tab closed while connecting) + // Keep a ref to disconnect so the unmount cleanup always calls the + // latest version without adding it as a dependency. + const connDisconnectRef = useRef(connDisconnect) + useEffect(() => { + connDisconnectRef.current = connDisconnect + }, [connDisconnect]) + + // Clean up on unmount (e.g. tab closed): disconnect the ACP connection + // so it doesn't leak, and remove lingering tasks. useEffect(() => { return () => { + connDisconnectRef.current().catch(() => {}) if (taskIdRef.current) { removeTask(taskIdRef.current) }