mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-02 10:35:37 +08:00
fix: session reconnect catch-up, always-on input, frozen session recovery (#524)
- WebSocketContext: emit 'websocket-reconnected' on onopen when it's a reconnect
(hasConnectedRef tracks first-connect vs. subsequent reconnects).
- useChatRealtimeHandlers: handle 'websocket-reconnected' via onWebSocketReconnect
callback; added to globalMessageTypes to bypass sessionId mismatch checks.
- ChatInterface: on reconnect, re-fetch JSONL session history so messages missed
during iOS background are shown immediately. Also resets isLoading and
canAbortSession so a dead/restarted session no longer freezes the UI forever.
- ChatComposer: remove disabled={isLoading} from textarea — users can always
type regardless of processing state; submit button still prevents double-send.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,7 @@ interface UseChatRealtimeHandlersArgs {
|
|||||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||||
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
||||||
onNavigateToSession?: (sessionId: string) => void;
|
onNavigateToSession?: (sessionId: string) => void;
|
||||||
|
onWebSocketReconnect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appendStreamingChunk = (
|
const appendStreamingChunk = (
|
||||||
@@ -113,6 +114,7 @@ export function useChatRealtimeHandlers({
|
|||||||
onSessionNotProcessing,
|
onSessionNotProcessing,
|
||||||
onReplaceTemporarySession,
|
onReplaceTemporarySession,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
|
onWebSocketReconnect,
|
||||||
}: UseChatRealtimeHandlersArgs) {
|
}: UseChatRealtimeHandlersArgs) {
|
||||||
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ export function useChatRealtimeHandlers({
|
|||||||
: null;
|
: null;
|
||||||
const messageType = String(latestMessage.type);
|
const messageType = String(latestMessage.type);
|
||||||
|
|
||||||
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
|
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'websocket-reconnected'];
|
||||||
const isGlobalMessage = globalMessageTypes.includes(messageType);
|
const isGlobalMessage = globalMessageTypes.includes(messageType);
|
||||||
const lifecycleMessageTypes = new Set([
|
const lifecycleMessageTypes = new Set([
|
||||||
'claude-complete',
|
'claude-complete',
|
||||||
@@ -300,6 +302,11 @@ export function useChatRealtimeHandlers({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'websocket-reconnected':
|
||||||
|
// WebSocket dropped and reconnected — re-fetch session history to catch up on missed messages
|
||||||
|
onWebSocketReconnect?.();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'token-budget':
|
case 'token-budget':
|
||||||
if (latestMessage.data) {
|
if (latestMessage.data) {
|
||||||
setTokenBudget(latestMessage.data);
|
setTokenBudget(latestMessage.data);
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function ChatInterface({
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
scrollToBottomAndReset,
|
scrollToBottomAndReset,
|
||||||
handleScroll,
|
handleScroll,
|
||||||
|
loadSessionMessages,
|
||||||
} = useChatSessionState({
|
} = useChatSessionState({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -197,6 +198,23 @@ function ChatInterface({
|
|||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On WebSocket reconnect, re-fetch the current session's messages from JSONL so missed
|
||||||
|
// streaming events (e.g. from long tool calls while iOS had the tab backgrounded) are shown.
|
||||||
|
// Also reset isLoading — if the server restarted or the session died mid-stream, the client
|
||||||
|
// would be stuck in "Processing..." forever without this reset.
|
||||||
|
const handleWebSocketReconnect = useCallback(async () => {
|
||||||
|
if (!selectedProject || !selectedSession) return;
|
||||||
|
const provider = (localStorage.getItem('selected-provider') as any) || 'claude';
|
||||||
|
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, provider);
|
||||||
|
if (messages && messages.length > 0) {
|
||||||
|
setChatMessages(messages);
|
||||||
|
}
|
||||||
|
// Reset loading state — if the session is still active, new WebSocket messages will
|
||||||
|
// set it back to true. If it died, this clears the permanent frozen state.
|
||||||
|
setIsLoading(false);
|
||||||
|
setCanAbortSession(false);
|
||||||
|
}, [selectedProject, selectedSession, loadSessionMessages, setChatMessages, setIsLoading, setCanAbortSession]);
|
||||||
|
|
||||||
useChatRealtimeHandlers({
|
useChatRealtimeHandlers({
|
||||||
latestMessage,
|
latestMessage,
|
||||||
provider,
|
provider,
|
||||||
@@ -219,6 +237,7 @@ function ChatInterface({
|
|||||||
onSessionNotProcessing,
|
onSessionNotProcessing,
|
||||||
onReplaceTemporarySession,
|
onReplaceTemporarySession,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
|
onWebSocketReconnect: handleWebSocketReconnect,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -301,8 +301,7 @@ export default function ChatComposer({
|
|||||||
onBlur={() => onInputFocusChange?.(false)}
|
onBlur={() => onInputFocusChange?.(false)}
|
||||||
onInput={onTextareaInput}
|
onInput={onTextareaInput}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={isLoading}
|
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
|
||||||
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none disabled:opacity-50 sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
|
|
||||||
style={{ height: '50px' }}
|
style={{ height: '50px' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const buildWebSocketUrl = (token: string | null) => {
|
|||||||
const useWebSocketProviderState = (): WebSocketContextType => {
|
const useWebSocketProviderState = (): WebSocketContextType => {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const unmountedRef = useRef(false); // Track if component is unmounted
|
const unmountedRef = useRef(false); // Track if component is unmounted
|
||||||
|
const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects)
|
||||||
const [latestMessage, setLatestMessage] = useState<any>(null);
|
const [latestMessage, setLatestMessage] = useState<any>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -61,6 +62,11 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
wsRef.current = websocket;
|
wsRef.current = websocket;
|
||||||
|
if (hasConnectedRef.current) {
|
||||||
|
// This is a reconnect — signal so components can catch up on missed messages
|
||||||
|
setLatestMessage({ type: 'websocket-reconnected', timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
hasConnectedRef.current = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
websocket.onmessage = (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user