diff --git a/server/modules/websocket/services/websocket-server.service.ts b/server/modules/websocket/services/websocket-server.service.ts index 7e5c12e4..2ba2ec6e 100644 --- a/server/modules/websocket/services/websocket-server.service.ts +++ b/server/modules/websocket/services/websocket-server.service.ts @@ -31,6 +31,24 @@ export function createWebSocketServer( }); wss.on('connection', (ws, request) => { + // Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s, + // AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections + // are silently torn down even when the UI is active, causing repeated + // reconnect cycles. ws library heartbeat is opt-in. + const HEARTBEAT_INTERVAL_MS = 30_000; + const heartbeat = setInterval(() => { + if (ws.readyState === ws.OPEN) { + try { + ws.ping(); + } catch { + // socket may have been closed concurrently — interval will be cleared below + } + } + }, HEARTBEAT_INTERVAL_MS); + const stopHeartbeat = () => clearInterval(heartbeat); + ws.on('close', stopHeartbeat); + ws.on('error', stopHeartbeat); + const incomingRequest = request as AuthenticatedWebSocketRequest; const url = incomingRequest.url ?? '/'; const pathname = new URL(url, 'http://localhost').pathname; diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index c447bc19..f041b773 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -295,6 +295,7 @@ export default function ChatComposer({
-
+
{message.content}
{message.images && message.images.length > 0 && ( @@ -405,7 +405,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o ) : ( -
+
{/* Reasoning accordion */} {showThinking && message.reasoning && ( diff --git a/src/contexts/WebSocketContext.tsx b/src/contexts/WebSocketContext.tsx index 116da6b9..456c0761 100644 --- a/src/contexts/WebSocketContext.tsx +++ b/src/contexts/WebSocketContext.tsx @@ -36,8 +36,12 @@ const useWebSocketProviderState = (): WebSocketContextType => { const { token } = useAuth(); useEffect(() => { + // The cleanup below sets unmountedRef = true. Without this reset, every + // re-run of the effect (e.g. on token refresh) would short-circuit connect() + // at its unmounted guard and leave the socket permanently disconnected. + unmountedRef.current = false; connect(); - + return () => { unmountedRef.current = true; if (reconnectTimeoutRef.current) { diff --git a/vite.config.js b/vite.config.js index 61967cbe..664fd307 100755 --- a/vite.config.js +++ b/vite.config.js @@ -37,6 +37,10 @@ export default defineConfig(({ mode }) => { '/shell': { target: `ws://${proxyHost}:${serverPort}`, ws: true + }, + '/plugin-ws': { + target: `ws://${proxyHost}:${serverPort}`, + ws: true } } },