Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion dashboard/backend/routes/terminal_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
183 changes: 159 additions & 24 deletions dashboard/frontend/src/components/AgentChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,16 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e
const inputRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | 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(() => {
Expand All @@ -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 {
Expand All @@ -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')
}

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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 <textarea disabled>), which forces the user
// to click back in after every WS hiccup. canSend already gates the Send
// button on readyState === OPEN, so typing while the socket is down is safe:
// the text stays in React state and gets sent the moment the WS reopens.
// Only hard errors (server unreachable) still disable input.
const inputDisabled = !!effectiveError
Comment on lines 1010 to +1018
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: isConnecting is now unused and the new inputDisabled behavior might surprise future readers.

Since isConnecting is no longer used (and not referenced elsewhere), please remove it to avoid confusion. Also consider a brief inline comment or a more specific name for inputDisabled to clarify that it now reflects only error state, not connecting state, so future changes don’t accidentally reintroduce the old behavior.

const canSend = (input.trim().length > 0 || attachedFiles.length > 0) && !inputDisabled && status !== 'running'

return (
Expand Down Expand Up @@ -1035,7 +1151,7 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e
)}

{/* Messages area */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-6 space-y-5">
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-6 py-6 space-y-5 relative">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center">
<div
Expand Down Expand Up @@ -1159,7 +1275,7 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e
<AgentAvatar name={agent} size={28} />
</div>
<div className="flex-1 min-w-0 space-y-2">
{(msg as any).blocks.map((block: AssistantBlock, j: number) => (
{(((msg as any).blocks ?? []) as AssistantBlock[]).map((block: AssistantBlock, j: number) => (
<div key={j}>
{block.type === 'text' && (
<div className="text-sm text-[#e6edf3] leading-relaxed prose-invert max-w-none">
Expand All @@ -1173,7 +1289,7 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e
))}
{/* Typing indicator — shown while streaming with no visible content yet */}
{(msg as any).streaming && (() => {
const blocks = (msg as any).blocks as AssistantBlock[]
const blocks = (((msg as any).blocks ?? []) as AssistantBlock[])
const hasVisibleContent = blocks.some(b => b.type === 'text' || b.type === 'tool_use')
return !hasVisibleContent
})() && (
Expand Down Expand Up @@ -1227,6 +1343,25 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e
)}
</div>

{/* Botão flutuante "ir para o final" — aparece quando o usuário rolou
para cima e o auto-scroll está suspenso. Clique reativa o follow. */}
{showJumpToBottom && (
<button
onClick={forceScrollToBottom}
aria-label="Ir para o final"
className="absolute left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-[11px] shadow-lg transition-colors hover:bg-[#1a2744]"
style={{
bottom: '92px',
background: '#161b22',
borderColor: accentColor + '40',
color: accentColor,
}}
>
<ChevronDown size={12} />
Ir para o final
</button>
)}

{/* Input area */}
<div className="flex-shrink-0 border-t border-[#21262d] bg-[#0d1117] px-4 py-3">
<div className="max-w-3xl mx-auto space-y-2">
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"pyyaml>=6.0",
"pydantic>=2.0",
"flask-sock>=0.7",
"websocket-client>=1.9",
"boto3>=1.35",
"pywinpty>=3.0.3; sys_platform == 'win32'",
"cryptography>=42",
Expand Down
Loading