diff --git a/dashboard/backend/routes/terminal_proxy.py b/dashboard/backend/routes/terminal_proxy.py index 17b5a6af..2d631e80 100644 --- a/dashboard/backend/routes/terminal_proxy.py +++ b/dashboard/backend/routes/terminal_proxy.py @@ -192,7 +192,17 @@ def _pump_upstream_to_client(): while not stop.is_set(): msg = client_ws.receive(timeout=30) if msg is None: - break + # simple-websocket returns None on receive() timeout, not + # on disconnect (disconnects raise ConnectionClosed). An + # idle chat session — e.g. user with the tab in the + # background where browsers throttle the 25s ping + # setInterval below 1/min — would otherwise drop the WS + # here, leaving the frontend's wsRef pointing at a CLOSED + # socket. Subsequent sendMessage() then silently no-ops + # because readyState !== OPEN. Continuing the loop + # preserves the connection across idle periods; real + # disconnects still surface via the exception handler. + continue upstream.send(msg) except Exception: pass diff --git a/dashboard/frontend/src/components/AgentChat.tsx b/dashboard/frontend/src/components/AgentChat.tsx index b60ff664..93fd3402 100644 --- a/dashboard/frontend/src/components/AgentChat.tsx +++ b/dashboard/frontend/src/components/AgentChat.tsx @@ -104,8 +104,16 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e const inputRef = useRef(null) const fileInputRef = useRef(null) const pingRef = useRef | null>(null) + const reconnectTimerRef = useRef | null>(null) + const reconnectDelayRef = useRef(1000) const dragCounterRef = useRef(0) const subagentToolRef = useRef<{ toolName: string; toolUseId: string; input: string; parentToolUseId: string } | null>(null) + // Auto-scroll só "segue" o stream se o usuário estiver perto do fundo. Se ele + // rolou para cima manualmente para ler histórico, NÃO joga ele de volta a + // cada nova mensagem/delta — UX comum em chat. Volta a seguir quando ele + // rolar de volta ao fundo (ou mandar nova mensagem, ver sendMessage). + const isAtBottomRef = useRef(true) + const [showJumpToBottom, setShowJumpToBottom] = useState(false) // Auto-dismiss global notifications when the user opens this session useEffect(() => { @@ -121,25 +129,88 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e } }, [pendingApprovals.length, sessionId, onPendingCountChange]) - // Auto-scroll to bottom + // Auto-scroll to bottom — respeita scroll manual do usuário. + // + // Race fix: durante streaming rápido (60+ deltas/seg), o rAF de scrollToBottom + // pode rodar ANTES do onScroll do usuário propagar. Se confiarmos apenas no + // isAtBottomRef (atualizado por handleScroll), a flag estará stale → rolaríamos + // pra baixo mesmo o usuário já tendo rolado pra cima. Solução: recomputar + // distance from bottom no MOMENTO do scroll, dentro do rAF. Se o usuário já + // não está no fundo, atualiza a flag proativamente e não rola. const scrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + const el = scrollRef.current + if (!el) return + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + if (distance < 50) { + el.scrollTop = el.scrollHeight + if (!isAtBottomRef.current) { + isAtBottomRef.current = true + setShowJumpToBottom(false) + } + } else { + // Usuário rolou pra cima entre o agendamento do rAF e este momento. + // Atualiza a flag pra evitar futuros auto-scrolls até ele descer. + if (isAtBottomRef.current) { + isAtBottomRef.current = false + setShowJumpToBottom(true) + } + } + }) + }, []) + + // Scroll forçado, ignora flag — usar quando o usuário pediu explicitamente + // (clique no botão "ir para o final" ou ao mandar nova mensagem). + const forceScrollToBottom = useCallback(() => { requestAnimationFrame(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight + isAtBottomRef.current = true + setShowJumpToBottom(false) } }) }, []) + // Threshold de 50px: se o usuário está a menos de 50px do fundo, considera + // "no fundo" e mantém auto-scroll ativo. Acima disso, suspende. + const handleScroll = useCallback(() => { + const el = scrollRef.current + if (!el) return + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight + const atBottom = distanceFromBottom < 50 + isAtBottomRef.current = atBottom + setShowJumpToBottom(!atBottom) + }, []) + // Connect WebSocket useEffect(() => { if (!sessionId) return - setStatus('connecting') - setErrorMsg(null) let cancelled = false - let ws: WebSocket | null = null - ;(async () => { + // Schedule a reconnect with exponential backoff, capped at 30s. Reset to + // 1s on every successful onopen — see the matching reset below. Without + // this, a transient WS drop (proxy idle-timeout, network blip, laptop + // sleep/wake) leaves the chat permanently "open" in the UI but the + // socket closed: sendMessage() then silently no-ops because readyState + // !== OPEN, which is exactly the "I click send and nothing happens" + // failure mode users hit. + const scheduleReconnect = () => { + if (cancelled) return + if (reconnectTimerRef.current) return // already scheduled + const delay = Math.min(reconnectDelayRef.current, 30000) + reconnectDelayRef.current = Math.min(delay * 2, 30000) + reconnectTimerRef.current = setTimeout(() => { + reconnectTimerRef.current = null + if (!cancelled) connect() + }, delay) + } + + const connect = async () => { + if (cancelled) return + setStatus('connecting') + setErrorMsg(null) + // 1) HTTP preflight — fails fast on ECONNREFUSED so we can show a real error // instead of hanging in 'connecting' forever (same pattern as AgentTerminal). try { @@ -149,16 +220,19 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e if (cancelled) return setStatus('error') setErrorMsg(`Could not reach terminal-server at ${TS_HTTP}. Is it running?`) + scheduleReconnect() return } if (cancelled) return - // 2) Open WS - ws = new WebSocket(`${TS_WS}/ws`) + // 2) Open WS — scope-local so each reconnect has its own instance and + // handlers don't race against the next one. + const ws = new WebSocket(`${TS_WS}/ws`) wsRef.current = ws ws.onopen = () => { - ws!.send(JSON.stringify({ type: 'join_session', sessionId })) + reconnectDelayRef.current = 1000 + ws.send(JSON.stringify({ type: 'join_session', sessionId })) setStatus('idle') } @@ -176,6 +250,8 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e uuid: m.uuid, streaming: false, }))) + // Abriu a sessão agora — começa no fundo. + isAtBottomRef.current = true scrollToBottom() } // Restore ticket binding (Feature 1.3) @@ -186,6 +262,7 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e // Fallback history restore if (msg.messages?.length > 0) { setMessages(msg.messages.map((m: any) => ({ ...m, streaming: false }))) + isAtBottomRef.current = true scrollToBottom() } break @@ -268,31 +345,57 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e break case 'pong': + lastPongAt = Date.now() break } } ws.onerror = () => { - if (cancelled) return - setStatus('error') - setErrorMsg('WebSocket error') + // Don't surface "WebSocket error" as a sticky error: most onerror + // events here are paired with an immediate onclose that triggers a + // reconnect, and showing the error disables the input mid-send. Just + // let onclose handle the recovery. } + // Per-ws ping interval + heartbeat-timeout. Captured locally so onclose + // of an old ws doesn't clear the ping interval belonging to a newer + // reconnect. The ws library can leave a socket "half-open" — TCP died + // but no close frame ever arrives — so onclose never fires and the + // frontend silently stops receiving chat_event. Symptom: agent reply + // only shows up after a manual F5 (the reload re-runs session_joined, + // which restores chatHistory from the server). Fix: track last pong; + // if more than 60s elapsed since one was received, force-close the + // socket so onclose → scheduleReconnect kicks in. + let lastPongAt = Date.now() + const localPing = setInterval(() => { + if (ws.readyState !== WebSocket.OPEN) return + if (Date.now() - lastPongAt > 60000) { + try { ws.close() } catch {} + return + } + ws.send(JSON.stringify({ type: 'ping' })) + }, 25000) + pingRef.current = localPing + ws.onclose = () => { - if (pingRef.current) { clearInterval(pingRef.current); pingRef.current = null } + clearInterval(localPing) + if (pingRef.current === localPing) pingRef.current = null + if (cancelled) return + // The closing ws may not be the current one if a reconnect already + // raced ahead — only clear wsRef if it still points at us. + if (wsRef.current === ws) wsRef.current = null + scheduleReconnect() } + } - pingRef.current = setInterval(() => { - if (ws!.readyState === WebSocket.OPEN) { - ws!.send(JSON.stringify({ type: 'ping' })) - } - }, 25000) - })() + connect() return () => { cancelled = true if (pingRef.current) { clearInterval(pingRef.current); pingRef.current = null } - try { ws?.close() } catch {} + if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null } + reconnectDelayRef.current = 1000 + try { wsRef.current?.close() } catch {} wsRef.current = null } }, [sessionId]) @@ -665,7 +768,7 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e // Extract plain text from a message for copying const getMessageText = (msg: ChatMessage): string => { if (msg.role === 'user' || msg.role === 'system') return msg.text - return msg.blocks + return (msg.blocks ?? []) .filter((b): b is { type: 'text'; text: string } => b.type === 'text') .map(b => b.text) .join('\n\n') @@ -721,6 +824,9 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e rewindFromUuid: uuid, })) + // Mesmo motivo do sendMessage: reenviar/editar = quer ver o resultado. + isAtBottomRef.current = true + setShowJumpToBottom(false) scrollToBottom() }, [editingText, editingUuid, scrollToBottom]) @@ -787,6 +893,10 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e files: filesForServer.length > 0 ? filesForServer : undefined, })) + // Mandou nova mensagem = sinal claro de "quero ver minha mensagem no + // fundo". Reativa auto-scroll mesmo se ele estava lendo histórico. + isAtBottomRef.current = true + setShowJumpToBottom(false) scrollToBottom() if (inputRef.current) { inputRef.current.style.height = 'auto' @@ -899,7 +1009,13 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e const isConnecting = externalLoading || status === 'connecting' const effectiveError = externalError || (status === 'error' ? errorMsg : null) - const inputDisabled = isConnecting || !!effectiveError + // Don't disable the textarea during transient reconnects — disabling blurs + // the cursor (native behavior of