Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/chat/hooks/useChatRealtimeHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface UseChatRealtimeHandlersArgs {
onSessionNotProcessing?: (sessionId?: string | null) => void;
onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string) => void;
onWebSocketReconnect?: () => void;
}

const appendStreamingChunk = (
Expand Down Expand Up @@ -113,6 +114,7 @@ export function useChatRealtimeHandlers({
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
onWebSocketReconnect,
}: UseChatRealtimeHandlersArgs) {
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);

Expand All @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions src/components/chat/view/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ function ChatInterface({
scrollToBottom,
scrollToBottomAndReset,
handleScroll,
loadSessionMessages,
} = useChatSessionState({
selectedProject,
selectedSession,
Expand Down Expand Up @@ -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]);
Comment on lines +205 to +216
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.


useChatRealtimeHandlers({
latestMessage,
provider,
Expand All @@ -219,6 +237,7 @@ function ChatInterface({
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
onWebSocketReconnect: handleWebSocketReconnect,
});

useEffect(() => {
Expand Down
3 changes: 1 addition & 2 deletions src/components/chat/view/subcomponents/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
/>

Expand Down
6 changes: 6 additions & 0 deletions src/contexts/WebSocketContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const buildWebSocketUrl = (token: string | null) => {
const useWebSocketProviderState = (): WebSocketContextType => {
const wsRef = useRef<WebSocket | null>(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<any>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
Expand Down Expand Up @@ -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) => {
Expand Down