diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 871174a..3abe894 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -82,8 +82,6 @@ dependencies = [ "tauri-plugin-updater", "thiserror 1.0.69", "tokio", - "tokio-tungstenite", - "url", "winreg", ] @@ -763,12 +761,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "data-encoding" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" - [[package]] name = "deranged" version = "0.5.4" @@ -1694,12 +1686,12 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.32", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", - "webpki-roots 1.0.3", + "webpki-roots", ] [[package]] @@ -3120,7 +3112,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.32", + "rustls", "socket2", "thiserror 2.0.17", "tokio", @@ -3140,7 +3132,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.32", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.17", @@ -3405,14 +3397,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.32", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3422,7 +3414,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.3", + "webpki-roots", ] [[package]] @@ -3483,20 +3475,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" -dependencies = [ - "log", - "ring", - "rustls-pki-types", - "rustls-webpki 0.102.8", - "subtle", - "zeroize", -] - [[package]] name = "rustls" version = "0.23.32" @@ -3506,7 +3484,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.7", + "rustls-webpki", "subtle", "zeroize", ] @@ -3521,17 +3499,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.7" @@ -3819,17 +3786,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sha2" version = "0.10.9" @@ -4663,43 +4619,16 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "tokio-rustls" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" -dependencies = [ - "rustls 0.22.4", - "rustls-pki-types", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.32", + "rustls", "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" -dependencies = [ - "futures-util", - "log", - "rustls 0.22.4", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.25.0", - "tungstenite", - "webpki-roots 0.26.11", -] - [[package]] name = "tokio-util" version = "0.7.16" @@ -4913,27 +4842,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.8.5", - "rustls 0.22.4", - "rustls-pki-types", - "sha1", - "thiserror 1.0.69", - "url", - "utf-8", -] - [[package]] name = "typeid" version = "1.0.3" @@ -5294,15 +5202,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.3", -] - [[package]] name = "webpki-roots" version = "1.0.3" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index fcef075..6999835 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -29,7 +29,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] } get_if_addrs = "0.5" -reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false } +reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking", "stream"], default-features = false } futures-util = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } once_cell = "1.19" @@ -39,8 +39,7 @@ parking_lot = "0.12" hostname = "0.4" base64 = "0.22" sha2 = "0.10" -tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] } -url = "2.5" +# SSE usa reqwest com stream, nao precisa de websocket [target.'cfg(windows)'.dependencies] winreg = "0.55" diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 9b40825..c212a95 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -1,10 +1,10 @@ //! Modulo de Chat em Tempo Real //! //! Este modulo implementa o sistema de chat entre agentes (dashboard web) -//! e clientes (Raven desktop). Usa WebSocket como metodo +//! e clientes (Raven desktop). Usa Server-Sent Events (SSE) como metodo //! primario para atualizacoes em tempo real, com fallback para HTTP polling. -use futures_util::{StreamExt, SinkExt}; +use futures_util::StreamExt; use once_cell::sync::Lazy; use parking_lot::Mutex; use reqwest::Client; @@ -16,8 +16,6 @@ use std::time::Duration; use tauri::async_runtime::JoinHandle; use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl}; use tauri_plugin_notification::NotificationExt; -use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; -use url::Url; // ============================================================================ // TYPES @@ -401,22 +399,46 @@ pub async fn upload_file( } // ============================================================================ -// WebSocket TYPES +// SSE TYPES // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct WsUpdateEvent { +struct SseUpdateEvent { has_active_sessions: bool, sessions: Vec, total_unread: u32, ts: i64, } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct WsEnvelope { +/// Parser de eventos SSE +struct SseEvent { event: String, - data: serde_json::Value, + data: String, +} + +fn parse_sse_line(buffer: &mut String, line: &str) -> Option { + if line.starts_with("event:") { + buffer.clear(); + let event_type = line.trim_start_matches("event:").trim(); + buffer.push_str(event_type); + buffer.push('\0'); // Separador interno + None + } else if line.starts_with("data:") { + let data = line.trim_start_matches("data:").trim(); + let parts: Vec<&str> = buffer.split('\0').collect(); + let event_type = if parts.len() >= 1 && !parts[0].is_empty() { + parts[0].to_string() + } else { + "message".to_string() + }; + Some(SseEvent { + event: event_type, + data: data.to_string(), + }) + } else { + None + } } // ============================================================================ @@ -440,7 +462,7 @@ pub struct ChatRuntime { inner: Arc>>, last_sessions: Arc>>, last_unread_count: Arc>, - is_using_ws: Arc, + is_using_sse: Arc, } impl ChatRuntime { @@ -449,17 +471,17 @@ impl ChatRuntime { inner: Arc::new(Mutex::new(None)), last_sessions: Arc::new(Mutex::new(Vec::new())), last_unread_count: Arc::new(Mutex::new(0)), - is_using_ws: Arc::new(AtomicBool::new(false)), + is_using_sse: Arc::new(AtomicBool::new(false)), } } - /// Retorna true se esta usando WebSocket, false se usando polling HTTP - pub fn is_using_ws(&self) -> bool { - self.is_using_ws.load(Ordering::Relaxed) + /// Retorna true se esta usando SSE, false se usando polling HTTP + pub fn is_using_sse(&self) -> bool { + self.is_using_sse.load(Ordering::Relaxed) } /// Inicia o sistema de atualizacoes de chat. - /// Tenta WebSocket primeiro, com fallback automatico para HTTP polling. + /// Tenta SSE primeiro, com fallback automatico para HTTP polling. pub fn start_polling( &self, base_url: String, @@ -471,7 +493,7 @@ impl ChatRuntime { return Err("URL base invalida".to_string()); } - // Para polling/WS existente + // Para polling/SSE existente { let mut guard = self.inner.lock(); if let Some(handle) = guard.take() { @@ -485,12 +507,12 @@ impl ChatRuntime { let token_clone = token.clone(); let last_sessions = self.last_sessions.clone(); let last_unread_count = self.last_unread_count.clone(); - let is_using_ws = self.is_using_ws.clone(); + let is_using_sse = self.is_using_sse.clone(); let join_handle = tauri::async_runtime::spawn(async move { - crate::log_info!("Chat iniciando (tentando WebSocket primeiro)"); + crate::log_info!("Chat iniciando (tentando SSE primeiro)"); - // Loop principal com WebSocket + fallback para polling + // Loop principal com SSE + fallback para polling loop { // Verificar se deve parar if stop_clone.load(Ordering::Relaxed) { @@ -498,14 +520,14 @@ impl ChatRuntime { break; } - // Tentar WebSocket primeiro - let ws_result = run_ws_loop( + // Tentar SSE primeiro + let sse_result = run_sse_loop( &base_clone, &token_clone, &app, &last_sessions, &last_unread_count, - &is_using_ws, + &is_using_sse, &stop_clone, ) .await; @@ -516,16 +538,16 @@ impl ChatRuntime { break; } - match ws_result { + match sse_result { Ok(()) => { - // WS encerrado normalmente (stop signal) + // SSE encerrado normalmente (stop signal) break; } Err(e) => { - crate::log_warn!("WebSocket falhou: {e}. Usando polling HTTP..."); - is_using_ws.store(false, Ordering::Relaxed); + crate::log_warn!("SSE falhou: {e}. Usando polling HTTP..."); + is_using_sse.store(false, Ordering::Relaxed); - // Executar polling HTTP por 5 minutos, depois tentar WebSocket novamente + // Executar polling HTTP por 5 minutos, depois tentar SSE novamente let poll_duration = Duration::from_secs(300); // 5 minutos let poll_result = run_polling_loop( &base_clone, @@ -542,7 +564,7 @@ impl ChatRuntime { break; } - crate::log_info!("Tentando reconectar WebSocket..."); + crate::log_info!("Tentando reconectar SSE..."); } } } @@ -562,7 +584,7 @@ impl ChatRuntime { if let Some(handle) = guard.take() { handle.stop(); } - self.is_using_ws.store(false, Ordering::Relaxed); + self.is_using_sse.store(false, Ordering::Relaxed); } pub fn get_sessions(&self) -> Vec { @@ -571,111 +593,144 @@ impl ChatRuntime { } // ============================================================================ -// WS LOOP +// SSE LOOP // ============================================================================ -async fn run_ws_loop( +/// Cliente HTTP para SSE com timeout mais longo (conexao persistente) +static SSE_CLIENT: Lazy = Lazy::new(|| { + Client::builder() + .user_agent("raven-chat-sse/1.0") + .timeout(Duration::from_secs(120)) // Timeout longo para SSE + .connect_timeout(Duration::from_secs(15)) + .use_rustls_tls() + .build() + .expect("failed to build SSE http client") +}); + +async fn run_sse_loop( base_url: &str, token: &str, app: &tauri::AppHandle, last_sessions: &Arc>>, last_unread_count: &Arc>, - is_using_ws: &Arc, + is_using_sse: &Arc, stop_flag: &Arc, ) -> Result<(), String> { - let ws_url = build_ws_url(base_url, token)?; - crate::log_info!("Conectando WebSocket: {}", ws_url); + let sse_url = format!("{}/api/machines/chat/stream?token={}", base_url, token); + crate::log_info!("Conectando SSE: {}", sse_url); - let (ws_stream, _) = connect_async(ws_url) + // Iniciar request SSE + let response = SSE_CLIENT + .get(&sse_url) + .header("Accept", "text/event-stream") + .header("Cache-Control", "no-cache") + .send() .await - .map_err(|e| format!("Falha ao conectar WebSocket: {e}"))?; - let (mut write, mut read) = ws_stream.split(); + .map_err(|e| format!("Falha ao conectar SSE: {e}"))?; - // Ativar ping periódico para manter conexão viva - let mut heartbeat = tokio::time::interval(Duration::from_secs(45)); + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("SSE falhou: status={}, body={}", status, body)); + } - is_using_ws.store(true, Ordering::Relaxed); - crate::log_info!("WebSocket conectado com sucesso"); + is_using_sse.store(true, Ordering::Relaxed); + crate::log_info!("SSE conectado com sucesso"); + + // Stream de bytes + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + let mut line_buffer = String::new(); + + // Timeout para detectar conexao morta (60s sem dados = reconectar) + let mut last_data_time = std::time::Instant::now(); + let max_silence = Duration::from_secs(60); loop { if stop_flag.load(Ordering::Relaxed) { - crate::log_info!("WebSocket encerrado por stop flag"); - let _ = write.send(Message::Close(None)).await; + crate::log_info!("SSE encerrado por stop flag"); return Ok(()); } - tokio::select! { - _ = heartbeat.tick() => { - let _ = write.send(Message::Ping(Vec::new())).await; - } - msg = read.next() => { - match msg { - Some(Ok(Message::Text(text))) => { - if let Ok(parsed) = serde_json::from_str::(&text) { - handle_ws_event( - parsed, - base_url, - token, - app, - last_sessions, - last_unread_count, - ).await?; - } else { - crate::log_warn!("WebSocket: payload inválido"); - } + // Verificar timeout de silencio + if last_data_time.elapsed() > max_silence { + crate::log_warn!("SSE: timeout de silencio ({}s sem dados)", max_silence.as_secs()); + return Err("SSE timeout - sem dados".to_string()); + } + + // Aguardar proximo chunk com timeout + let chunk_result = tokio::time::timeout( + Duration::from_secs(35), // Heartbeat do servidor e a cada 30s + stream.next() + ).await; + + match chunk_result { + Ok(Some(Ok(bytes))) => { + last_data_time = std::time::Instant::now(); + + let text = String::from_utf8_lossy(&bytes); + line_buffer.push_str(&text); + + // Processar linhas completas + while let Some(newline_pos) = line_buffer.find('\n') { + let line = line_buffer[..newline_pos].trim_end_matches('\r').to_string(); + line_buffer = line_buffer[newline_pos + 1..].to_string(); + + // Linha vazia = fim do evento + if line.is_empty() { + buffer.clear(); + continue; } - Some(Ok(Message::Close(_))) => { - crate::log_info!("WebSocket: conexão encerrada pelo servidor"); - return Err("WebSocket fechado pelo servidor".to_string()); - } - Some(Ok(_)) => { - // Ignorar outros frames - } - Some(Err(e)) => { - crate::log_warn!("WebSocket erro: {e}"); - return Err(format!("Erro WebSocket: {e}")); - } - None => { - crate::log_info!("WebSocket: stream encerrado"); - return Err("WebSocket encerrado".to_string()); + + // Parsear evento SSE + if let Some(event) = parse_sse_line(&mut buffer, &line) { + handle_sse_event( + &event, + base_url, + token, + app, + last_sessions, + last_unread_count, + ).await?; } } } + Ok(Some(Err(e))) => { + crate::log_warn!("SSE erro de stream: {e}"); + return Err(format!("Erro SSE: {e}")); + } + Ok(None) => { + crate::log_info!("SSE: stream encerrado pelo servidor"); + return Err("SSE encerrado".to_string()); + } + Err(_) => { + // Timeout aguardando chunk - verificar se conexao ainda viva + if last_data_time.elapsed() > max_silence { + return Err("SSE timeout".to_string()); + } + // Caso contrario, continuar aguardando + } } } } -fn build_ws_url(base_url: &str, token: &str) -> Result { - let trimmed = base_url.trim_end_matches('/'); - let mut ws_url = if trimmed.starts_with("https://") { - trimmed.replacen("https://", "wss://", 1) - } else if trimmed.starts_with("http://") { - trimmed.replacen("http://", "ws://", 1) - } else { - format!("wss://{}", trimmed) - }; - ws_url.push_str("/chat-ws?token="); - ws_url.push_str(token); - Url::parse(&ws_url).map_err(|e| format!("URL WS inválida: {e}")) -} - -async fn handle_ws_event( - envelope: WsEnvelope, +async fn handle_sse_event( + event: &SseEvent, base_url: &str, token: &str, app: &tauri::AppHandle, last_sessions: &Arc>>, last_unread_count: &Arc>, ) -> Result<(), String> { - match envelope.event.as_str() { + match event.event.as_str() { "connected" => { - crate::log_info!("WebSocket: conectado"); + crate::log_info!("SSE: conectado"); } "heartbeat" => { - // noop + // noop - apenas mantem conexao viva } "update" => { - let update: WsUpdateEvent = serde_json::from_value(envelope.data) + let update: SseUpdateEvent = serde_json::from_str(&event.data) .map_err(|e| format!("Payload update inválido: {e}"))?; process_chat_update( base_url, @@ -689,15 +744,15 @@ async fn handle_ws_event( .await; } "error" => { - let message = envelope - .data + let error_data: Value = serde_json::from_str(&event.data).unwrap_or_default(); + let message = error_data .get("message") .and_then(Value::as_str) - .unwrap_or("Erro WebSocket"); + .unwrap_or("Erro SSE"); return Err(message.to_string()); } _ => { - crate::log_info!("WebSocket: evento desconhecido {}", envelope.event); + crate::log_info!("SSE: evento desconhecido {}", event.event); } } Ok(()) @@ -924,7 +979,8 @@ fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str) -> Result< (100.0, 100.0) }; - let url_path = format!("/chat?ticketId={}", ticket_id); + // Usar query param ao inves de path para compatibilidade com SPA + let url_path = format!("index.html?view=chat&ticketId={}", ticket_id); WebviewWindowBuilder::new( app, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1216a94..2db9072 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -245,8 +245,8 @@ fn stop_chat_polling(state: tauri::State) -> Result<(), String> { } #[tauri::command] -fn is_chat_using_ws(state: tauri::State) -> bool { - state.is_using_ws() +fn is_chat_using_realtime(state: tauri::State) -> bool { + state.is_using_sse() } #[tauri::command] @@ -492,7 +492,7 @@ pub fn run() { // Chat commands start_chat_polling, stop_chat_polling, - is_chat_using_ws, + is_chat_using_realtime, get_chat_sessions, fetch_chat_sessions, fetch_chat_messages, @@ -632,7 +632,7 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> { async fn try_start_background_agent( app: &tauri::AppHandle, agent_runtime: AgentRuntime, - _chat_runtime: ChatRuntime, + chat_runtime: ChatRuntime, ) -> Result<(), String> { log_info!("Verificando credenciais salvas para iniciar agente..."); @@ -687,7 +687,18 @@ async fn try_start_background_agent( ) .map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?; - log_info!("Agente iniciado com sucesso em background (chat via Convex WebSocket no frontend)"); + // Iniciar sistema de chat (WebSocket + fallback HTTP polling) + if let Err(e) = chat_runtime.start_polling( + api_base_url.to_string(), + token.to_string(), + app.clone(), + ) { + log_warn!("Falha ao iniciar chat em background: {e}"); + } else { + log_info!("Chat iniciado com sucesso (WebSocket + fallback polling)"); + } + + log_info!("Agente iniciado com sucesso em background"); Ok(()) } diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index 1306b9b..99330f7 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -50,7 +50,8 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null) const [hasSession, setHasSession] = useState(false) const [pendingAttachments, setPendingAttachments] = useState([]) - const [isMinimized, setIsMinimized] = useState(false) + // Inicializa minimizado porque o Rust abre a janela e minimiza imediatamente + const [isMinimized, setIsMinimized] = useState(true) const [unreadCount, setUnreadCount] = useState(0) const messagesEndRef = useRef(null) @@ -235,18 +236,26 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { } if (isLoading) { + // Mostrar chip compacto enquanto carrega (compativel com janela minimizada) + // pointer-events-none no container para que a area transparente nao seja clicavel return ( -
- -

Carregando chat...

+
+
+ + Carregando... +
) } if (error) { + // Mostrar chip compacto de erro (compativel com janela minimizada) return ( -
-

{error}

+
+
+ + Erro no chat +
) } @@ -254,8 +263,8 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { // Quando não há sessão, mostrar versão minimizada com indicador de offline if (!hasSession) { return ( -
-
+
+
{ticketInfo ? `Chat #${ticketInfo.ref}` : "Chat"} @@ -268,12 +277,13 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { } // Versão minimizada (chip compacto igual web) + // pointer-events-none no container para que apenas o botao seja clicavel if (isMinimized) { return ( -
+