diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 1ed8e2f..47d5435 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use tauri::async_runtime::JoinHandle; use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl}; use tauri_plugin_notification::NotificationExt; @@ -509,79 +509,145 @@ impl ChatRuntime { let is_connected = self.is_connected.clone(); let join_handle = tauri::async_runtime::spawn(async move { - crate::log_info!("Chat iniciando via Convex WebSocket"); + crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)"); - let client_result = ConvexClient::new(&convex_clone).await; - let mut client = match client_result { - Ok(c) => c, - Err(err) => { - crate::log_warn!("Falha ao criar cliente Convex: {err:?}"); - return; - } - }; + 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; - 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); - sub - } - Err(err) => { - crate::log_warn!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}"); - return; - } - }; - - while let Some(next) = subscription.next().await { + loop { 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; + 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; } - FunctionResult::ConvexError(err) => { - crate::log_warn!("Convex error em checkMachineUpdates: {err:?}"); + }; + + 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 } - FunctionResult::ErrorMessage(msg) => { - crate::log_warn!("Erro em checkMachineUpdates: {msg}"); + 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; } - FunctionResult::Value(other) => { - crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}"); + }; + + 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 (Convex WebSocket finalizado)"); + crate::log_info!("Chat encerrado (realtime finalizado)"); }); let mut guard = self.inner.lock(); @@ -610,6 +676,32 @@ impl ChatRuntime { // 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, @@ -620,12 +712,21 @@ async fn process_chat_update( total_unread: u32, ) { // Buscar sessoes completas para ter dados corretos - let current_sessions = if has_active_sessions { + 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(); @@ -706,8 +807,49 @@ async fn process_chat_update( }), ); + // 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) = current_sessions.first() { + 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) @@ -749,6 +891,46 @@ async fn process_chat_update( // 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 } @@ -771,22 +953,8 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r (380.0, 520.0) // Tamanho expandido }; - // Obter tamanho da tela para posicionar no canto inferior direito - let monitors = app.available_monitors().map_err(|e| e.to_string())?; - let primary = monitors.into_iter().next(); - - let (x, y) = if let Some(monitor) = primary { - let size = monitor.size(); - let scale = monitor.scale_factor(); - let margin = 20.0; - let taskbar_height = 50.0; - ( - (size.width as f64 / scale) - width - margin, - (size.height as f64 / scale) - height - margin - taskbar_height, - ) - } else { - (100.0, 100.0) - }; + // 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); @@ -810,6 +978,10 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r .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(()) } @@ -846,19 +1018,8 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo (380.0, 520.0) // Tamanho expandido }; - // Calcular posicao no canto inferior direito - let (x, y) = if let Some(monitor) = window.current_monitor().ok().flatten() { - let size = monitor.size(); - let scale = monitor.scale_factor(); - let margin = 20.0; - let taskbar_height = 50.0; - ( - (size.width as f64 / scale) - width - margin, - (size.height as f64 / scale) - height - margin - taskbar_height, - ) - } else { - (100.0, 100.0) - }; + // 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())?; diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index 97b4604..d7e0416 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react" -import { open } from "@tauri-apps/plugin-dialog" +import { open as openDialog } from "@tauri-apps/plugin-dialog" +import { open as openExternal } from "@tauri-apps/plugin-opener" import { invoke } from "@tauri-apps/api/core" import { listen } from "@tauri-apps/api/event" -import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react" -import type { ChatMessage, ChatMessagesResponse, NewMessageEvent } from "./types" +import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react" +import type { ChatAttachment, ChatMessage, ChatMessagesResponse, NewMessageEvent, SessionEndedEvent } from "./types" import { getMachineStoreConfig } from "./machineStore" const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak @@ -32,6 +33,170 @@ function getFileIcon(fileName: string) { return } +function isImageAttachment(attachment: ChatAttachment) { + if (attachment.type?.startsWith("image/")) return true + const ext = attachment.name.toLowerCase().split(".").pop() ?? "" + return ["jpg", "jpeg", "png", "gif", "webp"].includes(ext) +} + +function formatAttachmentSize(size?: number) { + if (!size) return null + if (size < 1024) return `${size}B` + const kb = size / 1024 + if (kb < 1024) return `${Math.round(kb)}KB` + return `${(kb / 1024).toFixed(1)}MB` +} + +function MessageAttachment({ + attachment, + isAgent, + loadUrl, +}: { + attachment: ChatAttachment + isAgent: boolean + loadUrl: (storageId: string) => Promise +}) { + const [url, setUrl] = useState(null) + const [loading, setLoading] = useState(true) + const [downloading, setDownloading] = useState(false) + const [downloaded, setDownloaded] = useState(false) + + useEffect(() => { + let cancelled = false + setLoading(true) + loadUrl(attachment.storageId) + .then((resolved) => { + if (!cancelled) setUrl(resolved) + }) + .catch((err) => { + console.error("Falha ao carregar URL do anexo:", err) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [attachment.storageId, loadUrl]) + + const handleView = async () => { + if (!url) return + try { + await openExternal(url) + } catch (err) { + console.error("Falha ao abrir anexo:", err) + } + } + + const handleDownload = async () => { + if (!url || downloading) return + setDownloading(true) + try { + const response = await fetch(url) + const blob = await response.blob() + const downloadUrl = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = downloadUrl + a.download = attachment.name + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(downloadUrl) + setDownloaded(true) + setTimeout(() => setDownloaded(false), 2000) + } catch (err) { + console.error("Falha ao baixar anexo:", err) + // Fallback: abrir no navegador/sistema + await handleView() + } finally { + setDownloading(false) + } + } + + const sizeLabel = formatAttachmentSize(attachment.size) + const isImage = isImageAttachment(attachment) + + if (loading) { + return ( +
+ + Carregando anexo... +
+ ) + } + + if (isImage && url) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {attachment.name} +
+ + +
+
+ ) + } + + return ( +
+ {getFileIcon(attachment.name)} + + {sizeLabel && ({sizeLabel})} +
+ + +
+
+ ) +} + interface ChatWidgetProps { ticketId: string ticketRef?: number @@ -89,6 +254,37 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { return cfg }, []) + const attachmentUrlCacheRef = useRef>(new Map()) + + const loadAttachmentUrl = useCallback(async (storageId: string) => { + const cached = attachmentUrlCacheRef.current.get(storageId) + if (cached) return cached + + const cfg = await ensureConfig() + const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + machineToken: cfg.token, + ticketId, + storageId, + }), + }) + + if (!response.ok) { + const text = await response.text().catch(() => "") + throw new Error(text || `Falha ao obter URL do anexo (${response.status})`) + } + + const data = (await response.json()) as { url?: string } + if (!data.url) { + throw new Error("Resposta invalida ao obter URL do anexo") + } + + attachmentUrlCacheRef.current.set(storageId, data.url) + return data.url + }, [ensureConfig, ticketId]) + const loadMessages = useCallback(async () => { try { const cfg = await ensureConfig() @@ -151,6 +347,24 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } }, [ticketId, loadMessages]) + // Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente) + useEffect(() => { + let unlisten: (() => void) | null = null + listen("raven://chat/session-ended", (event) => { + if (event.payload?.ticketId === ticketId) { + loadMessages() + } + }) + .then((u) => { + unlisten = u + }) + .catch((err) => console.error("Falha ao registrar listener session-ended:", err)) + + return () => { + unlisten?.() + } + }, [ticketId, loadMessages]) + // Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions /* useEffect(() => { setIsLoading(true) @@ -242,7 +456,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { if (isUploading || isSending) return try { - const selected = await open({ + const selected = await openDialog({ multiple: false, filters: [{ name: "Arquivos permitidos", @@ -513,22 +727,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {

{msg.body}

{/* Anexos */} {msg.attachments && msg.attachments.length > 0 && ( -
+
{msg.attachments.map((att) => ( -
- {getFileIcon(att.name)} - {att.name} - {att.size && ( - - ({Math.round(att.size / 1024)}KB) - - )} -
+ attachment={att} + isAgent={isAgent} + loadUrl={loadAttachmentUrl} + /> ))}
)} diff --git a/convex/liveChat.ts b/convex/liveChat.ts index 836f9c9..94109ef 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -487,7 +487,8 @@ export const listMachineMessages = query({ const messages = (await messagesQuery.take(limit)).reverse() // Obter userId da máquina para verificar se é autor - const machineUserId = machine.assignedUserId ?? machine.linkedUserIds?.[0] + // Deve refletir o mesmo "userId" usado em postMachineMessage/markMachineMessagesRead + const machineUserId = machine.assignedUserId ?? machine.linkedUserIds?.[0] ?? ticket.requesterId const result = messages.map((msg) => { const isFromMachine = machineUserId @@ -626,6 +627,12 @@ export const listAgentSessions = query({ return [] } + const role = agent.role?.toUpperCase() ?? "" + if (!["ADMIN", "MANAGER", "AGENT"].includes(role)) { + // Nao expor sessoes de chat para usuarios nao-staff (ex.: portal/collaborator) + return [] + } + // Buscar sessoes ativas do tenant do agente (limitado para evitar OOM) const sessions = await ctx.db .query("liveChatSessions") @@ -939,4 +946,3 @@ export const generateMachineUploadUrl = action({ return { uploadUrl } }, }) - diff --git a/src/app/api/machines/chat/attachments/url/route.ts b/src/app/api/machines/chat/attachments/url/route.ts new file mode 100644 index 0000000..d94d458 --- /dev/null +++ b/src/app/api/machines/chat/attachments/url/route.ts @@ -0,0 +1,95 @@ +import { z } from "zod" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { checkRateLimit, RATE_LIMITS, rateLimitHeaders } from "@/server/rate-limit" + +const attachmentUrlSchema = z.object({ + machineToken: z.string().min(1), + ticketId: z.string().min(1), + storageId: z.string().min(1), +}) + +const CORS_METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + +// POST /api/machines/chat/attachments/url +// Retorna URL assinada para download/preview de um anexo do chat (validado por machineToken). +export async function POST(request: Request) { + const origin = request.headers.get("origin") + + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error + } + + let payload + try { + const raw = await request.json() + payload = attachmentUrlSchema.parse(raw) + } catch (error) { + return jsonWithCors( + { error: "Payload invalido", details: error instanceof Error ? error.message : String(error) }, + 400, + origin, + CORS_METHODS + ) + } + + const rateLimit = checkRateLimit( + `chat-attachment:${payload.machineToken}`, + RATE_LIMITS.CHAT_MESSAGES.maxRequests, + RATE_LIMITS.CHAT_MESSAGES.windowMs + ) + if (!rateLimit.allowed) { + return jsonWithCors( + { error: "Rate limit exceeded", retryAfterMs: rateLimit.retryAfterMs }, + 429, + origin, + CORS_METHODS, + rateLimitHeaders(rateLimit) + ) + } + + try { + const messagesResult = await client.query(api.liveChat.listMachineMessages, { + machineToken: payload.machineToken, + ticketId: payload.ticketId as Id<"tickets">, + limit: 200, + }) + + if (!messagesResult.hasSession) { + return jsonWithCors({ error: "Chat nao esta ativo" }, 403, origin, CORS_METHODS, rateLimitHeaders(rateLimit)) + } + + const attachmentExists = messagesResult.messages.some((message) => + (message.attachments ?? []).some((att) => String(att.storageId) === payload.storageId) + ) + + if (!attachmentExists) { + return jsonWithCors({ error: "Anexo nao encontrado" }, 404, origin, CORS_METHODS, rateLimitHeaders(rateLimit)) + } + + const url = await client.action(api.files.getUrl, { storageId: payload.storageId as Id<"_storage"> }) + if (!url) { + return jsonWithCors({ error: "URL nao disponivel" }, 404, origin, CORS_METHODS, rateLimitHeaders(rateLimit)) + } + + return jsonWithCors({ url }, 200, origin, CORS_METHODS, rateLimitHeaders(rateLimit)) + } catch (error) { + console.error("[machines.chat.attachments.url] Falha ao obter URL de anexo", error) + const details = error instanceof Error ? error.message : String(error) + return jsonWithCors({ error: "Falha ao obter URL de anexo", details }, 500, origin, CORS_METHODS, rateLimitHeaders(rateLimit)) + } +} + diff --git a/src/components/chat/chat-widget.tsx b/src/components/chat/chat-widget.tsx index f42d112..e8aee90 100644 --- a/src/components/chat/chat-widget.tsx +++ b/src/components/chat/chat-widget.tsx @@ -58,7 +58,9 @@ type ChatSession = { ticketRef: number ticketSubject: string sessionId: string + agentId: string unreadCount: number + lastActivityAt: number } type UploadedFile = { @@ -242,8 +244,8 @@ export function ChatWidget() { // pois o chat nativo do Tauri ja esta disponivel const isTauriContext = typeof window !== "undefined" && "__TAURI__" in window - const { convexUserId } = useAuth() - const viewerId = convexUserId ?? null + const { convexUserId, isStaff } = useAuth() + const viewerId = isStaff ? (convexUserId ?? null) : null // Inicializar estado a partir do localStorage (para persistir entre reloads) const [isOpen, setIsOpen] = useState(() => { @@ -290,8 +292,8 @@ export function ChatWidget() { const inputRef = useRef(null) const fileInputRef = useRef(null) const dropAreaRef = useRef(null) - const prevSessionCountRef = useRef(-1) // -1 indica "ainda nao inicializado" - const hasRestoredStateRef = useRef(false) // Flag para evitar sobrescrever estado do localStorage + const hasInitializedSessionsRef = useRef(false) + const prevSessionIdsRef = useRef>(new Set()) // Buscar sessões de chat ativas do agente const activeSessions = useQuery( @@ -366,40 +368,32 @@ export function ChatWidget() { } }, [activeTicketId, activeSessions]) - // Auto-abrir widget quando uma nova sessão é iniciada (apenas para sessoes NOVAS, nao na montagem inicial) + // Auto-abrir o widget quando ESTE agente iniciar uma nova sessão de chat. + // Nao roda na montagem inicial para nao sobrescrever o estado do localStorage. useEffect(() => { if (!activeSessions) return - const currentCount = activeSessions.length - const prevCount = prevSessionCountRef.current - // Primeira execucao: apenas inicializar o ref, nao abrir automaticamente - // Isso preserva o estado do localStorage (se usuario tinha minimizado, mantem minimizado) - if (prevCount === -1) { - prevSessionCountRef.current = currentCount - hasRestoredStateRef.current = true + const currentIds = new Set(activeSessions.map((s) => s.sessionId)) + + if (!hasInitializedSessionsRef.current) { + prevSessionIdsRef.current = currentIds + hasInitializedSessionsRef.current = true return } - // Se aumentou o número de sessões APOS a montagem inicial, é uma nova sessão - abrir o widget expandido - if (currentCount > prevCount && hasRestoredStateRef.current) { - // O estado do widget e definido com base nas nao lidas. - // Selecionar a sessão mais recente (última da lista ou primeira se única) - const newestSession = activeSessions[activeSessions.length - 1] ?? activeSessions[0] - const hasUnreadForAgent = (newestSession?.unreadCount ?? 0) > 0 + const newSessions = activeSessions.filter((s) => !prevSessionIdsRef.current.has(s.sessionId)) + prevSessionIdsRef.current = currentIds - if (!isOpen) { - setIsOpen(true) - setIsMinimized(!hasUnreadForAgent) - } else if (isMinimized && hasUnreadForAgent) { - setIsMinimized(false) - } - if (newestSession) { - setActiveTicketId(newestSession.ticketId) - } - } + if (newSessions.length === 0) return + if (!viewerId) return - prevSessionCountRef.current = currentCount - }, [activeSessions, isOpen, isMinimized]) + const mine = newSessions.find((s) => s.agentId === viewerId) ?? null + if (!mine) return + + setIsOpen(true) + setIsMinimized(false) + setActiveTicketId(mine.ticketId) + }, [activeSessions, viewerId]) // Scroll para última mensagem useEffect(() => {