From 8628c81cbe0df5ad18164d4b7dcdba34060eaf44 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Fri, 6 Feb 2026 21:52:55 -0800 Subject: [PATCH] shelley/ui: improve stream connection reliability Prompt: use a new worktree; fetch; rebase on origin/main. Sometimes, it seems like the stream for he conversation disappears or something, and the UI gets behind. Is there still an occasional heartbeat? Smoe visual indicator that we're disconnected? Good retry semantics? I don't quite know how it works, and I think it happens when I leave a tab for a while. - Add 'reconnecting' visual state shown during backoff attempts (1-3) - Check EventSource.readyState on tab visibility/focus to detect dead connections - Always attempt reconnection when tab becomes visible if connection is unhealthy - Previously, visibility/focus handlers only triggered reconnect after the 'isDisconnected' state was set (after 3 failed attempts), missing cases where EventSource silently died due to browser throttling backgrounded tabs The heartbeat mechanism (30s server, 60s client timeout) remains in place as a fallback, but proactive connection health checks on visibility change provide faster recovery when users return to the tab. Co-authored-by: Shelley --- ui/src/components/ChatInterface.tsx | 77 +++++++++++++++++++++++++---- ui/src/styles.css | 20 ++++++++ 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/ui/src/components/ChatInterface.tsx b/ui/src/components/ChatInterface.tsx index 900d3dabc8856307629056c185eb6747e280bd12..f2b5c1d9b1ae602eeb165c332376e27b42971877 100644 --- a/ui/src/components/ChatInterface.tsx +++ b/ui/src/components/ChatInterface.tsx @@ -599,8 +599,9 @@ function ChatInterface({ const links = window.__SHELLEY_INIT__?.links || []; const hostname = window.__SHELLEY_INIT__?.hostname || "localhost"; const { hasUpdate, openModal: openVersionModal, VersionModal } = useVersionChecker(); - const [, setReconnectAttempts] = useState(0); + const [reconnectAttempts, setReconnectAttempts] = useState(0); const [isDisconnected, setIsDisconnected] = useState(false); + const [isReconnecting, setIsReconnecting] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); // Ephemeral terminals are local-only and not persisted to the database const [ephemeralTerminals, setEphemeralTerminals] = useState([]); @@ -697,19 +698,47 @@ function ChatInterface({ // Store reconnect function in a ref so event listeners always have the latest version const reconnectRef = useRef<() => void>(() => {}); + // Check connection health - returns true if connection needs to be re-established + const checkConnectionHealth = useCallback(() => { + if (!conversationId) return false; + + const es = eventSourceRef.current; + // No connection exists + if (!es) return true; + // EventSource.CLOSED = 2, EventSource.CONNECTING = 0 + // If closed or errored, we need to reconnect + if (es.readyState === 2) return true; + // If still connecting after coming back, that's fine + return false; + }, [conversationId]); + useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === "visible") { - reconnectRef.current(); + // When tab becomes visible, always check connection health + if (checkConnectionHealth()) { + console.log("Tab visible: connection unhealthy, reconnecting"); + reconnectRef.current(); + } else { + console.log("Tab visible: connection healthy"); + } } }; const handleFocus = () => { - reconnectRef.current(); + // On focus, check connection health + if (checkConnectionHealth()) { + console.log("Window focus: connection unhealthy, reconnecting"); + reconnectRef.current(); + } }; const handleOnline = () => { - reconnectRef.current(); + // Coming back online - definitely try to reconnect if needed + if (checkConnectionHealth()) { + console.log("Online: connection unhealthy, reconnecting"); + reconnectRef.current(); + } }; document.addEventListener("visibilitychange", handleVisibilityChange); @@ -721,7 +750,7 @@ function ChatInterface({ window.removeEventListener("focus", handleFocus); window.removeEventListener("online", handleOnline); }; - }, []); + }, [checkConnectionHealth]); const loadMessages = async () => { if (!conversationId) return; @@ -874,6 +903,7 @@ function ChatInterface({ if (attempts > delays.length) { // Show disconnected UI but start periodic retry every 30 seconds + setIsReconnecting(false); setIsDisconnected(true); if (!periodicRetryRef.current) { periodicRetryRef.current = window.setInterval(() => { @@ -886,6 +916,8 @@ function ChatInterface({ return attempts; } + // Show reconnecting indicator during backoff attempts + setIsReconnecting(true); const delay = delays[attempts - 1]; console.log(`Reconnecting in ${delay}ms (attempt ${attempts}/${delays.length})`); @@ -904,6 +936,7 @@ function ChatInterface({ // Reset reconnect attempts and clear periodic retry on successful connection setReconnectAttempts(0); setIsDisconnected(false); + setIsReconnecting(false); if (periodicRetryRef.current) { clearInterval(periodicRetryRef.current); periodicRetryRef.current = null; @@ -981,6 +1014,7 @@ function ChatInterface({ const handleManualReconnect = () => { if (!conversationId || eventSourceRef.current) return; setIsDisconnected(false); + setIsReconnecting(false); setReconnectAttempts(0); if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); @@ -993,15 +1027,30 @@ function ChatInterface({ setupMessageStream(); }; - // Update the reconnect ref when isDisconnected or conversationId changes + // Update the reconnect ref - always attempt reconnect if connection is unhealthy useEffect(() => { reconnectRef.current = () => { - if (isDisconnected && conversationId && !eventSourceRef.current) { - console.log("Visibility/focus/online triggered reconnect attempt"); - handleManualReconnect(); + if (!conversationId) return; + // Always try to reconnect if there's no active connection + if (!eventSourceRef.current || eventSourceRef.current.readyState === 2) { + console.log("Reconnect triggered: no active connection"); + // Clear any pending reconnect attempts + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (periodicRetryRef.current) { + clearInterval(periodicRetryRef.current); + periodicRetryRef.current = null; + } + // Reset state and reconnect + setIsDisconnected(false); + setIsReconnecting(false); + setReconnectAttempts(0); + setupMessageStream(); } }; - }, [isDisconnected, conversationId]); + }, [conversationId]); // Handle external trigger to open diff viewer useEffect(() => { @@ -1645,6 +1694,14 @@ function ChatInterface({ Retry + ) : isReconnecting ? ( + // Reconnecting state - show during backoff attempts + <> + + Reconnecting{reconnectAttempts > 0 ? ` (${reconnectAttempts}/3)` : ""} + ... + + ) : error ? ( // Error state <> diff --git a/ui/src/styles.css b/ui/src/styles.css index 03f5912f31cace4ce4d9f013db88825732fee4e1..9a9502d3e0f5bb05929f22d4634b70912a48d0ff 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -2073,6 +2073,26 @@ select:disabled { font-weight: 500; } +.status-message.status-reconnecting { + color: var(--blue-text); + font-weight: 500; +} + +.reconnecting-dots { + display: inline-block; + animation: reconnecting-pulse 1.5s ease-in-out infinite; +} + +@keyframes reconnecting-pulse { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 1; + } +} + .animated-working { display: inline; }