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:
patrickmwatson
2026-03-11 03:06:57 -04:00
committed by GitHub
parent a77f213dd5
commit 4d8fb6e30a
4 changed files with 34 additions and 3 deletions

View File

@@ -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<LatestChatMessage | null>(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);

View File

@@ -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(() => {

View File

@@ -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' }}
/>

View File

@@ -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);
@@ -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) => {