diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index d543359..ff957af 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -48,6 +48,7 @@ interface UseChatRealtimeHandlersArgs { onSessionNotProcessing?: (sessionId?: string | null) => void; onReplaceTemporarySession?: (sessionId?: string | null) => void; onNavigateToSession?: (sessionId: string) => void; + onWebSocketReconnect?: () => void; } const appendStreamingChunk = ( @@ -113,6 +114,7 @@ export function useChatRealtimeHandlers({ onSessionNotProcessing, onReplaceTemporarySession, onNavigateToSession, + onWebSocketReconnect, }: UseChatRealtimeHandlersArgs) { const lastProcessedMessageRef = useRef(null); @@ -136,7 +138,7 @@ export function useChatRealtimeHandlers({ : null; 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 lifecycleMessageTypes = new Set([ 'claude-complete', @@ -300,6 +302,11 @@ export function useChatRealtimeHandlers({ } break; + case 'websocket-reconnected': + // WebSocket dropped and reconnected — re-fetch session history to catch up on missed messages + onWebSocketReconnect?.(); + break; + case 'token-budget': if (latestMessage.data) { setTokenBudget(latestMessage.data); diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 90c1921..79c99d8 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -109,6 +109,7 @@ function ChatInterface({ scrollToBottom, scrollToBottomAndReset, handleScroll, + loadSessionMessages, } = useChatSessionState({ selectedProject, selectedSession, @@ -197,6 +198,23 @@ function ChatInterface({ 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({ latestMessage, provider, @@ -219,6 +237,7 @@ function ChatInterface({ onSessionNotProcessing, onReplaceTemporarySession, onNavigateToSession, + onWebSocketReconnect: handleWebSocketReconnect, }); useEffect(() => { diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 35bf754..2bf8eb5 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -301,8 +301,7 @@ export default function ChatComposer({ onBlur={() => onInputFocusChange?.(false)} onInput={onTextareaInput} 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 disabled:opacity-50 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 sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40" style={{ height: '50px' }} /> diff --git a/src/contexts/WebSocketContext.tsx b/src/contexts/WebSocketContext.tsx index 7ff5a74..116da6b 100644 --- a/src/contexts/WebSocketContext.tsx +++ b/src/contexts/WebSocketContext.tsx @@ -29,6 +29,7 @@ const buildWebSocketUrl = (token: string | null) => { const useWebSocketProviderState = (): WebSocketContextType => { const wsRef = useRef(null); 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(null); const [isConnected, setIsConnected] = useState(false); const reconnectTimeoutRef = useRef(null); @@ -61,6 +62,11 @@ const useWebSocketProviderState = (): WebSocketContextType => { websocket.onopen = () => { setIsConnected(true); 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) => {