diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index 0873897c..26b57331 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -791,16 +791,78 @@ function AgentDetailInner() { const [sessionsLoading, setSessionsLoading] = useState(false); const [allSessionsLoading, setAllSessionsLoading] = useState(false); const [agentExpired, setAgentExpired] = useState(false); + // Websocket chat state (for 'me' conversation) + const token = useAuthStore((s) => s.token); + const currentUser = useAuthStore((s) => s.user); + const isAdmin = currentUser?.role === 'platform_admin' || currentUser?.role === 'org_admin'; + type SessionRuntimeKey = string; + const wsMapRef = useRef>({}); + const reconnectTimerRef = useRef | null>>({}); + const reconnectDisabledRef = useRef>({}); + const sessionUiStateRef = useRef>({}); + const activeSessionIdRef = useRef(null); + const currentAgentIdRef = useRef(id); + const sessionMsgAbortRef = useRef(null); + const sessionLoadSeqRef = useRef(0); + + const buildSessionRuntimeKey = (agentId: string, sessionId: string) => `${agentId}:${sessionId}`; + + const clearReconnectTimer = (key: SessionRuntimeKey) => { + const timer = reconnectTimerRef.current[key]; + if (timer) { + clearTimeout(timer); + reconnectTimerRef.current[key] = null; + } + }; - const fetchMySessions = async (silent = false) => { - if (!id) return; - if (!silent) setSessionsLoading(true); + const closeSessionSocket = (key: SessionRuntimeKey, disableReconnect = true) => { + if (disableReconnect) reconnectDisabledRef.current[key] = true; + clearReconnectTimer(key); + const ws = wsMapRef.current[key]; + if (ws && ws.readyState !== WebSocket.CLOSED) ws.close(); + delete wsMapRef.current[key]; + delete sessionUiStateRef.current[key]; + }; + + const setSessionUiState = (key: SessionRuntimeKey, next: Partial<{ isWaiting: boolean; isStreaming: boolean }>) => { + const prev = sessionUiStateRef.current[key] || { isWaiting: false, isStreaming: false }; + sessionUiStateRef.current[key] = { ...prev, ...next }; + }; + + const isWritableSession = (sess: any) => { + if (!sess) return false; + const isAgentSession = sess.source_channel === 'agent' || sess.participant_type === 'agent'; + if (isAgentSession) return false; + if (sess.user_id && currentUser && sess.user_id !== String(currentUser.id)) return false; + return true; + }; + + const syncActiveSocketState = (sess: any | null = activeSession, agentId: string | undefined = id) => { + if (!sess || !agentId) { + wsRef.current = null; + setWsConnected(false); + return; + } + const key = buildSessionRuntimeKey(agentId, sess.id); + const ws = wsMapRef.current[key]; + wsRef.current = ws ?? null; + setWsConnected(!!ws && ws.readyState === WebSocket.OPEN); + }; + + const fetchMySessions = async (silent = false, agentId: string | undefined = id) => { + if (!agentId) return []; + if (!silent && currentAgentIdRef.current === agentId) setSessionsLoading(true); try { const tkn = localStorage.getItem('token'); - const res = await fetch(`/api/agents/${id}/sessions?scope=mine`, { headers: { Authorization: `Bearer ${tkn}` } }); - if (res.ok) { const data = await res.json(); setSessions(data); return data; } + const res = await fetch(`/api/agents/${agentId}/sessions?scope=mine`, { headers: { Authorization: `Bearer ${tkn}` } }); + if (res.ok) { + const data = await res.json(); + if (currentAgentIdRef.current === agentId) setSessions(data); + if (!silent && currentAgentIdRef.current === agentId) setSessionsLoading(false); + return data; + } } catch { } - if (!silent) setSessionsLoading(false); + if (!silent && currentAgentIdRef.current === agentId) setSessionsLoading(false); return []; }; @@ -812,13 +874,63 @@ function AgentDetailInner() { const res = await fetch(`/api/agents/${id}/sessions?scope=all`, { headers: { Authorization: `Bearer ${tkn}` } }); if (res.ok) { const all = await res.json(); - setAllSessions(all.filter((s: any) => s.source_channel !== 'trigger')); + if (currentAgentIdRef.current === id) { + setAllSessions(all.filter((s: any) => s.source_channel !== 'trigger')); + } } } catch { } setAllSessionsLoading(false); }; + const selectSession = async (sess: any) => { + const targetAgentId = id; + if (!targetAgentId) return; + const runtimeKey = buildSessionRuntimeKey(targetAgentId, String(sess.id)); + const runtimeState = sessionUiStateRef.current[runtimeKey] || { isWaiting: false, isStreaming: false }; + activeSessionIdRef.current = sess.id; + setChatMessages([]); + setHistoryMsgs([]); + setIsStreaming(runtimeState.isStreaming); + setIsWaiting(runtimeState.isWaiting); + setActiveSession(sess); + setAgentExpired(false); + syncActiveSocketState(sess, targetAgentId); + + // Abort any pending message load and increment sequence + sessionMsgAbortRef.current?.abort(); + const controller = new AbortController(); + sessionMsgAbortRef.current = controller; + const loadSeq = ++sessionLoadSeqRef.current; + try { + const tkn = localStorage.getItem('token'); + const res = await fetch(`/api/agents/${targetAgentId}/sessions/${sess.id}/messages`, { + headers: { Authorization: `Bearer ${tkn}` }, + signal: controller.signal, + }); + if (!res.ok) return; + const msgs = await res.json(); + if (controller.signal.aborted || loadSeq !== sessionLoadSeqRef.current) return; + if (currentAgentIdRef.current !== targetAgentId) return; + if (activeSessionIdRef.current !== sess.id) return; + const isAgentSession = sess.source_channel === 'agent' || sess.participant_type === 'agent'; + if (!isAgentSession && sess.user_id === String(currentUser?.id)) { + setChatMessages(msgs.map((m: any) => parseChatMsg({ + role: m.role, content: m.content, + ...(m.toolName && { toolName: m.toolName, toolArgs: m.toolArgs, toolStatus: m.toolStatus, toolResult: m.toolResult }), + ...(m.thinking && { thinking: m.thinking }), + ...(m.created_at && { timestamp: m.created_at }), + }))); + } else { + setHistoryMsgs(msgs); + } + } catch (err: any) { + if (err?.name === 'AbortError') return; + console.error('Failed to load session messages:', err); + } + }; + const createNewSession = async () => { + if (!id) return; try { const tkn = localStorage.getItem('token'); const res = await fetch(`/api/agents/${id}/sessions`, { @@ -828,9 +940,9 @@ function AgentDetailInner() { if (res.ok) { const newSess = await res.json(); setSessions(prev => [newSess, ...prev]); - setChatMessages([]); - setHistoryMsgs([]); - setActiveSession(newSess); + setIsStreaming(false); + setIsWaiting(false); + await selectSession(newSess); } else { const err = await res.json().catch(() => ({ detail: `HTTP ${res.status}` })); console.error('Failed to create session:', err); @@ -847,64 +959,24 @@ function AgentDetailInner() { const tkn = localStorage.getItem('token'); try { await fetch(`/api/agents/${id}/sessions/${sessionId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${tkn}` } }); + if (id) closeSessionSocket(buildSessionRuntimeKey(id, sessionId), true); // If deleted the active session, clear it if (activeSession?.id === sessionId) { + activeSessionIdRef.current = null; setActiveSession(null); setChatMessages([]); setHistoryMsgs([]); + setWsConnected(false); + setIsStreaming(false); + setIsWaiting(false); } - // Refresh session lists - const r1 = await fetch(`/api/agents/${id}/sessions?scope=mine`, { headers: { Authorization: `Bearer ${tkn}` } }); - if (r1.ok) setSessions(await r1.json()); - const r2 = await fetch(`/api/agents/${id}/sessions?scope=all`, { headers: { Authorization: `Bearer ${tkn}` } }); - if (r2.ok) { - const all2 = await r2.json(); - setAllSessions(all2.filter((s: any) => s.source_channel !== 'trigger')); - } + await fetchMySessions(false, id); + await fetchAllSessions(id); } catch (e: any) { alert(e.message || 'Delete failed'); } }; - const selectSession = async (sess: any) => { - // Close the existing WS before switching so its onmessage can no longer - // write stale streaming data into the new session's message list. - if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) { - wsRef.current.close(); - wsRef.current = null; - } - setChatMessages([]); - setHistoryMsgs([]); - setIsStreaming(false); - setIsWaiting(false); - setActiveSession(sess); - // Always load stored messages for the selected session - const tkn = localStorage.getItem('token'); - const res = await fetch(`/api/agents/${id}/sessions/${sess.id}/messages`, { headers: { Authorization: `Bearer ${tkn}` } }); - if (res.ok) { - const msgs = await res.json(); - // Agent-to-agent sessions are always read-only - const isAgentSession = sess.source_channel === 'agent' || sess.participant_type === 'agent'; - if (!isAgentSession && sess.user_id === String(currentUser?.id)) { - // Own session: load into chatMessages so WS can append new replies seamlessly - setChatMessages(msgs.map((m: any) => parseChatMsg({ - role: m.role, content: m.content, - ...(m.toolName && { toolName: m.toolName, toolArgs: m.toolArgs, toolStatus: m.toolStatus, toolResult: m.toolResult }), - ...(m.thinking && { thinking: m.thinking }), - ...(m.created_at && { timestamp: m.created_at }), - }))); - } else { - // Other user's session or agent-to-agent: read-only view - setHistoryMsgs(msgs); - } - } - }; - - // Websocket chat state (for 'me' conversation) - const token = useAuthStore((s) => s.token); - const currentUser = useAuthStore((s) => s.user); - const isAdmin = currentUser?.role === 'platform_admin' || currentUser?.role === 'org_admin'; - // Expiry editor modal state const [showExpiryModal, setShowExpiryModal] = useState(false); const [expiryValue, setExpiryValue] = useState(''); // datetime-local string or '' @@ -1043,17 +1115,22 @@ function AgentDetailInner() { }; - // Reset state whenever the viewed agent changes useEffect(() => { - if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) { - wsRef.current.close(); - wsRef.current = null; - } + currentAgentIdRef.current = id; + }, [id]); + + // Reset visible state whenever the viewed agent changes. + // Existing background sockets keep running and will be cleaned up on unmount. + useEffect(() => { + sessionMsgAbortRef.current?.abort(); + activeSessionIdRef.current = null; setActiveSession(null); setChatMessages([]); setHistoryMsgs([]); setIsStreaming(false); setIsWaiting(false); + setWsConnected(false); + wsRef.current = null; setChatScope('mine'); setAgentExpired(false); settingsInitRef.current = false; @@ -1061,106 +1138,174 @@ function AgentDetailInner() { useEffect(() => { if (!id || !token || activeTab !== 'chat') return; - // Load sessions when entering chat tab; auto-select first and load its history - fetchMySessions().then((data: any) => { + fetchMySessions(false, id).then((data: any) => { + if (currentAgentIdRef.current !== id) return; setSessionsLoading(false); if (data && data.length > 0) selectSession(data[0]); }); - }, [id, activeTab]); + }, [id, token, activeTab]); + + const ensureSessionSocket = (sess: any, agentId: string, authToken: string) => { + const sessionId = String(sess.id); + const key = buildSessionRuntimeKey(agentId, sessionId); + const existing = wsMapRef.current[key]; + if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return; + reconnectDisabledRef.current[key] = false; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const sessionParam = `&session_id=${sessionId}`; + + const scheduleReconnect = () => { + if (reconnectDisabledRef.current[key]) return; + clearReconnectTimer(key); + reconnectTimerRef.current[key] = setTimeout(() => { + reconnectTimerRef.current[key] = null; + if (!reconnectDisabledRef.current[key]) ensureSessionSocket(sess, agentId, authToken); + }, 2000); + }; - useEffect(() => { - if (!id || !token || activeTab !== 'chat') return; - if (!activeSession) return; // wait for session to be set - // Only connect WS for own sessions (not other users' and not agent-to-agent) - const isAgentSession = activeSession.source_channel === 'agent' || activeSession.participant_type === 'agent'; - if (isAgentSession) return; - if (activeSession.user_id && currentUser && activeSession.user_id !== String(currentUser.id)) return; - let cancelled = false; - const sessionParam = activeSession?.id ? `&session_id=${activeSession.id}` : ''; - const connect = () => { - if (cancelled) return; - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${protocol}//${window.location.host}/ws/chat/${id}?token=${token}${sessionParam}`); - ws.onopen = () => { if (cancelled) { ws.close(); return; } setWsConnected(true); wsRef.current = ws; }; - ws.onclose = (e) => { - if (e.code === 4003 || e.code === 4002) { - // 4003 = Agent expired, 4002 = Config error (no model, setup failed) - if (e.code === 4003) setAgentExpired(true); - setWsConnected(false); - setIsWaiting(false); - setIsStreaming(false); - return; + const ws = new WebSocket(`${protocol}//${window.location.host}/ws/chat/${agentId}?token=${authToken}${sessionParam}`); + wsMapRef.current[key] = ws; + ws.onopen = () => { + if (reconnectDisabledRef.current[key]) { + ws.close(); + return; + } + if (currentAgentIdRef.current === agentId && activeSessionIdRef.current === sessionId) { + wsRef.current = ws; + setWsConnected(true); + } + }; + ws.onclose = (e) => { + if (wsMapRef.current[key] === ws) delete wsMapRef.current[key]; + setSessionUiState(key, { isWaiting: false, isStreaming: false }); + const isActiveRuntime = currentAgentIdRef.current === agentId && activeSessionIdRef.current === sessionId; + if (isActiveRuntime) { + wsRef.current = null; + setWsConnected(false); + setIsWaiting(false); + setIsStreaming(false); + } + if (e.code === 4003 || e.code === 4002) { + reconnectDisabledRef.current[key] = true; + clearReconnectTimer(key); + if (isActiveRuntime && e.code === 4003) setAgentExpired(true); + return; + } + scheduleReconnect(); + }; + ws.onerror = (error) => { + const isActiveRuntime = currentAgentIdRef.current === agentId && activeSessionIdRef.current === sessionId; + if (isActiveRuntime) setWsConnected(false); + console.warn(`WebSocket error for session ${sessionId}:`, error); + // Error automatically triggers onclose with abnormal code, which handles reconnect + }; + ws.onmessage = (e) => { + const d = JSON.parse(e.data); + const isActiveRuntime = currentAgentIdRef.current === agentId && activeSessionIdRef.current === sessionId; + if (['thinking', 'chunk', 'tool_call', 'done', 'error', 'quota_exceeded'].includes(d.type)) { + const nextStreaming = ['thinking', 'chunk', 'tool_call'].includes(d.type); + const endStreaming = ['done', 'error', 'quota_exceeded'].includes(d.type); + setSessionUiState(key, { + isWaiting: false, + isStreaming: endStreaming ? false : nextStreaming, + }); + } + if (!isActiveRuntime) { + if (['done', 'error', 'quota_exceeded', 'trigger_notification'].includes(d.type)) { + fetchMySessions(true, agentId); } - if (!cancelled) { setWsConnected(false); setIsWaiting(false); setIsStreaming(false); setTimeout(connect, 2000); } - }; - ws.onerror = () => { if (!cancelled) setWsConnected(false); }; - ws.onmessage = (e) => { - const d = JSON.parse(e.data); - if (['thinking', 'chunk', 'tool_call', 'done', 'error', 'quota_exceeded'].includes(d.type)) { - setIsWaiting(false); - if (['thinking', 'chunk', 'tool_call'].includes(d.type)) setIsStreaming(true); - if (['done', 'error', 'quota_exceeded'].includes(d.type)) setIsStreaming(false); + if (['done', 'error', 'quota_exceeded'].includes(d.type)) { + closeSessionSocket(key, true); } + return; + } - if (d.type === 'thinking') { - setChatMessages(prev => { - const last = prev[prev.length - 1]; - if (last && last.role === 'assistant' && (last as any)._streaming) { - return [...prev.slice(0, -1), { ...last, thinking: (last.thinking || '') + d.content } as any]; - } - return [...prev, { role: 'assistant', content: '', thinking: d.content, _streaming: true } as any]; - }); - } else if (d.type === 'tool_call') { - setChatMessages(prev => { - const toolMsg: ChatMsg = { role: 'tool_call', content: '', toolName: d.name, toolArgs: d.args, toolStatus: d.status, toolResult: d.result }; - if (d.status === 'done') { - const lastIdx = prev.length - 1; - const last = prev[lastIdx]; - if (last && last.role === 'tool_call' && last.toolName === d.name && last.toolStatus === 'running') return [...prev.slice(0, lastIdx), toolMsg]; - } - return [...prev, toolMsg]; - }); - } else if (d.type === 'chunk') { - setChatMessages(prev => { - const last = prev[prev.length - 1]; - if (last && last.role === 'assistant' && (last as any)._streaming) return [...prev.slice(0, -1), { ...last, content: last.content + d.content } as any]; - return [...prev, { role: 'assistant', content: d.content, _streaming: true } as any]; - }); - } else if (d.type === 'done') { - setChatMessages(prev => { - const last = prev[prev.length - 1]; - const thinking = (last && last.role === 'assistant' && (last as any)._streaming) ? last.thinking : undefined; - if (last && last.role === 'assistant' && (last as any)._streaming) return [...prev.slice(0, -1), { role: 'assistant', content: d.content, thinking, timestamp: new Date().toISOString() }]; - return [...prev, { role: d.role, content: d.content, timestamp: new Date().toISOString() }]; - }); - // Silently refresh session list to update last_message_at (no loading spinner) - fetchMySessions(true); - } else if (d.type === 'error' || d.type === 'quota_exceeded') { - const msg = d.content || d.detail || d.message || 'Request denied'; - // Only add message if not a duplicate of the last one - setChatMessages(prev => { - const last = prev[prev.length - 1]; - if (last && last.role === 'assistant' && last.content === `⚠️ ${msg}`) return prev; - return [...prev, { role: 'assistant', content: `⚠️ ${msg}` }]; - }); - // Permanent errors — stop reconnecting - if (msg.includes('expired') || msg.includes('Setup failed') || msg.includes('no LLM model') || msg.includes('No model')) { - cancelled = true; - if (msg.includes('expired')) setAgentExpired(true); + if (['thinking', 'chunk', 'tool_call', 'done', 'error', 'quota_exceeded'].includes(d.type)) { + setIsWaiting(false); + if (['thinking', 'chunk', 'tool_call'].includes(d.type)) setIsStreaming(true); + if (['done', 'error', 'quota_exceeded'].includes(d.type)) setIsStreaming(false); + } + + if (d.type === 'thinking') { + setChatMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant' && (last as any)._streaming) { + return [...prev.slice(0, -1), { ...last, thinking: (last.thinking || '') + d.content } as any]; } - } else if (d.type === 'trigger_notification') { - // Trigger fired — show the result as a new assistant message - setChatMessages(prev => [...prev, { role: 'assistant', content: d.content }]); - fetchMySessions(true); - } else { - setChatMessages(prev => [...prev, { role: d.role, content: d.content }]); + return [...prev, { role: 'assistant', content: '', thinking: d.content, _streaming: true } as any]; + }); + } else if (d.type === 'tool_call') { + setChatMessages(prev => { + const toolMsg: ChatMsg = { role: 'tool_call', content: '', toolName: d.name, toolArgs: d.args, toolStatus: d.status, toolResult: d.result }; + if (d.status === 'done') { + const lastIdx = prev.length - 1; + const last = prev[lastIdx]; + if (last && last.role === 'tool_call' && last.toolName === d.name && last.toolStatus === 'running') return [...prev.slice(0, lastIdx), toolMsg]; + } + return [...prev, toolMsg]; + }); + } else if (d.type === 'chunk') { + setChatMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant' && (last as any)._streaming) return [...prev.slice(0, -1), { ...last, content: last.content + d.content } as any]; + return [...prev, { role: 'assistant', content: d.content, _streaming: true } as any]; + }); + } else if (d.type === 'done') { + setChatMessages(prev => { + const last = prev[prev.length - 1]; + const thinking = (last && last.role === 'assistant' && (last as any)._streaming) ? last.thinking : undefined; + if (last && last.role === 'assistant' && (last as any)._streaming) return [...prev.slice(0, -1), { role: 'assistant', content: d.content, thinking, timestamp: new Date().toISOString() }]; + return [...prev, { role: d.role, content: d.content, timestamp: new Date().toISOString() }]; + }); + fetchMySessions(true, agentId); + } else if (d.type === 'error' || d.type === 'quota_exceeded') { + const msg = d.content || d.detail || d.message || 'Request denied'; + setChatMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant' && last.content === `⚠️ ${msg}`) return prev; + return [...prev, { role: 'assistant', content: `⚠️ ${msg}` }]; + }); + if (msg.includes('expired') || msg.includes('Setup failed') || msg.includes('no LLM model') || msg.includes('No model')) { + reconnectDisabledRef.current[key] = true; + if (msg.includes('expired')) setAgentExpired(true); } - }; + } else if (d.type === 'trigger_notification') { + setChatMessages(prev => [...prev, { role: 'assistant', content: d.content }]); + fetchMySessions(true, agentId); + } else { + setChatMessages(prev => [...prev, { role: d.role, content: d.content }]); + } }; - connect(); - return () => { cancelled = true; wsRef.current?.close(); wsRef.current = null; setWsConnected(false); }; + }; + + useEffect(() => { + if (!id || !token || activeTab !== 'chat') return; + if (!activeSession) { + syncActiveSocketState(null, id); + return; + } + activeSessionIdRef.current = String(activeSession.id); + if (!isWritableSession(activeSession)) { + syncActiveSocketState(activeSession, id); + return; + } + ensureSessionSocket(activeSession, id, token); + syncActiveSocketState(activeSession, id); }, [id, token, activeTab, activeSession?.id]); + useEffect(() => { + return () => { + sessionMsgAbortRef.current?.abort(); + Object.keys(reconnectDisabledRef.current).forEach((key) => { reconnectDisabledRef.current[key] = true; }); + Object.keys(reconnectTimerRef.current).forEach((key) => clearReconnectTimer(key)); + Object.values(wsMapRef.current).forEach((ws) => { + if (ws.readyState !== WebSocket.CLOSED) ws.close(); + }); + wsMapRef.current = {}; + wsRef.current = null; + }; + }, []); + // Smart scroll: only auto-scroll if user is at the bottom const isNearBottom = useRef(true); const isFirstLoad = useRef(true); @@ -1224,7 +1369,10 @@ function AgentDetailInner() { }, [activeSession?.id, activeTab]); const sendChatMsg = () => { - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + if (!id || !activeSession?.id) return; + const activeRuntimeKey = buildSessionRuntimeKey(id, String(activeSession.id)); + const activeSocket = wsMapRef.current[activeRuntimeKey]; + if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) return; if (!chatInput.trim() && attachedFiles.length === 0) return; let userMsg = chatInput.trim(); @@ -1261,6 +1409,7 @@ function AgentDetailInner() { setIsWaiting(true); setIsStreaming(false); + setSessionUiState(activeRuntimeKey, { isWaiting: true, isStreaming: false }); setChatMessages(prev => [...prev, { role: 'user', content: userMsg, @@ -1268,7 +1417,7 @@ function AgentDetailInner() { imageUrl: attachedFiles.length === 1 ? attachedFiles[0].imageUrl : undefined, timestamp: new Date().toISOString() }]); - wsRef.current.send(JSON.stringify({ + activeSocket.send(JSON.stringify({ content: contentForLLM, display_content: userMsg, file_name: attachedFiles.map(f => f.name).join(', ') @@ -3350,7 +3499,17 @@ function AgentDetailInner() { placeholder={!wsConnected && (!activeSession?.user_id || !currentUser || activeSession.user_id === String(currentUser?.id)) ? 'Connecting...' : attachedFiles.length > 0 ? t('agent.chat.askAboutFile', { name: attachedFiles.length === 1 ? attachedFiles[0].name : `${attachedFiles.length} files` }) : t('chat.placeholder')} disabled={!wsConnected || isWaiting || isStreaming} style={{ flex: 1 }} autoFocus /> {(isStreaming || isWaiting) ? ( - ) : (