Skip to content

fix(ios): auto-recover from WebSocket reconnect — catch up missed messages, unfreeze input#524

Merged
blackmammoth merged 1 commit into
siteboon:mainfrom
LifeHackInnovationsLLC:fix/ios-websocket-reconnect-recovery
Mar 11, 2026
Merged

fix(ios): auto-recover from WebSocket reconnect — catch up missed messages, unfreeze input#524
blackmammoth merged 1 commit into
siteboon:mainfrom
LifeHackInnovationsLLC:fix/ios-websocket-reconnect-recovery

Conversation

@patrickmwatson
Copy link
Copy Markdown
Contributor

@patrickmwatson patrickmwatson commented Mar 10, 2026

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 once isLoading = true, if the server restarts or a session times out without sending claude-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 (onopen fires and hasConnectedRef.current === true), emit { type: 'websocket-reconnected' } through setLatestMessage so all components can react.

useChatRealtimeHandlers: Add websocket-reconnected to globalMessageTypes (bypasses sessionId mismatch checks) and a new case 'websocket-reconnected' that calls an onWebSocketReconnect callback.

ChatInterface: Wire onWebSocketReconnect to re-fetch JSONL session history via loadSessionMessages, update setChatMessages with the full history, and reset isLoading + canAbortSession so 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

  • Start a session, kill/restart the server mid-stream → on next WebSocket reconnect the chat auto-refreshes with full history and input unlocks
  • Long Bash tool call (>2 min) while iOS tab is backgrounded → return to tab, WebSocket reconnects, missed output appears automatically
  • Input field stays typeable at all times even during "Processing..." state

Summary by CodeRabbit

Release Notes

  • New Features

    • Added WebSocket reconnection handling that automatically reloads chat history when connection is restored.
    • Chat input now remains interactive during loading states.
  • Improvements

    • Enhanced recovery from mid-stream connection interruptions.

… 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>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 10, 2026

📝 Walkthrough

Walkthrough

The 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

Cohort / File(s) Summary
WebSocket Reconnection Infrastructure
src/components/chat/hooks/useChatRealtimeHandlers.ts, src/contexts/WebSocketContext.tsx
Adds 'websocket-reconnected' message type to global messages list, introduces onWebSocketReconnect optional callback in the realtime handlers interface, and emits synthetic reconnection messages from WebSocket context on subsequent opens to signal reconnection events.
Chat Message Rehydration
src/components/chat/view/ChatInterface.tsx
Implements reconnection handler that loads session messages and resets loading/abort states when WebSocket reconnects, integrating the new onWebSocketReconnect callback into the realtime handlers pipeline.
Input Interaction
src/components/chat/view/subcomponents/ChatComposer.tsx
Removes disabled state and disabled-state styling from chat input textarea, allowing user interaction to continue during loading periods.

Sequence Diagram

sequenceDiagram
    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
Loading

Possibly related PRs

Suggested reviewers

  • viper151

Poem

🐰 Reconnect, oh noble WebSocket thread,
When connection stumbles, our messages spread!
Fresh handlers awaken, session messages flow,
Rehydrating the chat with a bounce-back we know!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically describes the main changes: WebSocket reconnect recovery with message catch-up and input unfreezing—directly addressing the core objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/components/chat/hooks/useChatRealtimeHandlers.ts (1)

305-308: Missing onWebSocketReconnect in useEffect dependency array.

The onWebSocketReconnect callback is used inside the useEffect (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 handleWebSocketReconnect in ChatInterface.tsx is memoized with useCallback, 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9bceab9 and eb7420c.

📒 Files selected for processing (4)
  • src/components/chat/hooks/useChatRealtimeHandlers.ts
  • src/components/chat/view/ChatInterface.tsx
  • src/components/chat/view/subcomponents/ChatComposer.tsx
  • src/contexts/WebSocketContext.tsx

Comment on lines +205 to +216
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]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Copy link
Copy Markdown
Collaborator

@blackmammoth blackmammoth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested by @viper151 and works.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants