fix(ios): auto-recover from WebSocket reconnect — catch up missed messages, unfreeze input#524
Conversation
… recovery
- 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>
📝 WalkthroughWalkthroughThe pull request implements WebSocket reconnection handling by adding a new lifecycle event ('websocket-reconnected'), emitting it from the WebSocket context on subsequent connections, integrating it into the realtime handlers pipeline, and triggering message rehydration in the chat interface when reconnection occurs. Changes
Sequence DiagramsequenceDiagram
participant WebSocket Context
participant Realtime Handlers Hook
participant Chat Interface
participant Session State
participant User
User->>WebSocket Context: Connection lost & regained
WebSocket Context->>WebSocket Context: Emit synthetic 'websocket-reconnected' message
WebSocket Context->>Realtime Handlers Hook: Pass latestMessage (type: 'websocket-reconnected')
Realtime Handlers Hook->>Realtime Handlers Hook: Match 'websocket-reconnected' case
Realtime Handlers Hook->>Chat Interface: Call onWebSocketReconnect()
Chat Interface->>Session State: loadSessionMessages()
Session State->>Chat Interface: Return session messages
Chat Interface->>Chat Interface: Update chatMessages, reset isLoading & canAbortSession
Chat Interface->>User: Display rehydrated message history
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/components/chat/hooks/useChatRealtimeHandlers.ts (1)
305-308: MissingonWebSocketReconnectin useEffect dependency array.The
onWebSocketReconnectcallback is used inside theuseEffect(line 307) but is not included in the dependency array (lines 1154-1173). This could theoretically cause stale closure issues if the callback reference changes.In practice, since
handleWebSocketReconnectinChatInterface.tsxis memoized withuseCallback, this is unlikely to cause runtime issues. However, for correctness and to satisfy exhaustive-deps lint rules, consider adding it to the dependencies.Proposed fix
], [ latestMessage, provider, selectedProject, selectedSession, currentSessionId, setCurrentSessionId, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setTokenBudget, setIsSystemSessionChange, setPendingPermissionRequests, onSessionInactive, onSessionProcessing, onSessionNotProcessing, onReplaceTemporarySession, onNavigateToSession, + onWebSocketReconnect, ]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/chat/hooks/useChatRealtimeHandlers.ts` around lines 305 - 308, The useEffect that handles realtime events in useChatRealtimeHandlers.ts references the onWebSocketReconnect callback (used in the 'websocket-reconnected' case) but does not include it in the effect dependency array; update that useEffect's dependency array to include onWebSocketReconnect to avoid stale closures and satisfy exhaustive-deps (if the prop comes from ChatInterface.tsx ensure it remains memoized with useCallback there as well).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/components/chat/view/ChatInterface.tsx`:
- Around line 205-216: The local variable provider in handleWebSocketReconnect
shadows the component-scoped provider from useChatProviderState; replace the
localStorage lookup with the component state provider (or rename local variable
to avoid shadowing) so the function uses the reactive provider value, and update
the useCallback dependency array to include provider; specifically modify
handleWebSocketReconnect to call loadSessionMessages(selectedProject.name,
selectedSession.id, false, providerState) using the provider from the hook (or
rename the local var to localProvider everywhere it’s used) and add provider to
the dependency list so reconnects use the current UI state.
---
Nitpick comments:
In `@src/components/chat/hooks/useChatRealtimeHandlers.ts`:
- Around line 305-308: The useEffect that handles realtime events in
useChatRealtimeHandlers.ts references the onWebSocketReconnect callback (used in
the 'websocket-reconnected' case) but does not include it in the effect
dependency array; update that useEffect's dependency array to include
onWebSocketReconnect to avoid stale closures and satisfy exhaustive-deps (if the
prop comes from ChatInterface.tsx ensure it remains memoized with useCallback
there as well).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: bc58d0f1-bccc-4f94-98c8-051969510c6e
📒 Files selected for processing (4)
src/components/chat/hooks/useChatRealtimeHandlers.tssrc/components/chat/view/ChatInterface.tsxsrc/components/chat/view/subcomponents/ChatComposer.tsxsrc/contexts/WebSocketContext.tsx
| 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]); |
There was a problem hiding this comment.
Variable shadowing: provider shadows the component-scoped state variable.
Line 207 declares a local provider variable that shadows the provider from useChatProviderState (line 59). This could cause confusion and potentially use a stale localStorage value instead of the current reactive state.
Proposed fix to avoid shadowing
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);
+ const storedProvider = (localStorage.getItem('selected-provider') as any) || 'claude';
+ const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, storedProvider);
if (messages && messages.length > 0) {
setChatMessages(messages);
}Alternatively, consider using the provider state directly instead of reading from localStorage, which would ensure consistency with the current UI state.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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]); | |
| const handleWebSocketReconnect = useCallback(async () => { | |
| if (!selectedProject || !selectedSession) return; | |
| const storedProvider = (localStorage.getItem('selected-provider') as any) || 'claude'; | |
| const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, storedProvider); | |
| 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]); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/chat/view/ChatInterface.tsx` around lines 205 - 216, The local
variable provider in handleWebSocketReconnect shadows the component-scoped
provider from useChatProviderState; replace the localStorage lookup with the
component state provider (or rename local variable to avoid shadowing) so the
function uses the reactive provider value, and update the useCallback dependency
array to include provider; specifically modify handleWebSocketReconnect to call
loadSessionMessages(selectedProject.name, selectedSession.id, false,
providerState) using the provider from the hook (or rename the local var to
localProvider everywhere it’s used) and add provider to the dependency list so
reconnects use the current UI state.
blackmammoth
left a comment
There was a problem hiding this comment.
Tested by @viper151 and works.
Problem
Three related issues that leave iOS users stuck:
1. Missed messages on WebSocket reconnect
When iOS backgrounds the tab during a long Claude operation (file edits, bash commands), the WebSocket drops. On return, a new WebSocket connects but the in-progress streaming session still sends to the old dead connection. Messages written to the JSONL file never reach the client — users see partial or no output until they manually reload.
2. Input field permanently disabled after session dies
<textarea disabled={isLoading}>means onceisLoading = true, if the server restarts or a session times out without sendingclaude-complete, the input stays locked forever. The only escape is a full page reload.3. No mechanism to catch up after reconnect
The WebSocket context reconnects silently — components have no way to know a reconnect happened and cannot trigger a session history refresh.
Fix
WebSocketContext: Track first-connect vs. reconnect with
hasConnectedRef. On reconnect (onopenfires andhasConnectedRef.current === true), emit{ type: 'websocket-reconnected' }throughsetLatestMessageso all components can react.useChatRealtimeHandlers: Add
websocket-reconnectedtoglobalMessageTypes(bypasses sessionId mismatch checks) and a newcase 'websocket-reconnected'that calls anonWebSocketReconnectcallback.ChatInterface: Wire
onWebSocketReconnectto re-fetch JSONL session history vialoadSessionMessages, updatesetChatMessageswith the full history, and resetisLoading + canAbortSessionso a dead session doesn't freeze the UI.ChatComposer: Remove
disabled={isLoading}from<textarea>. Users can always type — the submit button remains disabled during active processing to prevent double-sends.Testing
Summary by CodeRabbit
Release Notes
New Features
Improvements