mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-14 20:57:32 +00:00
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
126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useAuth } from './AuthContext';
|
|
import { IS_PLATFORM } from '../constants/config';
|
|
|
|
type WebSocketContextType = {
|
|
ws: WebSocket | null;
|
|
sendMessage: (message: any) => void;
|
|
latestMessage: any | null;
|
|
isConnected: boolean;
|
|
};
|
|
|
|
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
|
|
|
export const useWebSocket = () => {
|
|
const context = useContext(WebSocketContext);
|
|
if (!context) {
|
|
throw new Error('useWebSocket must be used within a WebSocketProvider');
|
|
}
|
|
return context;
|
|
};
|
|
|
|
const buildWebSocketUrl = (token: string | null) => {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
if (IS_PLATFORM) return `${protocol}//${window.location.host}/ws`; // Platform mode: Use same domain as the page (goes through proxy)
|
|
if (!token) return null;
|
|
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page
|
|
};
|
|
|
|
const useWebSocketProviderState = (): WebSocketContextType => {
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const unmountedRef = useRef(false); // Track if component is unmounted
|
|
const [latestMessage, setLatestMessage] = useState<any>(null);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const { token } = useAuth();
|
|
|
|
useEffect(() => {
|
|
connect();
|
|
|
|
return () => {
|
|
unmountedRef.current = true;
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
}
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
}
|
|
};
|
|
}, [token]); // everytime token changes, we reconnect
|
|
|
|
const connect = useCallback(() => {
|
|
if (unmountedRef.current) return; // Prevent connection if unmounted
|
|
try {
|
|
// Construct WebSocket URL
|
|
const wsUrl = buildWebSocketUrl(token);
|
|
|
|
if (!wsUrl) return console.warn('No authentication token found for WebSocket connection');
|
|
|
|
const websocket = new WebSocket(wsUrl);
|
|
|
|
websocket.onopen = () => {
|
|
setIsConnected(true);
|
|
wsRef.current = websocket;
|
|
};
|
|
|
|
websocket.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
setLatestMessage(data);
|
|
} catch (error) {
|
|
console.error('Error parsing WebSocket message:', error);
|
|
}
|
|
};
|
|
|
|
websocket.onclose = () => {
|
|
setIsConnected(false);
|
|
wsRef.current = null;
|
|
|
|
// Attempt to reconnect after 3 seconds
|
|
reconnectTimeoutRef.current = setTimeout(() => {
|
|
if (unmountedRef.current) return; // Prevent reconnection if unmounted
|
|
connect();
|
|
}, 3000);
|
|
};
|
|
|
|
websocket.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Error creating WebSocket connection:', error);
|
|
}
|
|
}, [token]); // everytime token changes, we reconnect
|
|
|
|
const sendMessage = useCallback((message: any) => {
|
|
const socket = wsRef.current;
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify(message));
|
|
} else {
|
|
console.warn('WebSocket not connected');
|
|
}
|
|
}, []);
|
|
|
|
const value: WebSocketContextType = useMemo(() =>
|
|
({
|
|
ws: wsRef.current,
|
|
sendMessage,
|
|
latestMessage,
|
|
isConnected
|
|
}), [sendMessage, latestMessage, isConnected]);
|
|
|
|
return value;
|
|
};
|
|
|
|
export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {
|
|
const webSocketData = useWebSocketProviderState();
|
|
|
|
return (
|
|
<WebSocketContext.Provider value={webSocketData}>
|
|
{children}
|
|
</WebSocketContext.Provider>
|
|
);
|
|
};
|
|
|
|
export default WebSocketContext;
|