From 88a3b37f2fa76bf068480ebdd397dfc4e19f4c20 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 7 Dec 2025 11:16:56 -0300 Subject: [PATCH] Fix chat session management and add floating widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix session sync: events now send complete ChatSession data instead of partial ChatSessionSummary, ensuring proper ticket/agent info display - Add session-ended event detection to remove closed sessions from client - Add ChatFloatingWidget component for in-app chat experience - Restrict endSession to ADMIN/MANAGER/AGENT roles only - Improve polling logic to detect new and ended sessions properly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src-tauri/src/chat.rs | 203 ++++---- apps/desktop/src/chat/types.ts | 24 + .../src/components/ChatFloatingWidget.tsx | 436 ++++++++++++++++++ apps/desktop/src/main.tsx | 93 ++++ convex/liveChat.ts | 16 +- 5 files changed, 680 insertions(+), 92 deletions(-) create mode 100644 apps/desktop/src/components/ChatFloatingWidget.tsx diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index fbc610e..6801abe 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -322,102 +322,131 @@ impl ChatRuntime { Ok(result) => { last_checked_at = Some(chrono::Utc::now().timestamp_millis()); - if result.has_active_sessions { - // Verificar novas sessoes - let prev_sessions: Vec = { - last_sessions.lock().iter().map(|s| s.session_id.clone()).collect() - }; + // Buscar sessoes completas para ter dados corretos + let current_sessions = if result.has_active_sessions { + fetch_sessions(&base_clone, &token_clone).await.unwrap_or_default() + } else { + Vec::new() + }; - // Buscar detalhes das sessoes - if let Ok(sessions) = fetch_sessions(&base_clone, &token_clone).await { - for session in &sessions { - if !prev_sessions.contains(&session.session_id) { - // Nova sessao! Emitir evento - crate::log_info!( - "Nova sessao de chat: ticket={}", - session.ticket_id - ); - let _ = app.emit( - "raven://chat/session-started", - SessionStartedEvent { - session: session.clone(), - }, - ); + // 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(); - // Enviar notificacao nativa do Windows - // A janela de chat NAO abre automaticamente - - // o usuario deve clicar na notificacao ou no tray - let notification_title = format!( - "Chat iniciado - Chamado #{}", - session.ticket_ref - ); - let notification_body = format!( - "{} iniciou um chat de suporte.\nClique no icone do Raven para abrir.", - session.agent_name - ); - if let Err(e) = app - .notification() - .builder() - .title(¬ification_title) - .body(¬ification_body) - .show() - { - crate::log_warn!( - "Falha ao enviar notificacao: {e}" - ); - } - } - } - - // Atualizar cache - *last_sessions.lock() = sessions; - } - - // Verificar mensagens nao lidas e emitir evento - let prev_unread = *last_unread_count.lock(); - let new_messages = result.total_unread > prev_unread; - *last_unread_count.lock() = result.total_unread; - - if result.total_unread > 0 { + // Detectar novas sessoes + for session in ¤t_sessions { + if !prev_session_ids.contains(&session.session_id) { + // Nova sessao! Emitir evento crate::log_info!( - "Chat: {} mensagens nao lidas (prev={})", - result.total_unread, - prev_unread + "Nova sessao de chat: ticket={}, session={}", + session.ticket_id, + session.session_id ); let _ = app.emit( - "raven://chat/unread-update", - serde_json::json!({ - "totalUnread": result.total_unread, - "sessions": result.sessions - }), + "raven://chat/session-started", + SessionStartedEvent { + session: session.clone(), + }, ); - // Notificar novas mensagens (apenas se aumentou) - if new_messages && prev_unread > 0 { - let new_count = result.total_unread - prev_unread; - let notification_title = "Nova mensagem de suporte"; - let notification_body = if new_count == 1 { - "Voce recebeu 1 nova mensagem no chat".to_string() - } else { - format!("Voce recebeu {} novas mensagens no chat", new_count) - }; - if let Err(e) = app - .notification() - .builder() - .title(notification_title) - .body(¬ification_body) - .show() - { - crate::log_warn!( - "Falha ao enviar notificacao de nova mensagem: {e}" - ); - } - // NAO foca a janela automaticamente - usuario abre manualmente + // Enviar notificacao nativa do Windows + let notification_title = format!( + "Chat iniciado - Chamado #{}", + session.ticket_ref + ); + let notification_body = format!( + "{} iniciou um chat de suporte.\nClique no icone do Raven para abrir.", + session.agent_name + ); + if let Err(e) = app + .notification() + .builder() + .title(¬ification_title) + .body(¬ification_body) + .show() + { + crate::log_warn!( + "Falha ao enviar notificacao de nova sessao: {e}" + ); } } - } else { - // Sem sessoes ativas - *last_sessions.lock() = Vec::new(); + } + + // Detectar sessoes encerradas + for prev_session in &prev_sessions { + if !current_session_ids.contains(&prev_session.session_id) { + // Sessao foi encerrada! Emitir evento + 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 = result.total_unread > prev_unread; + *last_unread_count.lock() = result.total_unread; + + // Sempre emitir unread-update com sessoes completas + let _ = app.emit( + "raven://chat/unread-update", + serde_json::json!({ + "totalUnread": result.total_unread, + "sessions": current_sessions + }), + ); + + // Notificar novas mensagens (quando aumentou) + if new_messages && result.total_unread > 0 { + let new_count = result.total_unread - prev_unread; + + crate::log_info!( + "Chat: {} novas mensagens (total={})", + new_count, + result.total_unread + ); + + // Emitir evento para o frontend atualizar UI + let _ = app.emit( + "raven://chat/new-message", + serde_json::json!({ + "totalUnread": result.total_unread, + "newCount": new_count, + "sessions": current_sessions + }), + ); + + // Enviar notificacao nativa do Windows + let notification_title = "Nova mensagem de suporte"; + let notification_body = if new_count == 1 { + "Voce recebeu 1 nova mensagem no chat".to_string() + } else { + format!("Voce recebeu {} novas mensagens no chat", new_count) + }; + if let Err(e) = app + .notification() + .builder() + .title(notification_title) + .body(¬ification_body) + .show() + { + crate::log_warn!( + "Falha ao enviar notificacao de nova mensagem: {e}" + ); + } } } Err(e) => { diff --git a/apps/desktop/src/chat/types.ts b/apps/desktop/src/chat/types.ts index c46d289..cddb5c6 100644 --- a/apps/desktop/src/chat/types.ts +++ b/apps/desktop/src/chat/types.ts @@ -43,3 +43,27 @@ export interface SendMessageResponse { export interface SessionStartedEvent { session: ChatSession } + +export interface UnreadUpdateEvent { + totalUnread: number + sessions: ChatSession[] +} + +export interface NewMessageEvent { + totalUnread: number + newCount: number + sessions: ChatSession[] +} + +export interface SessionEndedEvent { + sessionId: string + ticketId: string +} + +export interface ChatHistorySession { + sessionId: string + startedAt: number + endedAt: number | null + agentName: string + messages: ChatMessage[] +} diff --git a/apps/desktop/src/components/ChatFloatingWidget.tsx b/apps/desktop/src/components/ChatFloatingWidget.tsx new file mode 100644 index 0000000..27c370f --- /dev/null +++ b/apps/desktop/src/components/ChatFloatingWidget.tsx @@ -0,0 +1,436 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { invoke } from "@tauri-apps/api/core" +import { Store } from "@tauri-apps/plugin-store" +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" + +interface ChatFloatingWidgetProps { + sessions: ChatSession[] + totalUnread: number + isOpen: boolean + onToggle: () => void + onMinimize: () => void +} + +export function ChatFloatingWidget({ + sessions, + totalUnread, + isOpen, + onToggle, + onMinimize, +}: ChatFloatingWidgetProps) { + const [selectedTicketId, setSelectedTicketId] = useState(null) + const [messages, setMessages] = useState([]) + const [inputValue, setInputValue] = useState("") + const [isLoading, setIsLoading] = useState(false) + const [isSending, setIsSending] = useState(false) + const [historyExpanded, setHistoryExpanded] = useState(false) + const [historySessions] = useState([]) + + const messagesEndRef = useRef(null) + const lastFetchRef = useRef(0) + const pollIntervalRef = useRef | 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) + setSelectedTicketId(sorted[0].ticketId) + } + }, [sessions, selectedTicketId]) + + // Scroll para o final quando novas mensagens chegam + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }, []) + + useEffect(() => { + scrollToBottom() + }, [messages, scrollToBottom]) + + // Carregar configuracao do store + const loadConfig = useCallback(async () => { + try { + const store = await Store.load("machine-agent.json") + 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 + 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() + + return () => { + mounted = false + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current) + pollIntervalRef.current = null + } + } + }, [selectedTicketId, isOpen, loadConfig, fetchMessages]) + + // Limpar mensagens quando trocar de ticket + useEffect(() => { + setMessages([]) + lastFetchRef.current = 0 + }, [selectedTicketId]) + + // Enviar mensagem + const handleSend = async () => { + if (!inputValue.trim() || isSending || !selectedTicketId) return + + const messageText = inputValue.trim() + setInputValue("") + 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, + }) + + setMessages(prev => [...prev, { + id: response.messageId, + body: messageText, + authorName: "Voce", + isFromMachine: true, + createdAt: response.createdAt, + attachments: [], + }]) + + lastFetchRef.current = response.createdAt + } catch (err) { + console.error("Erro ao enviar mensagem:", err) + setInputValue(messageText) + } finally { + setIsSending(false) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const currentSession = sessions.find(s => s.ticketId === selectedTicketId) + + // Botao flutuante (fechado) + if (!isOpen) { + return ( +
+ +
+ ) + } + + // Widget expandido + return ( +
+ {/* Header */} +
+
+ {sessions.length > 1 && selectedTicketId && ( + + )} +
+ +
+
+

+ {currentSession?.agentName ?? "Suporte"} +

+ {currentSession && ( +

+ Chamado #{currentSession.ticketRef} +

+ )} +
+
+
+ {/* Tabs de tickets (se houver mais de 1) */} + {sessions.length > 1 && ( +
+ {sessions.slice(0, 3).map((session) => ( + + ))} + {sessions.length > 3 && ( + +{sessions.length - 3} + )} +
+ )} + + +
+
+ + {/* Selecao de ticket (se nenhum selecionado e ha multiplos) */} + {!selectedTicketId && sessions.length > 1 ? ( +
+

Selecione um chamado:

+
+ {sessions.map((session) => ( + + ))} +
+
+ ) : ( + <> + {/* Area de mensagens */} +
+ {/* Historico de sessoes anteriores */} + {historySessions.length > 0 && ( +
+ + {historyExpanded && ( +
+ {historySessions.map((session) => ( +
+

{session.agentName}

+

{session.messages.length} mensagens

+
+ ))} +
+ )} +
+ )} + + {isLoading ? ( +
+ +

Carregando...

+
+ ) : messages.length === 0 ? ( +
+

+ Nenhuma mensagem ainda +

+

+ O agente iniciara a conversa em breve +

+
+ ) : ( +
+ {messages.map((msg) => ( +
+
+ {!msg.isFromMachine && ( +

+ {msg.authorName} +

+ )} +

{msg.body}

+

+ {formatTime(msg.createdAt)} +

+
+
+ ))} +
+
+ )} +
+ + {/* Input */} +
+
+