From a8f5ff9d513d19e73903e27bc64c1b42ce7cfb7a Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Tue, 9 Dec 2025 01:01:54 -0300 Subject: [PATCH] feat(chat): desktop usando Convex WS direto e fallback WS dedicado --- .env.example | 2 + apps/desktop/src-tauri/Cargo.lock | 171 ++++++++----- apps/desktop/src-tauri/Cargo.toml | 3 +- apps/desktop/src-tauri/src/chat.rs | 208 ++++++++++------ apps/desktop/src-tauri/src/lib.rs | 21 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src/chat/ChatWidget.tsx | 223 ++++------------- apps/desktop/src/chat/convexMachineClient.ts | 180 ++++++++++++++ .../src/components/ChatFloatingWidget.tsx | 229 ++++++++---------- bun.lock | 1 + package.json | 1 + scripts/chat-ws-server.mjs | 130 ++++++++++ scripts/start-web.sh | 14 ++ stack.yml | 8 + 14 files changed, 735 insertions(+), 458 deletions(-) create mode 100644 apps/desktop/src/chat/convexMachineClient.ts create mode 100644 scripts/chat-ws-server.mjs diff --git a/.env.example b/.env.example index 3bd8cbc..aa7b291 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,8 @@ NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210 CONVEX_INTERNAL_URL=http://127.0.0.1:3210 # Intervalo (ms) para aceitar token revogado ao sincronizar acessos remotos (opcional) REMOTE_ACCESS_TOKEN_GRACE_MS=900000 +# Porta do servidor WebSocket de chat (processo dedicado iniciado no container) +CHAT_WS_PORT=3030 # SQLite database (local dev) DATABASE_URL=file:./prisma/db.dev.sqlite diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index e19700e..871174a 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -68,7 +68,6 @@ dependencies = [ "once_cell", "parking_lot", "reqwest", - "reqwest-eventsource", "serde", "serde_json", "sha2", @@ -83,6 +82,8 @@ dependencies = [ "tauri-plugin-updater", "thiserror 1.0.69", "tokio", + "tokio-tungstenite", + "url", "winreg", ] @@ -762,6 +763,12 @@ 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" @@ -1023,17 +1030,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "eventsource-stream" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" -dependencies = [ - "futures-core", - "nom", - "pin-project-lite", -] - [[package]] name = "fastrand" version = "2.3.0" @@ -1208,12 +1204,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - [[package]] name = "futures-util" version = "0.3.31" @@ -1704,12 +1694,12 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.32", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", - "webpki-roots", + "webpki-roots 1.0.3", ] [[package]] @@ -2227,12 +2217,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "minisign-verify" version = "0.2.4" @@ -2336,16 +2320,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "notify-rust" version = "4.11.7" @@ -3146,7 +3120,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.32", "socket2", "thiserror 2.0.17", "tokio", @@ -3166,7 +3140,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls", + "rustls 0.23.32", "rustls-pki-types", "slab", "thiserror 2.0.17", @@ -3431,14 +3405,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.32", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -3448,23 +3422,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", -] - -[[package]] -name = "reqwest-eventsource" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde" -dependencies = [ - "eventsource-stream", - "futures-core", - "futures-timer", - "mime", - "nom", - "pin-project-lite", - "reqwest", - "thiserror 1.0.69", + "webpki-roots 1.0.3", ] [[package]] @@ -3525,6 +3483,20 @@ 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" @@ -3534,7 +3506,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.7", "subtle", "zeroize", ] @@ -3549,6 +3521,17 @@ 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" @@ -3836,6 +3819,17 @@ 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" @@ -4669,16 +4663,43 @@ 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", + "rustls 0.23.32", "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" @@ -4892,6 +4913,27 @@ 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" @@ -5252,6 +5294,15 @@ 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 ccb98f0..fcef075 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -30,7 +30,6 @@ 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-eventsource = "0.6" futures-util = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } once_cell = "1.19" @@ -40,6 +39,8 @@ 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" [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 1a41721..9b40825 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -1,21 +1,23 @@ //! Modulo de Chat em Tempo Real //! //! Este modulo implementa o sistema de chat entre agentes (dashboard web) -//! e clientes (Raven desktop). Usa SSE (Server-Sent Events) como metodo +//! e clientes (Raven desktop). Usa WebSocket como metodo //! primario para atualizacoes em tempo real, com fallback para HTTP polling. -use futures_util::StreamExt; +use futures_util::{StreamExt, SinkExt}; use once_cell::sync::Lazy; use parking_lot::Mutex; use reqwest::Client; -use reqwest_eventsource::{Event, EventSource}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; 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 @@ -399,18 +401,24 @@ pub async fn upload_file( } // ============================================================================ -// SSE (Server-Sent Events) TYPES +// WebSocket TYPES // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct SseUpdateEvent { +struct WsUpdateEvent { has_active_sessions: bool, sessions: Vec, total_unread: u32, ts: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WsEnvelope { + event: String, + data: serde_json::Value, +} + // ============================================================================ // CHAT RUNTIME // ============================================================================ @@ -432,7 +440,7 @@ pub struct ChatRuntime { inner: Arc>>, last_sessions: Arc>>, last_unread_count: Arc>, - is_using_sse: Arc, + is_using_ws: Arc, } impl ChatRuntime { @@ -441,17 +449,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_sse: Arc::new(AtomicBool::new(false)), + is_using_ws: Arc::new(AtomicBool::new(false)), } } - /// 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) + /// 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) } /// Inicia o sistema de atualizacoes de chat. - /// Tenta SSE primeiro, com fallback automatico para HTTP polling. + /// Tenta WebSocket primeiro, com fallback automatico para HTTP polling. pub fn start_polling( &self, base_url: String, @@ -463,7 +471,7 @@ impl ChatRuntime { return Err("URL base invalida".to_string()); } - // Para polling/SSE existente + // Para polling/WS existente { let mut guard = self.inner.lock(); if let Some(handle) = guard.take() { @@ -477,12 +485,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_sse = self.is_using_sse.clone(); + let is_using_ws = self.is_using_ws.clone(); let join_handle = tauri::async_runtime::spawn(async move { - crate::log_info!("Chat iniciando (tentando SSE primeiro)"); + crate::log_info!("Chat iniciando (tentando WebSocket primeiro)"); - // Loop principal com SSE + fallback para polling + // Loop principal com WebSocket + fallback para polling loop { // Verificar se deve parar if stop_clone.load(Ordering::Relaxed) { @@ -490,14 +498,14 @@ impl ChatRuntime { break; } - // Tentar SSE primeiro - let sse_result = run_sse_loop( + // Tentar WebSocket primeiro + let ws_result = run_ws_loop( &base_clone, &token_clone, &app, &last_sessions, &last_unread_count, - &is_using_sse, + &is_using_ws, &stop_clone, ) .await; @@ -508,16 +516,16 @@ impl ChatRuntime { break; } - match sse_result { + match ws_result { Ok(()) => { - // SSE encerrado normalmente (stop signal) + // WS encerrado normalmente (stop signal) break; } Err(e) => { - crate::log_warn!("SSE falhou: {e}. Usando polling HTTP..."); - is_using_sse.store(false, Ordering::Relaxed); + crate::log_warn!("WebSocket falhou: {e}. Usando polling HTTP..."); + is_using_ws.store(false, Ordering::Relaxed); - // Executar polling HTTP por 5 minutos, depois tentar SSE novamente + // Executar polling HTTP por 5 minutos, depois tentar WebSocket novamente let poll_duration = Duration::from_secs(300); // 5 minutos let poll_result = run_polling_loop( &base_clone, @@ -534,7 +542,7 @@ impl ChatRuntime { break; } - crate::log_info!("Tentando reconectar SSE..."); + crate::log_info!("Tentando reconectar WebSocket..."); } } } @@ -554,7 +562,7 @@ impl ChatRuntime { if let Some(handle) = guard.take() { handle.stop(); } - self.is_using_sse.store(false, Ordering::Relaxed); + self.is_using_ws.store(false, Ordering::Relaxed); } pub fn get_sessions(&self) -> Vec { @@ -563,90 +571,138 @@ impl ChatRuntime { } // ============================================================================ -// SSE LOOP +// WS LOOP // ============================================================================ -async fn run_sse_loop( +async fn run_ws_loop( base_url: &str, token: &str, app: &tauri::AppHandle, last_sessions: &Arc>>, last_unread_count: &Arc>, - is_using_sse: &Arc, + is_using_ws: &Arc, stop_flag: &Arc, ) -> Result<(), String> { - let sse_url = format!("{}/api/machines/chat/stream?token={}", base_url, token); - crate::log_info!("Conectando SSE: {}", sse_url); + let ws_url = build_ws_url(base_url, token)?; + crate::log_info!("Conectando WebSocket: {}", ws_url); - let request = CHAT_CLIENT.get(&sse_url); - let mut es = EventSource::new(request).map_err(|e| format!("Falha ao criar EventSource: {e}"))?; + let (ws_stream, _) = connect_async(ws_url) + .await + .map_err(|e| format!("Falha ao conectar WebSocket: {e}"))?; + let (mut write, mut read) = ws_stream.split(); - is_using_sse.store(true, Ordering::Relaxed); - crate::log_info!("SSE conectado com sucesso"); + // Ativar ping periódico para manter conexão viva + let mut heartbeat = tokio::time::interval(Duration::from_secs(45)); + + is_using_ws.store(true, Ordering::Relaxed); + crate::log_info!("WebSocket conectado com sucesso"); loop { - // Verificar stop flag periodicamente if stop_flag.load(Ordering::Relaxed) { - crate::log_info!("SSE encerrado por stop flag"); + crate::log_info!("WebSocket encerrado por stop flag"); + let _ = write.send(Message::Close(None)).await; return Ok(()); } - // Usar timeout para poder verificar stop flag - let event = tokio::time::timeout(Duration::from_secs(1), es.next()).await; - - match event { - Ok(Some(Ok(Event::Open))) => { - crate::log_info!("SSE: conexao aberta"); + tokio::select! { + _ = heartbeat.tick() => { + let _ = write.send(Message::Ping(Vec::new())).await; } - Ok(Some(Ok(Event::Message(msg)))) => { - let event_type = msg.event.as_str(); - - match event_type { - "connected" => { - crate::log_info!("SSE: evento connected recebido"); - } - "heartbeat" => { - // Ignorar heartbeats silenciosamente - } - "update" => { - // Processar update de chat - if let Ok(update) = serde_json::from_str::(&msg.data) { - process_chat_update( + 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, - update.has_active_sessions, - update.total_unread, - ) - .await; + ).await?; + } else { + crate::log_warn!("WebSocket: payload inválido"); } } - "error" => { - crate::log_warn!("SSE: erro recebido do servidor: {}", msg.data); - return Err(format!("Erro SSE do servidor: {}", msg.data)); + Some(Ok(Message::Close(_))) => { + crate::log_info!("WebSocket: conexão encerrada pelo servidor"); + return Err("WebSocket fechado pelo servidor".to_string()); } - _ => { - crate::log_info!("SSE: evento desconhecido: {}", event_type); + 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()); } } } - Ok(Some(Err(e))) => { - crate::log_warn!("SSE erro: {e}"); - return Err(format!("Erro SSE: {e}")); - } - Ok(None) => { - crate::log_info!("SSE: stream encerrado"); - return Err("Stream SSE encerrado".to_string()); - } - Err(_) => { - // Timeout - continuar loop para verificar stop flag - } } } } +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, + base_url: &str, + token: &str, + app: &tauri::AppHandle, + last_sessions: &Arc>>, + last_unread_count: &Arc>, +) -> Result<(), String> { + match envelope.event.as_str() { + "connected" => { + crate::log_info!("WebSocket: conectado"); + } + "heartbeat" => { + // noop + } + "update" => { + let update: WsUpdateEvent = serde_json::from_value(envelope.data) + .map_err(|e| format!("Payload update inválido: {e}"))?; + process_chat_update( + base_url, + token, + app, + last_sessions, + last_unread_count, + update.has_active_sessions, + update.total_unread, + ) + .await; + } + "error" => { + let message = envelope + .data + .get("message") + .and_then(Value::as_str) + .unwrap_or("Erro WebSocket"); + return Err(message.to_string()); + } + _ => { + crate::log_info!("WebSocket: evento desconhecido {}", envelope.event); + } + } + Ok(()) +} + // ============================================================================ // HTTP POLLING LOOP (FALLBACK) // ============================================================================ diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index baff18e..1216a94 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_sse(state: tauri::State) -> bool { - state.is_using_sse() +fn is_chat_using_ws(state: tauri::State) -> bool { + state.is_using_ws() } #[tauri::command] @@ -492,7 +492,7 @@ pub fn run() { // Chat commands start_chat_polling, stop_chat_polling, - is_chat_using_sse, + is_chat_using_ws, 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,18 +687,7 @@ async fn try_start_background_agent( ) .map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?; - log_info!("Agente iniciado com sucesso em background"); - - // Iniciar chat 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 polling: {e}"); - } else { - log_info!("Chat polling iniciado com sucesso"); - } + log_info!("Agente iniciado com sucesso em background (chat via Convex WebSocket no frontend)"); Ok(()) } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 7703ac6..e224e32 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -32,7 +32,7 @@ ], "dialog": true, "active": true, - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5MTMxRTQwODA1NEFCRjAKUldUd3ExU0FRQjRUR2VqcHBNdXhBMUV3WlM2cFA4dmNnNEhtMUJ2a3VVWVlTQnoxbEo5YUtlUTMK" + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZDRTBFNkY1NUQ3QzU0QkEKUldTNlZIeGQ5ZWJnYk5mY0J4aWRlb0dRdVZ4TGpBSUZXMnRVUFhmdmlLT0tlY084UjJQUHFWWUkK" }, "deep-link": { "desktop": { diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index 9958919..1306b9b 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -1,13 +1,15 @@ import { useCallback, useEffect, useRef, useState } from "react" -import { invoke } from "@tauri-apps/api/core" -import { listen } from "@tauri-apps/api/event" -import { Store } from "@tauri-apps/plugin-store" -import { appLocalDataDir, join } from "@tauri-apps/api/path" import { open } from "@tauri-apps/plugin-dialog" +import { invoke } from "@tauri-apps/api/core" import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react" -import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types" +import type { ChatMessage } from "./types" +import { + subscribeMachineMessages, + sendMachineMessage, + markMachineMessagesRead, + getMachineStoreConfig, +} from "./convexMachineClient" -const STORE_FILENAME = "machine-agent.json" const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak // Tipos de arquivo permitidos @@ -52,8 +54,7 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { const [unreadCount, setUnreadCount] = useState(0) const messagesEndRef = useRef(null) - const lastFetchRef = useRef(0) - const pollIntervalRef = useRef | null>(null) + const messagesSubRef = useRef<(() => void) | null>(null) const hadSessionRef = useRef(false) // Scroll para o final quando novas mensagens chegam @@ -77,153 +78,48 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { hadSessionRef.current = hasSession }, [hasSession, ticketId]) - // Carregar configuracao do store - const loadConfig = useCallback(async () => { - try { - const appData = await appLocalDataDir() - const storePath = await join(appData, STORE_FILENAME) - const store = await Store.load(storePath) - const token = await store.get("token") - const config = await store.get<{ apiBaseUrl: string }>("config") - - if (!token || !config?.apiBaseUrl) { - setError("Máquina não registrada") - setIsLoading(false) - return null - } - - return { token, baseUrl: config.apiBaseUrl } - } catch (err) { - setError("Erro ao carregar configuracao") - setIsLoading(false) - return null - } - }, []) - - // Buscar mensagens - const fetchMessages = useCallback(async (baseUrl: string, token: string, since?: number) => { - try { - const response = await invoke("fetch_chat_messages", { - baseUrl, - token, - ticketId, - since: since ?? null, - }) - - setHasSession(response.hasSession) - - if (response.messages.length > 0) { - if (since) { - // Adicionar apenas novas mensagens (com limite para evitar memory leak) - setMessages(prev => { - const existingIds = new Set(prev.map(m => m.id)) - const newMsgs = response.messages.filter(m => !existingIds.has(m.id)) - const combined = [...prev, ...newMsgs] - // Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens - return combined.slice(-MAX_MESSAGES_IN_MEMORY) - }) - } else { - // Primeira carga (já limitada) - setMessages(response.messages.slice(-MAX_MESSAGES_IN_MEMORY)) - } - lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt)) - } - - return response - } catch (err) { - console.error("Erro ao buscar mensagens:", err) - return null - } - }, [ticketId]) - - // Buscar info da sessao - const fetchSessionInfo = useCallback(async (baseUrl: string, token: string) => { - try { - const sessions = await invoke>("fetch_chat_sessions", { baseUrl, token }) - - const session = sessions.find(s => s.ticketId === ticketId) - if (session) { - setTicketInfo({ - ref: session.ticketRef, - subject: session.ticketSubject, - agentName: session.agentName, - }) - } - } catch (err) { - console.error("Erro ao buscar sessao:", err) - } - }, [ticketId]) - - // Inicializacao + // Inicializacao via Convex (WS) useEffect(() => { - let mounted = true + setIsLoading(true) + setMessages([]) + messagesSubRef.current?.() - const init = async () => { - const config = await loadConfig() - if (!config || !mounted) return - - const { baseUrl, token } = config - - // Buscar sessao e mensagens iniciais - await Promise.all([ - fetchSessionInfo(baseUrl, token), - fetchMessages(baseUrl, token), - ]) - - if (!mounted) return - setIsLoading(false) - - // Iniciar polling (2 segundos para maior responsividade) - pollIntervalRef.current = setInterval(async () => { - await fetchMessages(baseUrl, token, lastFetchRef.current) - }, 2000) - } - - init() - - // Listener para eventos de nova mensagem do Tauri - const unlistenNewMessage = listen<{ ticketId: string; message: ChatMessage }>( - "raven://chat/new-message", - (event) => { - if (event.payload.ticketId === ticketId) { - setMessages(prev => { - if (prev.some(m => m.id === event.payload.message.id)) { - return prev - } - const combined = [...prev, event.payload.message] - // Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens - return combined.slice(-MAX_MESSAGES_IN_MEMORY) - }) + subscribeMachineMessages( + ticketId, + (payload) => { + setIsLoading(false) + setHasSession(payload.hasSession) + hadSessionRef.current = hadSessionRef.current || payload.hasSession + const unread = payload.messages.filter(m => !m.isFromMachine).length + setUnreadCount(unread) + setMessages(prev => { + const existingIds = new Set(prev.map(m => m.id)) + const combined = [...prev, ...payload.messages.filter(m => !existingIds.has(m.id))] + return combined.slice(-MAX_MESSAGES_IN_MEMORY) + }) + // Atualiza info basica do ticket + if (payload.messages.length > 0) { + const first = payload.messages[0] + setTicketInfo((prevInfo) => prevInfo ?? { ref: 0, subject: "", agentName: first.authorName ?? "Suporte" }) } - } - ) - - // Listener para atualização de mensagens não lidas - const unlistenUnread = listen<{ totalUnread: number; sessions: Array<{ ticketId: string; unreadCount: number }> }>( - "raven://chat/unread-update", - (event) => { - // Encontrar o unread count para este ticket - const session = event.payload.sessions?.find(s => s.ticketId === ticketId) - if (session) { - setUnreadCount(session.unreadCount ?? 0) + const unreadIds = payload.messages.filter(m => !m.isFromMachine).map(m => m.id as string) + if (unreadIds.length > 0) { + markMachineMessagesRead(ticketId, unreadIds).catch(err => console.error("mark read falhou", err)) } + }, + (err) => { + setIsLoading(false) + setError(err.message || "Erro ao carregar mensagens.") } - ) + ).then((unsub) => { + messagesSubRef.current = unsub + }) return () => { - mounted = false - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current) - } - unlistenNewMessage.then(unlisten => unlisten()) - unlistenUnread.then(unlisten => unlisten()) + messagesSubRef.current?.() + messagesSubRef.current = null } - }, [ticketId, loadConfig, fetchMessages, fetchSessionInfo]) + }, [ticketId]) // Selecionar arquivo para anexar const handleAttach = async () => { @@ -245,14 +141,10 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { setIsUploading(true) - const config = await loadConfig() - if (!config) { - setIsUploading(false) - return - } + const config = await getMachineStoreConfig() const attachment = await invoke("upload_chat_file", { - baseUrl: config.baseUrl, + baseUrl: config.apiBaseUrl, token: config.token, filePath, }) @@ -282,29 +174,20 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { setIsSending(true) try { - const config = await loadConfig() - if (!config) { - setIsSending(false) - setInputValue(messageText) - setPendingAttachments(attachmentsToSend) - return - } - - const response = await invoke("send_chat_message", { - baseUrl: config.baseUrl, - token: config.token, + const bodyToSend = messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : "") + await sendMachineMessage({ ticketId, - body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""), - attachments: attachmentsToSend.length > 0 ? attachmentsToSend : null, + body: bodyToSend, + attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined, }) // Adicionar mensagem localmente setMessages(prev => [...prev, { - id: response.messageId, - body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""), + id: crypto.randomUUID(), + body: bodyToSend, authorName: "Você", isFromMachine: true, - createdAt: response.createdAt, + createdAt: Date.now(), attachments: attachmentsToSend.map(a => ({ storageId: a.storageId, name: a.name, @@ -312,8 +195,6 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { type: a.type, })), }]) - - lastFetchRef.current = response.createdAt } catch (err) { console.error("Erro ao enviar mensagem:", err) // Restaurar input e anexos em caso de erro diff --git a/apps/desktop/src/chat/convexMachineClient.ts b/apps/desktop/src/chat/convexMachineClient.ts new file mode 100644 index 0000000..ae4870a --- /dev/null +++ b/apps/desktop/src/chat/convexMachineClient.ts @@ -0,0 +1,180 @@ +import { ConvexClient } from "convex/browser" +import { Store } from "@tauri-apps/plugin-store" +import { appLocalDataDir, join } from "@tauri-apps/api/path" +import type { ChatMessage } from "./types" + +const STORE_FILENAME = "machine-agent.json" +const DEFAULT_CONVEX_URL = + import.meta.env.VITE_CONVEX_URL?.trim() || + "https://convex.esdrasrenan.com.br" + +type MachineStoreConfig = { + apiBaseUrl?: string + appUrl?: string + convexUrl?: string +} + +type MachineStoreData = { + token?: string + config?: MachineStoreConfig +} + +type ClientCache = { + client: ConvexClient + token: string + convexUrl: string +} + +let cached: ClientCache | null = null + +type MachineUpdatePayload = { + hasActiveSessions: boolean + sessions: Array<{ ticketId: string; unreadCount: number; lastActivityAt: number }> + totalUnread: number +} + +async function loadStore(): Promise { + const appData = await appLocalDataDir() + const storePath = await join(appData, STORE_FILENAME) + const store = await Store.load(storePath) + const token = await store.get("token") + const config = await store.get("config") + return { token: token ?? undefined, config: config ?? undefined } +} + +function resolveConvexUrl(config?: MachineStoreConfig): string { + const fromConfig = config?.convexUrl?.trim() + if (fromConfig) return fromConfig.replace(/\/+$/, "") + return DEFAULT_CONVEX_URL +} + +function resolveApiBaseUrl(config?: MachineStoreConfig): string { + const fromConfig = config?.apiBaseUrl?.trim() + if (fromConfig) return fromConfig.replace(/\/+$/, "") + return "https://tickets.esdrasrenan.com.br" +} + +export async function getMachineStoreConfig() { + const data = await loadStore() + if (!data.token) { + throw new Error("Token de máquina não encontrado no store") + } + const apiBaseUrl = resolveApiBaseUrl(data.config) + const appUrl = data.config?.appUrl?.trim() || apiBaseUrl + return { token: data.token, apiBaseUrl, appUrl, convexUrl: resolveConvexUrl(data.config) } +} + +async function ensureClient(): Promise { + const data = await loadStore() + if (!data.token) { + throw new Error("Token de máquina não encontrado no store") + } + const convexUrl = resolveConvexUrl(data.config) + + if (cached && cached.token === data.token && cached.convexUrl === convexUrl) { + return cached + } + + const client = new ConvexClient(convexUrl) + cached = { client, token: data.token, convexUrl } + return cached +} + +export async function subscribeMachineUpdates( + callback: (payload: MachineUpdatePayload) => void, + onError?: (error: Error) => void +): Promise<() => void> { + const { client, token } = await ensureClient() + const sub = client.onUpdate( + FN_CHECK_UPDATES as any, + { machineToken: token }, + (value) => callback(value), + onError + ) + return () => sub.unsubscribe() +} + +export async function subscribeMachineMessages( + ticketId: string, + callback: (payload: { messages: ChatMessage[]; hasSession: boolean }) => void, + onError?: (error: Error) => void +): Promise<() => void> { + const { client, token } = await ensureClient() + const sub = client.onUpdate( + FN_LIST_MESSAGES as any, + { + machineToken: token, + ticketId, + }, + (value) => callback(value), + onError + ) + return () => sub.unsubscribe() +} + +export async function sendMachineMessage(input: { + ticketId: string + body: string + attachments?: Array<{ + storageId: string + name: string + size?: number + type?: string + }> +}) { + const { client, token } = await ensureClient() + return client.mutation(FN_POST_MESSAGE as any, { + machineToken: token, + ticketId: input.ticketId, + body: input.body, + attachments: input.attachments?.map((att) => ({ + storageId: att.storageId, + name: att.name, + size: att.size, + type: att.type, + })), + }) +} + +export async function markMachineMessagesRead(ticketId: string, messageIds: string[]) { + if (messageIds.length === 0) return + const { client, token } = await ensureClient() + await client.mutation(FN_MARK_READ as any, { + machineToken: token, + ticketId, + messageIds, + }) +} + +export async function generateMachineUploadUrl(opts: { + fileName: string + fileType: string + fileSize: number +}) { + const { client, token } = await ensureClient() + return client.action(FN_UPLOAD_URL as any, { + machineToken: token, + fileName: opts.fileName, + fileType: opts.fileType, + fileSize: opts.fileSize, + }) +} + +export async function uploadToConvexStorage(uploadUrl: string, file: Blob | ArrayBuffer, contentType: string) { + const response = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": contentType }, + body: file, + }) + if (!response.ok) { + const body = await response.text() + throw new Error(`Upload falhou: ${response.status} ${body}`) + } + const json = await response.json().catch(() => ({})) + return json.storageId || json.storage_id +} +const FN_CHECK_UPDATES = "liveChat.checkMachineUpdates" +const FN_LIST_MESSAGES = "liveChat.listMachineMessages" +const FN_POST_MESSAGE = "liveChat.postMachineMessage" +const FN_MARK_READ = "liveChat.markMachineMessagesRead" +const FN_UPLOAD_URL = "liveChat.generateMachineUploadUrl" diff --git a/apps/desktop/src/components/ChatFloatingWidget.tsx b/apps/desktop/src/components/ChatFloatingWidget.tsx index 84161c1..d2a42d5 100644 --- a/apps/desktop/src/components/ChatFloatingWidget.tsx +++ b/apps/desktop/src/components/ChatFloatingWidget.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useRef, useState } from "react" -import { invoke } from "@tauri-apps/api/core" -import { Store } from "@tauri-apps/plugin-store" -import { appLocalDataDir, join } from "@tauri-apps/api/path" import { MessageCircle, X, Minus, Send, Loader2, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react" import { cn } from "../lib/utils" -import type { ChatSession, ChatMessage, ChatMessagesResponse, SendMessageResponse, ChatHistorySession } from "../chat/types" - -const STORE_FILENAME = "machine-agent.json" +import type { ChatSession, ChatMessage, ChatHistorySession } from "../chat/types" +import { + subscribeMachineUpdates, + subscribeMachineMessages, + sendMachineMessage, + markMachineMessagesRead, +} from "../chat/convexMachineClient" interface ChatFloatingWidgetProps { sessions: ChatSession[] @@ -30,19 +31,22 @@ export function ChatFloatingWidget({ const [isSending, setIsSending] = useState(false) const [historyExpanded, setHistoryExpanded] = useState(false) const [historySessions] = useState([]) + const [liveSessions, setLiveSessions] = useState(sessions) + const [liveUnread, setLiveUnread] = useState(totalUnread) + const sessionList = liveSessions.length > 0 ? liveSessions : sessions const messagesEndRef = useRef(null) - const lastFetchRef = useRef(0) - const pollIntervalRef = useRef | null>(null) + const messagesSubRef = useRef<(() => void) | null>(null) + const updatesSubRef = useRef<(() => void) | null>(null) // Selecionar ticket mais recente automaticamente useEffect(() => { - if (sessions.length > 0 && !selectedTicketId) { - // Ordenar por lastActivityAt e pegar o mais recente - const sorted = [...sessions].sort((a, b) => b.lastActivityAt - a.lastActivityAt) + const source = liveSessions.length > 0 ? liveSessions : sessions + if (source.length > 0 && !selectedTicketId) { + const sorted = [...source].sort((a, b) => b.lastActivityAt - a.lastActivityAt) setSelectedTicketId(sorted[0].ticketId) } - }, [sessions, selectedTicketId]) + }, [sessions, liveSessions, selectedTicketId]) // Scroll para o final quando novas mensagens chegam const scrollToBottom = useCallback(() => { @@ -53,99 +57,73 @@ export function ChatFloatingWidget({ scrollToBottom() }, [messages, scrollToBottom]) - // Carregar configuracao do store - const loadConfig = useCallback(async () => { - try { - const appData = await appLocalDataDir() - const storePath = await join(appData, STORE_FILENAME) - const store = await Store.load(storePath) - const token = await store.get("token") - const config = await store.get<{ apiBaseUrl: string }>("config") - - if (!token || !config?.apiBaseUrl) { - return null - } - - return { token, baseUrl: config.apiBaseUrl } - } catch { - return null - } - }, []) - - // Buscar mensagens - const fetchMessages = useCallback(async (baseUrl: string, token: string, ticketId: string, since?: number) => { - try { - const response = await invoke("fetch_chat_messages", { - baseUrl, - token, - ticketId, - since: since ?? null, - }) - - if (response.messages.length > 0) { - if (since) { - setMessages(prev => { - const existingIds = new Set(prev.map(m => m.id)) - const newMsgs = response.messages.filter(m => !existingIds.has(m.id)) - return [...prev, ...newMsgs] - }) - } else { - setMessages(response.messages) - } - lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt)) - } - - return response - } catch (err) { - console.error("Erro ao buscar mensagens:", err) - return null - } - }, []) - - // Inicializar e fazer polling quando ticket selecionado + // Assinar updates de sessões/unread useEffect(() => { - if (!selectedTicketId || !isOpen) return - - let mounted = true - - const init = async () => { - setIsLoading(true) - const config = await loadConfig() - if (!config || !mounted) { - setIsLoading(false) - return - } - - const { baseUrl, token } = config - - // Buscar mensagens iniciais - await fetchMessages(baseUrl, token, selectedTicketId) - - if (!mounted) return - setIsLoading(false) - - // Iniciar polling (2 segundos) - pollIntervalRef.current = setInterval(async () => { - await fetchMessages(baseUrl, token, selectedTicketId, lastFetchRef.current) - }, 2000) - } - - init() + let cancelled = false + subscribeMachineUpdates( + (payload) => { + if (cancelled) return + const mapped: ChatSession[] = (payload.sessions ?? []).map((s) => ({ + sessionId: s.ticketId, + ticketId: s.ticketId, + ticketRef: 0, + ticketSubject: "", + agentName: "", + agentEmail: undefined, + agentAvatarUrl: undefined, + unreadCount: s.unreadCount, + lastActivityAt: s.lastActivityAt, + startedAt: 0, + })) + setLiveSessions(mapped) + setLiveUnread(payload.totalUnread ?? 0) + }, + (err) => console.error("chat updates erro:", err) + ).then((unsub) => { + updatesSubRef.current = unsub + }) return () => { - mounted = false - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current) - pollIntervalRef.current = null - } + cancelled = true + updatesSubRef.current?.() + updatesSubRef.current = null } - }, [selectedTicketId, isOpen, loadConfig, fetchMessages]) + }, []) - // Limpar mensagens quando trocar de ticket + // Assinar mensagens do ticket selecionado useEffect(() => { + if (!selectedTicketId || !isOpen) return + messagesSubRef.current?.() setMessages([]) - lastFetchRef.current = 0 - }, [selectedTicketId]) + setIsLoading(true) + + subscribeMachineMessages( + selectedTicketId, + (payload) => { + setIsLoading(false) + setMessages(payload.messages) + const unreadIds = payload.messages + .filter((m) => !m.isFromMachine) + .map((m) => m.id as string) + if (unreadIds.length) { + markMachineMessagesRead(selectedTicketId, unreadIds).catch((err) => + console.error("mark read falhou", err) + ) + } + }, + (err) => { + setIsLoading(false) + console.error("chat messages erro:", err) + } + ).then((unsub) => { + messagesSubRef.current = unsub + }) + + return () => { + messagesSubRef.current?.() + messagesSubRef.current = null + } + }, [selectedTicketId, isOpen]) // Enviar mensagem const handleSend = async () => { @@ -156,29 +134,15 @@ export function ChatFloatingWidget({ setIsSending(true) try { - const config = await loadConfig() - if (!config) { - setIsSending(false) - return - } - - const response = await invoke("send_chat_message", { - baseUrl: config.baseUrl, - token: config.token, - ticketId: selectedTicketId, - body: messageText, - }) - + await sendMachineMessage({ ticketId: selectedTicketId, body: messageText }) setMessages(prev => [...prev, { - id: response.messageId, + id: crypto.randomUUID(), body: messageText, authorName: "Você", isFromMachine: true, - createdAt: response.createdAt, + createdAt: Date.now(), attachments: [], }]) - - lastFetchRef.current = response.createdAt } catch (err) { console.error("Erro ao enviar mensagem:", err) setInputValue(messageText) @@ -194,35 +158,34 @@ export function ChatFloatingWidget({ } } - const currentSession = sessions.find(s => s.ticketId === selectedTicketId) + const currentSession = sessionList.find(s => s.ticketId === selectedTicketId) // Botao flutuante (fechado) // DEBUG: Log do estado do widget - console.log("[ChatFloatingWidget] Estado:", { - isOpen, - totalUnread, - sessionsCount: sessions.length, - sessions: sessions.map(s => ({ id: s.sessionId, ticketId: s.ticketId, unread: s.unreadCount })) - }) + // console.log("[ChatFloatingWidget] Estado:", { + // isOpen, + // totalUnread: liveUnread, + // sessionsCount: liveSessions.length, + // }) if (!isOpen) { return (
{/* DEBUG: Indicador visual do estado */}
- unread: {totalUnread} | sessions: {sessions.length} + unread: {liveUnread} | sessions: {sessionList.length}
{/* Tabs de tickets (se houver mais de 1) */} - {sessions.length > 1 && ( + {sessionList.length > 1 && (
- {sessions.slice(0, 3).map((session) => ( + {sessionList.slice(0, 3).map((session) => ( ))} - {sessions.length > 3 && ( - +{sessions.length - 3} + {sessionList.length > 3 && ( + +{sessionList.length - 3} )}
)} @@ -304,11 +267,11 @@ export function ChatFloatingWidget({
{/* Selecao de ticket (se nenhum selecionado e ha multiplos) */} - {!selectedTicketId && sessions.length > 1 ? ( + {!selectedTicketId && sessionList.length > 1 ? (

Selecione um chamado:

- {sessions.map((session) => ( + {sessionList.map((session) => (