//! Modulo de Chat em Tempo Real //! //! Este modulo implementa o sistema de chat entre agentes (dashboard web) //! e clientes (Raven desktop). Usa Server-Sent Events (SSE) como metodo //! primario para atualizacoes em tempo real, com fallback para HTTP polling. use convex::{ConvexClient, FunctionResult, Value}; use futures_util::StreamExt; use once_cell::sync::Lazy; use parking_lot::Mutex; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use tauri::async_runtime::JoinHandle; use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl}; use tauri_plugin_notification::NotificationExt; // ============================================================================ // TYPES // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatSession { pub session_id: String, pub ticket_id: String, pub ticket_ref: u64, pub ticket_subject: String, pub agent_name: String, pub agent_email: Option, pub agent_avatar_url: Option, pub unread_count: u32, pub last_activity_at: i64, pub started_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatMessage { pub id: String, pub body: String, pub author_name: String, pub author_avatar_url: Option, pub is_from_machine: bool, pub created_at: i64, pub attachments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatAttachment { pub storage_id: String, pub name: String, pub size: Option, #[serde(rename = "type")] pub mime_type: Option, } #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatPollResponse { pub has_active_sessions: bool, pub sessions: Vec, pub total_unread: u32, } #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatSessionSummary { pub ticket_id: String, pub ticket_ref: u64, pub unread_count: u32, pub last_activity_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatMessagesResponse { pub messages: Vec, pub has_session: bool, #[serde(default)] pub unread_count: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendMessageResponse { pub message_id: String, pub created_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionStartedEvent { pub session: ChatSession, } // ============================================================================ // HTTP CLIENT // ============================================================================ static CHAT_CLIENT: Lazy = Lazy::new(|| { Client::builder() .user_agent("raven-chat/1.0") .timeout(Duration::from_secs(15)) .use_rustls_tls() .build() .expect("failed to build chat http client") }); // ============================================================================ // API FUNCTIONS // ============================================================================ #[allow(dead_code)] pub async fn poll_chat_updates( base_url: &str, token: &str, last_checked_at: Option, ) -> Result { let url = format!("{}/api/machines/chat/poll", base_url); let mut payload = serde_json::json!({ "machineToken": token, }); if let Some(ts) = last_checked_at { payload["lastCheckedAt"] = serde_json::json!(ts); } let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de poll: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Poll falhou: status={}, body={}", status, body)); } response .json() .await .map_err(|e| format!("Falha ao parsear resposta de poll: {e}")) } pub async fn fetch_sessions(base_url: &str, token: &str) -> Result, String> { let url = format!("{}/api/machines/chat/sessions", base_url); let payload = serde_json::json!({ "machineToken": token, }); let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de sessions: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Sessions falhou: status={}, body={}", status, body)); } #[derive(Deserialize)] struct SessionsResponse { sessions: Vec, } let data: SessionsResponse = response .json() .await .map_err(|e| format!("Falha ao parsear resposta de sessions: {e}"))?; Ok(data.sessions) } pub async fn fetch_messages( base_url: &str, token: &str, ticket_id: &str, since: Option, ) -> Result { let url = format!("{}/api/machines/chat/messages", base_url); let mut payload = serde_json::json!({ "machineToken": token, "ticketId": ticket_id, "action": "list", "limit": 200, }); if let Some(ts) = since { payload["since"] = serde_json::json!(ts); } let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de messages: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Messages falhou: status={}, body={}", status, body)); } response .json() .await .map_err(|e| format!("Falha ao parsear resposta de messages: {e}")) } pub async fn send_message( base_url: &str, token: &str, ticket_id: &str, body: &str, attachments: Option>, ) -> Result { let url = format!("{}/api/machines/chat/messages", base_url); let mut payload = serde_json::json!({ "machineToken": token, "ticketId": ticket_id, "action": "send", "body": body, }); if let Some(atts) = attachments { payload["attachments"] = serde_json::to_value(atts).unwrap_or_default(); } let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de send: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Send falhou: status={}, body={}", status, body)); } response .json() .await .map_err(|e| format!("Falha ao parsear resposta de send: {e}")) } pub async fn mark_messages_read( base_url: &str, token: &str, ticket_id: &str, message_ids: &[String], ) -> Result<(), String> { let url = format!("{}/api/machines/chat/read", base_url); let payload = serde_json::json!({ "machineToken": token, "ticketId": ticket_id, "messageIds": message_ids, }); let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de mark read: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Mark read falhou: status={}, body={}", status, body)); } Ok(()) } // ============================================================================ // UPLOAD DE ARQUIVOS // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AttachmentPayload { pub storage_id: String, pub name: String, pub size: Option, #[serde(rename = "type")] pub mime_type: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UploadUrlResponse { pub upload_url: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UploadResult { pub storage_id: String, } // Extensoes permitidas const ALLOWED_EXTENSIONS: &[&str] = &[ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx", ]; // Tamanho maximo: 10MB const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; pub fn is_allowed_file(file_name: &str, file_size: u64) -> Result<(), String> { let ext = file_name .to_lowercase() .rsplit('.') .next() .map(|e| format!(".{}", e)) .unwrap_or_default(); if !ALLOWED_EXTENSIONS.contains(&ext.as_str()) { return Err(format!( "Tipo de arquivo não permitido. Permitidos: {}", ALLOWED_EXTENSIONS.join(", ") )); } if file_size > MAX_FILE_SIZE { return Err(format!( "Arquivo muito grande. Máximo: {}MB", MAX_FILE_SIZE / 1024 / 1024 )); } Ok(()) } pub fn get_mime_type(file_name: &str) -> String { let lower = file_name.to_lowercase(); let ext = lower.rsplit('.').next().unwrap_or(""); match ext { "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "gif" => "image/gif", "webp" => "image/webp", "pdf" => "application/pdf", "txt" => "text/plain", "doc" => "application/msword", "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "xls" => "application/vnd.ms-excel", "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", _ => "application/octet-stream", } .to_string() } pub async fn generate_upload_url( base_url: &str, token: &str, file_name: &str, file_type: &str, file_size: u64, ) -> Result { let url = format!("{}/api/machines/chat/upload", base_url); let payload = serde_json::json!({ "machineToken": token, "fileName": file_name, "fileType": file_type, "fileSize": file_size, }); let response = CHAT_CLIENT .post(&url) .json(&payload) .send() .await .map_err(|e| format!("Falha na requisicao de upload URL: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Upload URL falhou: status={}, body={}", status, body)); } let data: UploadUrlResponse = response .json() .await .map_err(|e| format!("Falha ao parsear resposta de upload URL: {e}"))?; Ok(data.upload_url) } pub async fn upload_file( upload_url: &str, file_data: Vec, content_type: &str, ) -> Result { let response = CHAT_CLIENT .post(upload_url) .header("Content-Type", content_type) .body(file_data) .send() .await .map_err(|e| format!("Falha no upload: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Upload falhou: status={}, body={}", status, body)); } let data: UploadResult = response .json() .await .map_err(|e| format!("Falha ao parsear resposta de upload: {e}"))?; Ok(data.storage_id) } // ============================================================================ // CHAT RUNTIME // ============================================================================ struct ChatRealtimeHandle { stop_flag: Arc, join_handle: JoinHandle<()>, } impl ChatRealtimeHandle { fn stop(self) { self.stop_flag.store(true, Ordering::Relaxed); self.join_handle.abort(); } } #[derive(Default, Clone)] pub struct ChatRuntime { inner: Arc>>, last_sessions: Arc>>, last_unread_count: Arc>, is_connected: Arc, } impl ChatRuntime { pub fn new() -> Self { Self { inner: Arc::new(Mutex::new(None)), last_sessions: Arc::new(Mutex::new(Vec::new())), last_unread_count: Arc::new(Mutex::new(0)), is_connected: Arc::new(AtomicBool::new(false)), } } /// Retorna true se conexao WS Convex esta ativa pub fn is_using_sse(&self) -> bool { self.is_connected.load(Ordering::Relaxed) } /// Inicia o sistema de atualizacoes de chat via WebSocket do Convex pub fn start_polling( &self, base_url: String, convex_url: String, token: String, app: tauri::AppHandle, ) -> Result<(), String> { let sanitized_base = base_url.trim().trim_end_matches('/').to_string(); if sanitized_base.is_empty() { return Err("URL base invalida".to_string()); } let sanitized_convex = convex_url.trim().trim_end_matches('/').to_string(); if sanitized_convex.is_empty() { return Err("URL do Convex inválida".to_string()); } // Para polling/SSE existente { let mut guard = self.inner.lock(); if let Some(handle) = guard.take() { handle.stop(); } } let stop_flag = Arc::new(AtomicBool::new(false)); let stop_clone = stop_flag.clone(); let base_clone = sanitized_base.clone(); let convex_clone = sanitized_convex.clone(); let token_clone = token.clone(); let last_sessions = self.last_sessions.clone(); let last_unread_count = self.last_unread_count.clone(); let is_connected = self.is_connected.clone(); let join_handle = tauri::async_runtime::spawn(async move { crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)"); let mut backoff_ms: u64 = 1_000; let max_backoff_ms: u64 = 30_000; let poll_interval = Duration::from_secs(5); let mut last_poll = Instant::now() - poll_interval; loop { if stop_clone.load(Ordering::Relaxed) { break; } let client_result = ConvexClient::new(&convex_clone).await; let mut client = match client_result { Ok(c) => c, Err(err) => { is_connected.store(false, Ordering::Relaxed); crate::log_warn!("Falha ao criar cliente Convex: {err:?}"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( &base_clone, &token_clone, &app, &last_sessions, &last_unread_count, ) .await; last_poll = Instant::now(); } tokio::time::sleep(Duration::from_millis(backoff_ms)).await; backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms); continue; } }; let mut args = BTreeMap::new(); args.insert("machineToken".to_string(), token_clone.clone().into()); let subscribe_result = client.subscribe("liveChat:checkMachineUpdates", args).await; let mut subscription = match subscribe_result { Ok(sub) => { is_connected.store(true, Ordering::Relaxed); backoff_ms = 1_000; sub } Err(err) => { is_connected.store(false, Ordering::Relaxed); crate::log_warn!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( &base_clone, &token_clone, &app, &last_sessions, &last_unread_count, ) .await; last_poll = Instant::now(); } tokio::time::sleep(Duration::from_millis(backoff_ms)).await; backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms); continue; } }; while let Some(next) = subscription.next().await { if stop_clone.load(Ordering::Relaxed) { break; } match next { FunctionResult::Value(Value::Object(obj)) => { let has_active = obj .get("hasActiveSessions") .and_then(|v| match v { Value::Boolean(b) => Some(*b), _ => None, }) .unwrap_or(false); let total_unread = obj .get("totalUnread") .and_then(|v| match v { Value::Int64(i) => Some(*i as u32), Value::Float64(f) => Some(*f as u32), _ => None, }) .unwrap_or(0); process_chat_update( &base_clone, &token_clone, &app, &last_sessions, &last_unread_count, has_active, total_unread, ) .await; } FunctionResult::ConvexError(err) => { crate::log_warn!("Convex error em checkMachineUpdates: {err:?}"); } FunctionResult::ErrorMessage(msg) => { crate::log_warn!("Erro em checkMachineUpdates: {msg}"); } FunctionResult::Value(other) => { crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}"); } } } is_connected.store(false, Ordering::Relaxed); if stop_clone.load(Ordering::Relaxed) { break; } crate::log_warn!("Chat realtime desconectado; aplicando fallback e tentando reconectar"); if last_poll.elapsed() >= poll_interval { poll_and_process_chat_update( &base_clone, &token_clone, &app, &last_sessions, &last_unread_count, ) .await; last_poll = Instant::now(); } tokio::time::sleep(Duration::from_millis(backoff_ms)).await; backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms); } is_connected.store(false, Ordering::Relaxed); crate::log_info!("Chat encerrado (realtime finalizado)"); }); let mut guard = self.inner.lock(); *guard = Some(ChatRealtimeHandle { stop_flag, join_handle, }); Ok(()) } pub fn stop(&self) { let mut guard = self.inner.lock(); if let Some(handle) = guard.take() { handle.stop(); } self.is_connected.store(false, Ordering::Relaxed); } pub fn get_sessions(&self) -> Vec { self.last_sessions.lock().clone() } } // ============================================================================ // SHARED UPDATE PROCESSING // ============================================================================ async fn poll_and_process_chat_update( base_url: &str, token: &str, app: &tauri::AppHandle, last_sessions: &Arc>>, last_unread_count: &Arc>, ) { match poll_chat_updates(base_url, token, None).await { Ok(result) => { process_chat_update( base_url, token, app, last_sessions, last_unread_count, result.has_active_sessions, result.total_unread, ) .await; } Err(err) => { crate::log_warn!("Chat fallback poll falhou: {err}"); } } } async fn process_chat_update( base_url: &str, token: &str, app: &tauri::AppHandle, last_sessions: &Arc>>, last_unread_count: &Arc>, has_active_sessions: bool, total_unread: u32, ) { // Buscar sessoes completas para ter dados corretos let mut current_sessions = if has_active_sessions { fetch_sessions(base_url, token).await.unwrap_or_default() } else { Vec::new() }; // Ordenar por ultima atividade (mais recente primeiro) para consistencia em UI/tray. if current_sessions.len() > 1 { current_sessions.sort_by(|a, b| { b.last_activity_at .cmp(&a.last_activity_at) .then_with(|| b.started_at.cmp(&a.started_at)) }); } // Verificar sessoes anteriores let prev_sessions: Vec = last_sessions.lock().clone(); let prev_session_ids: Vec = prev_sessions.iter().map(|s| s.session_id.clone()).collect(); let current_session_ids: Vec = current_sessions.iter().map(|s| s.session_id.clone()).collect(); // Detectar novas sessoes for session in ¤t_sessions { if !prev_session_ids.contains(&session.session_id) { crate::log_info!( "Nova sessao de chat: ticket={}, session={}", session.ticket_id, session.session_id ); let _ = app.emit( "raven://chat/session-started", SessionStartedEvent { session: session.clone(), }, ); // NÃO abre janela aqui - só quando o agente enviar a primeira mensagem // O chat aparecerá minimizado com badge quando houver novas mensagens crate::log_info!( "Sessão de chat iniciada pelo agente {}. Aguardando primeira mensagem.", session.agent_name ); } } // Detectar sessoes encerradas for prev_session in &prev_sessions { if !current_session_ids.contains(&prev_session.session_id) { crate::log_info!( "Sessao de chat encerrada: ticket={}, session={}", prev_session.ticket_id, prev_session.session_id ); let _ = app.emit( "raven://chat/session-ended", serde_json::json!({ "sessionId": prev_session.session_id, "ticketId": prev_session.ticket_id }), ); } } // Atualizar cache de sessoes *last_sessions.lock() = current_sessions.clone(); // Verificar mensagens nao lidas let prev_unread = *last_unread_count.lock(); let new_messages = total_unread > prev_unread; *last_unread_count.lock() = total_unread; // Sempre emitir unread-update let _ = app.emit( "raven://chat/unread-update", serde_json::json!({ "totalUnread": total_unread, "sessions": current_sessions }), ); // Notificar novas mensagens - mostrar chat minimizado com badge if new_messages && total_unread > 0 { let new_count = total_unread - prev_unread; crate::log_info!("Chat: {} novas mensagens (total={})", new_count, total_unread); let _ = app.emit( "raven://chat/new-message", serde_json::json!({ "totalUnread": total_unread, "newCount": new_count, "sessions": current_sessions }), ); // Escolher qual sessao/ticket deve ser mostrado quando ha multiplas sessoes. // Preferencia: maior incremento de unread (delta) e, em empate, ultima atividade mais recente. let mut best_session: Option<&ChatSession> = None; let mut best_delta: u32 = 0; for session in ¤t_sessions { let prev_unread_for_ticket = prev_sessions .iter() .find(|s| s.ticket_id == session.ticket_id) .map(|s| s.unread_count) .unwrap_or(0); let delta = session.unread_count.saturating_sub(prev_unread_for_ticket); let is_better = if delta > best_delta { true } else if delta == best_delta { match best_session { Some(best) => session.last_activity_at > best.last_activity_at, None => true, } } else { false }; if is_better { best_delta = delta; best_session = Some(session); } } // Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente. let session_to_show = if best_delta > 0 { best_session } else { current_sessions.iter().max_by(|a, b| { a.unread_count .cmp(&b.unread_count) .then_with(|| a.last_activity_at.cmp(&b.last_activity_at)) }) }; // Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra) if let Some(session) = session_to_show { let label = format!("chat-{}", session.ticket_id); if let Some(window) = app.get_webview_window(&label) { // Janela ja existe - apenas mostrar (NAO minimizar se estiver expandida) // Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens let _ = window.show(); // Verificar se esta expandida (altura > 100px significa expandido) // Se estiver expandida, NAO minimizar - usuario esta usando o chat if let Ok(size) = window.inner_size() { let is_expanded = size.height > 100; if !is_expanded { // Janela esta minimizada, manter minimizada let _ = set_chat_minimized(app, &session.ticket_id, true); } // Se esta expandida, nao faz nada - deixa o usuario continuar usando } } else { // Criar nova janela ja minimizada (menos intrusivo) let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref); } } // Notificacao nativa let notification_title = "Nova mensagem de suporte"; let notification_body = if new_count == 1 { "Você recebeu 1 nova mensagem no chat".to_string() } else { format!("Você recebeu {} novas mensagens no chat", new_count) }; let _ = app .notification() .builder() .title(notification_title) .body(¬ification_body) .show(); } } // ============================================================================ // WINDOW MANAGEMENT // ============================================================================ fn resolve_chat_window_position( app: &tauri::AppHandle, window: Option<&tauri::WebviewWindow>, width: f64, height: f64, ) -> (f64, f64) { let margin = 20.0; let taskbar_height = 50.0; let monitor = window .and_then(|w| w.current_monitor().ok().flatten()) .or_else(|| { app.get_webview_window("main") .and_then(|w| w.current_monitor().ok().flatten()) }) .or_else(|| app.available_monitors().ok().and_then(|monitors| monitors.into_iter().next())); let Some(monitor) = monitor else { return (100.0, 100.0); }; let size = monitor.size(); let pos = monitor.position(); let scale = monitor.scale_factor(); // Converter coordenadas do monitor para coordenadas logicas (multi-monitor pode ter origem negativa). let monitor_x = pos.x as f64 / scale; let monitor_y = pos.y as f64 / scale; let monitor_width = size.width as f64 / scale; let monitor_height = size.height as f64 / scale; let max_x = monitor_x + monitor_width - width - margin; let max_y = monitor_y + monitor_height - height - margin - taskbar_height; let x = if max_x.is_finite() { max_x.max(monitor_x) } else { 100.0 }; let y = if max_y.is_finite() { max_y.max(monitor_y) } else { 100.0 }; (x, y) } fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> { open_chat_window_with_state(app, ticket_id, ticket_ref, true) // Por padrao abre minimizada } /// Abre janela de chat com estado inicial de minimizacao configuravel fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> { let label = format!("chat-{}", ticket_id); // Verificar se ja existe if let Some(window) = app.get_webview_window(&label) { window.show().map_err(|e| e.to_string())?; window.set_focus().map_err(|e| e.to_string())?; return Ok(()); } // Dimensoes baseadas no estado inicial let (width, height) = if start_minimized { (240.0, 52.0) // Tamanho minimizado (chip com badge) } else { (380.0, 520.0) // Tamanho expandido }; // Posicionar no canto inferior direito (acima da barra de tarefas). let (x, y) = resolve_chat_window_position(app, None, width, height); // Usar query param ao inves de path para compatibilidade com SPA let url_path = format!("index.html?view=chat&ticketId={}&ticketRef={}", ticket_id, ticket_ref); WebviewWindowBuilder::new( app, &label, WebviewUrl::App(url_path.into()), ) .title("Chat de Suporte") .inner_size(width, height) // Abre ja no tamanho correto .min_inner_size(240.0, 52.0) // Tamanho minimo para modo minimizado com badge .position(x, y) .decorations(false) // Sem decoracoes nativas - usa header customizado .transparent(true) // Permite fundo transparente .shadow(false) // Desabilitar sombra para transparencia funcionar corretamente .always_on_top(true) .skip_taskbar(true) .focused(true) .visible(true) .build() .map_err(|e| e.to_string())?; // Reaplica layout/posicao logo apos criar a janela. // Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes. let _ = set_chat_minimized(app, ticket_id, start_minimized); crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label); Ok(()) } pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> { open_chat_window_internal(app, ticket_id, ticket_ref) } pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { let label = format!("chat-{}", ticket_id); if let Some(window) = app.get_webview_window(&label) { window.close().map_err(|e| e.to_string())?; } Ok(()) } pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { let label = format!("chat-{}", ticket_id); if let Some(window) = app.get_webview_window(&label) { window.hide().map_err(|e| e.to_string())?; } Ok(()) } /// Redimensiona a janela de chat para modo minimizado (chip) ou expandido pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> { let label = format!("chat-{}", ticket_id); let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?; // Tamanhos - chip minimizado com margem extra para badge (absolute -top-1 -right-1) let (width, height) = if minimized { (240.0, 52.0) // Tamanho com folga para "Ticket #XXX" e badge } else { (380.0, 520.0) // Tamanho expandido }; // Calcular posicao no canto inferior direito do monitor atual (com fallback seguro). let (x, y) = resolve_chat_window_position(app, Some(&window), width, height); // Aplicar novo tamanho e posicao window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?; crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized); Ok(()) }