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