From e09335031a533cc5ccc19522dc18e664e3203c13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 19:17:12 +0000 Subject: [PATCH 1/2] fix(websocket): eliminate sidebar flickering and connection timeouts Issue: Sidebar constantly flickering and WebSocket repeatedly disconnecting Root Causes: 1. EnhancedWebSocketStatus countdown timer updated every second - setCountdown() called every 1000ms causing re-renders - Re-renders propagated through component tree to Layout - Caused sidebar to flicker with CSS transitions 2. WebSocket backend missing ping/pong keepalive - Connections timed out due to inactivity - Triggered reconnection attempts and countdown display Solutions: 1. UI Fix - Remove per-second countdown updates - Changed countdown timer to set initial value only - Removed setInterval that updated every second - Now shows "Reconnecting... (attempt X/10)" without ticking countdown - Status still updates when reconnection attempt changes - Prevents thousands of unnecessary re-renders 2. Backend Fix - Add WebSocket ping/pong keepalive - writePump(): Send ping every 30 seconds to keep connection alive - readPump(): Set 60s read deadline and pong handler - Reset deadlines on any message activity - Prevents idle connection timeouts Technical Details: - UI: EnhancedWebSocketStatus.tsx countdown effect simplified - UI: LinearProgress changed from determinate to indeterminate - UI: Static "~Xs" display instead of live countdown - Backend: Added time.NewTicker for 30s ping interval - Backend: Added SetReadDeadline(60s) and SetPongHandler - Backend: Added SetWriteDeadline(10s) for write operations Result: Sidebar only re-renders on actual navigation or status changes, not every second. WebSocket stays connected indefinitely. Files Changed: - ui/src/components/EnhancedWebSocketStatus.tsx - api/internal/websocket/hub.go --- api/internal/websocket/hub.go | 62 +++++++++++++------ ui/src/components/EnhancedWebSocketStatus.tsx | 30 +++------ 2 files changed, 52 insertions(+), 40 deletions(-) diff --git a/api/internal/websocket/hub.go b/api/internal/websocket/hub.go index af9e8e1b..03d4565a 100644 --- a/api/internal/websocket/hub.go +++ b/api/internal/websocket/hub.go @@ -42,6 +42,7 @@ package websocket import ( "log" "sync" + "time" "github.com/gorilla/websocket" ) @@ -199,33 +200,46 @@ func (h *Hub) ClientCount() int { // writePump pumps messages from the hub to the websocket connection func (c *Client) writePump() { + ticker := time.NewTicker(30 * time.Second) // Send ping every 30 seconds defer func() { + ticker.Stop() c.conn.Close() }() for { - message, ok := <-c.send - if !ok { - // Hub closed the channel - c.conn.WriteMessage(websocket.CloseMessage, []byte{}) - return - } + select { + case message, ok := <-c.send: + // Set write deadline to prevent hanging on slow connections + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if !ok { + // Hub closed the channel + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } - w, err := c.conn.NextWriter(websocket.TextMessage) - if err != nil { - return - } - w.Write(message) + w, err := c.conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write(message) - // Add queued messages to the current websocket message - n := len(c.send) - for i := 0; i < n; i++ { - w.Write([]byte{'\n'}) - w.Write(<-c.send) - } + // Add queued messages to the current websocket message + n := len(c.send) + for i := 0; i < n; i++ { + w.Write([]byte{'\n'}) + w.Write(<-c.send) + } + + if err := w.Close(); err != nil { + return + } - if err := w.Close(); err != nil { - return + case <-ticker.C: + // Send ping to keep connection alive + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } } } } @@ -237,6 +251,13 @@ func (c *Client) readPump() { c.conn.Close() }() + // Set read deadline and pong handler to keep connection alive + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + for { _, message, err := c.conn.ReadMessage() if err != nil { @@ -246,6 +267,9 @@ func (c *Client) readPump() { break } + // Reset read deadline on any message + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + // For now, we just log received messages // In the future, we could handle client->server messages log.Printf("Received message from client %s: %s", c.id, message) diff --git a/ui/src/components/EnhancedWebSocketStatus.tsx b/ui/src/components/EnhancedWebSocketStatus.tsx index 84d1243a..711d97f0 100644 --- a/ui/src/components/EnhancedWebSocketStatus.tsx +++ b/ui/src/components/EnhancedWebSocketStatus.tsx @@ -60,24 +60,14 @@ function EnhancedWebSocketStatus({ return 60; // 60 seconds for all subsequent retries }; - // Countdown timer for reconnection + // Countdown timer for reconnection - DISABLED to prevent sidebar flickering + // The countdown state update every second was causing the entire component tree to re-render, + // which made the sidebar flicker. Instead, we show a static "Reconnecting..." message. useEffect(() => { if (reconnectAttempts > 0 && !isConnected) { + // Calculate next retry time but don't update state every second const delay = getReconnectDelay(reconnectAttempts - 1); - let remainingTime = delay; - setCountdown(remainingTime); - - const interval = setInterval(() => { - remainingTime -= 1; - if (remainingTime <= 0) { - clearInterval(interval); - setCountdown(null); - } else { - setCountdown(remainingTime); - } - }, 1000); - - return () => clearInterval(interval); + setCountdown(delay); // Set once, don't update every second } else { setCountdown(null); } @@ -113,9 +103,8 @@ function EnhancedWebSocketStatus({ return latency ? `Live • ${latency}ms` : 'Live Updates'; } if (reconnectAttempts > 0) { - return countdown !== null - ? `Reconnecting in ${countdown}s...` - : `Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})`; + // Show attempt count only, not countdown (to prevent re-renders) + return `Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})`; } if (reconnectAttempts >= maxReconnectAttempts) { return 'Connection Failed'; @@ -197,13 +186,12 @@ function EnhancedWebSocketStatus({ {countdown !== null && ( - {countdown}s + Next retry in ~{countdown}s )} = maxReconnectAttempts ? 'error' : 'primary'} /> From 6ff3efef4a7a3700e237099bbfd22b235d6dabf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 19:41:01 +0000 Subject: [PATCH 2/2] fix(ui): prevent CircularProgress spinner from restarting on re-renders Issue: The WebSocket status indicator's spinning circle constantly restarts, making the UI appear glitchy and distracting to users. Root Cause: The CircularProgress component in EnhancedWebSocketStatus was being recreated on every render because getStatusIcon() returned a new React element each time. Even though the component was memoized, any prop change (like latency updating every 10 seconds) would trigger a re-render, creating a new CircularProgress element and restarting its animation. Solution: - Added useMemo to memoize the status icon - Icon only recreates when connection status actually changes (isConnected, reconnectAttempts, or maxReconnectAttempts change) - Latency updates no longer cause the spinner to restart - Removed duplicate getStatusIcon() function Technical Details: - Added useMemo import to React imports - Created statusIcon memoized value with proper dependencies - Updated Chip and Popover to use statusIcon instead of getStatusIcon() - Removed redundant getStatusIcon() function Result: The CircularProgress spinner animation now runs smoothly without restarting, even when other component props update. This provides a much better user experience when WebSocket is reconnecting. Files Changed: - ui/src/components/EnhancedWebSocketStatus.tsx --- ui/src/components/EnhancedWebSocketStatus.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ui/src/components/EnhancedWebSocketStatus.tsx b/ui/src/components/EnhancedWebSocketStatus.tsx index 711d97f0..af1a8227 100644 --- a/ui/src/components/EnhancedWebSocketStatus.tsx +++ b/ui/src/components/EnhancedWebSocketStatus.tsx @@ -9,7 +9,7 @@ * * @component */ -import { useState, useEffect, memo } from 'react'; +import { useState, useEffect, useMemo, memo } from 'react'; import { Box, Chip, @@ -119,12 +119,14 @@ function EnhancedWebSocketStatus({ return 'default' as const; }; - const getStatusIcon = () => { + // Memoize the status icon to prevent CircularProgress from restarting on every render + // Only recreate when connection status actually changes, not when latency updates + const statusIcon = useMemo(() => { if (isConnected) return ; if (reconnectAttempts >= maxReconnectAttempts) return ; if (reconnectAttempts > 0) return ; return ; - }; + }, [isConnected, reconnectAttempts, maxReconnectAttempts]); const quality = getConnectionQuality(latency); const open = Boolean(anchorEl); @@ -132,7 +134,7 @@ function EnhancedWebSocketStatus({ return ( <> - {getStatusIcon()} + {statusIcon} {isConnected ? 'Connected' : reconnectAttempts > 0 ? 'Reconnecting' : 'Disconnected'}