From 4d55945c5dbd55f5eb1956a159199b523f0a8875 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Sun, 8 Feb 2026 18:13:14 +0300 Subject: [PATCH] fix(chat): make Stop and Esc reliably abort active sessions Problem Stop requests were unreliable because aborting depended on currentSessionId being set, Esc had no actual global abort binding, stale pending session ids could be reused, and abort failures were surfaced as successful interruptions. Codex sessions also used a soft abort flag without wiring SDK cancellation. Changes - Add global Escape key handler in chat while a run is loading/cancellable to trigger the same abort path as the Stop button. - Harden abort session target selection in composer by resolving from multiple active session id sources (current, pending view, pending storage, cursor storage, selected session) and ignoring temporary new-session-* ids. - Clear stale pendingSessionId when launching a brand-new session to prevent aborting an old run. - Update realtime abort handling to respect backend success=false responses: keep loading state when abort fails and emit an explicit failure message instead of pretending interruption succeeded. - Improve websocket send reliability by checking socket.readyState === WebSocket.OPEN directly before send. - Implement real Codex cancellation via AbortController + runStreamed(..., { signal }), propagate aborted status, and suppress expected abort-error noise. Impact This makes both UI Stop and Esc-to-stop materially more reliable across Claude/Cursor/Codex flows, especially during early-session windows before currentSessionId is finalized, and prevents false-positive interrupted states when backend cancellation fails. Validation - npm run -s typecheck - npm run -s build - node --check server/openai-codex.js --- server/openai-codex.js | 35 +++++++++++++------- src/components/ChatInterface.tsx | 20 ++++++++++++ src/contexts/WebSocketContext.tsx | 4 +-- src/hooks/chat/useChatComposerState.ts | 35 ++++++++++++++++++-- src/hooks/chat/useChatRealtimeHandlers.ts | 40 ++++++++++++++++------- 5 files changed, 107 insertions(+), 27 deletions(-) diff --git a/server/openai-codex.js b/server/openai-codex.js index 1967de4..bd368ff 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -203,6 +203,7 @@ export async function queryCodex(command, options = {}, ws) { let codex; let thread; let currentSessionId = sessionId; + const abortController = new AbortController(); try { // Initialize Codex SDK @@ -232,6 +233,7 @@ export async function queryCodex(command, options = {}, ws) { thread, codex, status: 'running', + abortController, startedAt: new Date().toISOString() }); @@ -243,7 +245,9 @@ export async function queryCodex(command, options = {}, ws) { }); // Execute with streaming - const streamedTurn = await thread.runStreamed(command); + const streamedTurn = await thread.runStreamed(command, { + signal: abortController.signal + }); for await (const event of streamedTurn.events) { // Check if session was aborted @@ -286,20 +290,27 @@ export async function queryCodex(command, options = {}, ws) { }); } catch (error) { - console.error('[Codex] Error:', error); + const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null; + const wasAborted = + session?.status === 'aborted' || + error?.name === 'AbortError' || + String(error?.message || '').toLowerCase().includes('aborted'); - sendMessage(ws, { - type: 'codex-error', - error: error.message, - sessionId: currentSessionId - }); + if (!wasAborted) { + console.error('[Codex] Error:', error); + sendMessage(ws, { + type: 'codex-error', + error: error.message, + sessionId: currentSessionId + }); + } } finally { // Update session status if (currentSessionId) { const session = activeCodexSessions.get(currentSessionId); if (session) { - session.status = 'completed'; + session.status = session.status === 'aborted' ? 'aborted' : 'completed'; } } } @@ -318,9 +329,11 @@ export function abortCodexSession(sessionId) { } session.status = 'aborted'; - - // The SDK doesn't have a direct abort method, but marking status - // will cause the streaming loop to exit + try { + session.abortController?.abort(); + } catch (error) { + console.warn(`[Codex] Failed to abort session ${sessionId}:`, error); + } return true; } diff --git a/src/components/ChatInterface.tsx b/src/components/ChatInterface.tsx index 1718702..c87238f 100644 --- a/src/components/ChatInterface.tsx +++ b/src/components/ChatInterface.tsx @@ -211,6 +211,26 @@ function ChatInterface({ onNavigateToSession, }); + useEffect(() => { + if (!isLoading || !canAbortSession) { + return; + } + + const handleGlobalEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.repeat || event.defaultPrevented) { + return; + } + + event.preventDefault(); + handleAbortSession(); + }; + + document.addEventListener('keydown', handleGlobalEscape, { capture: true }); + return () => { + document.removeEventListener('keydown', handleGlobalEscape, { capture: true }); + }; + }, [canAbortSession, handleAbortSession, isLoading]); + useEffect(() => { if (currentSessionId && isLoading && onSessionProcessing) { onSessionProcessing(currentSessionId); diff --git a/src/contexts/WebSocketContext.tsx b/src/contexts/WebSocketContext.tsx index ad31f66..2f2677f 100644 --- a/src/contexts/WebSocketContext.tsx +++ b/src/contexts/WebSocketContext.tsx @@ -94,12 +94,12 @@ const useWebSocketProviderState = (): WebSocketContextType => { const sendMessage = useCallback((message: any) => { const socket = wsRef.current; - if (socket && isConnected) { + if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(message)); } else { console.warn('WebSocket not connected'); } - }, [isConnected]); + }, []); const value: WebSocketContextType = useMemo(() => ({ diff --git a/src/hooks/chat/useChatComposerState.ts b/src/hooks/chat/useChatComposerState.ts index 37ffbb9..4c75933 100644 --- a/src/hooks/chat/useChatComposerState.ts +++ b/src/hooks/chat/useChatComposerState.ts @@ -77,6 +77,9 @@ const createFakeSubmitEvent = () => { return { preventDefault: () => undefined } as unknown as FormEvent; }; +const isTemporarySessionId = (sessionId: string | null | undefined) => + Boolean(sessionId && sessionId.startsWith('new-session-')); + export function useChatComposerState({ selectedProject, selectedSession, @@ -520,6 +523,10 @@ export function useChatComposerState({ const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; if (!effectiveSessionId && !selectedSession?.id) { + if (typeof window !== 'undefined') { + // Reset stale pending IDs from previous interrupted runs before creating a new one. + sessionStorage.removeItem('pendingSessionId'); + } pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; } onSessionActive?.(sessionToActivate); @@ -770,15 +777,37 @@ export function useChatComposerState({ }, [resetCommandMenuState]); const handleAbortSession = useCallback(() => { - if (!currentSessionId || !canAbortSession) { + if (!canAbortSession) { return; } + + const pendingSessionId = + typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; + const cursorSessionId = + typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null; + + const candidateSessionIds = [ + currentSessionId, + pendingViewSessionRef.current?.sessionId || null, + pendingSessionId, + provider === 'cursor' ? cursorSessionId : null, + selectedSession?.id || null, + ]; + + const targetSessionId = + candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null; + + if (!targetSessionId) { + console.warn('Abort requested but no concrete session ID is available yet.'); + return; + } + sendMessage({ type: 'abort-session', - sessionId: currentSessionId, + sessionId: targetSessionId, provider, }); - }, [canAbortSession, currentSessionId, provider, sendMessage]); + }, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]); const handleTranscript = useCallback((text: string) => { if (!text.trim()) { diff --git a/src/hooks/chat/useChatRealtimeHandlers.ts b/src/hooks/chat/useChatRealtimeHandlers.ts index 40c750d..6e875fb 100644 --- a/src/hooks/chat/useChatRealtimeHandlers.ts +++ b/src/hooks/chat/useChatRealtimeHandlers.ts @@ -808,27 +808,45 @@ export function useChatRealtimeHandlers({ case 'session-aborted': { const abortedSessionId = latestMessage.sessionId || currentSessionId; + const abortSucceeded = latestMessage.success !== false; - if (abortedSessionId === currentSessionId) { + if (abortSucceeded && abortedSessionId === currentSessionId) { setIsLoading(false); setCanAbortSession(false); setClaudeStatus(null); } - if (abortedSessionId) { + if (abortSucceeded && abortedSessionId) { onSessionInactive?.(abortedSessionId); onSessionNotProcessing?.(abortedSessionId); } - setPendingPermissionRequests([]); - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: 'Session interrupted by user.', - timestamp: new Date(), - }, - ]); + if (abortSucceeded) { + const pendingSessionId = + typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; + if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) { + sessionStorage.removeItem('pendingSessionId'); + } + + setPendingPermissionRequests([]); + setChatMessages((previous) => [ + ...previous, + { + type: 'assistant', + content: 'Session interrupted by user.', + timestamp: new Date(), + }, + ]); + } else { + setChatMessages((previous) => [ + ...previous, + { + type: 'error', + content: 'Stop request failed. The session is still running.', + timestamp: new Date(), + }, + ]); + } break; }