From d766de4fda14b9916a00fe42689fdb596b8298fb Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 7 Dec 2025 03:20:22 -0300 Subject: [PATCH] Add chat widget improvements and chat history component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widget improvements: - Pulsating badge with unread message count on floating button - Clickable ticket reference link in chat header - ExternalLink icon on hover Desktop (Raven) improvements: - Track previous unread count for new message detection - Send native Windows notifications for new messages - Focus chat window when new messages arrive Chat history: - New query getTicketChatHistory for fetching chat sessions and messages - New component TicketChatHistory displaying chat sessions - Sessions can be expanded/collapsed to view messages - Pagination support for long conversations - Added to both dashboard and portal ticket views 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src-tauri/src/chat.rs | 43 ++- convex/liveChat.ts | 90 ++++++ src/components/chat/chat-widget.tsx | 27 +- .../portal/portal-ticket-detail.tsx | 2 + .../tickets/ticket-chat-history.tsx | 298 ++++++++++++++++++ src/components/tickets/ticket-detail-view.tsx | 2 + 6 files changed, 453 insertions(+), 9 deletions(-) create mode 100644 src/components/tickets/ticket-chat-history.tsx diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 63f8282..b62f913 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -267,6 +267,7 @@ impl ChatPollerHandle { pub struct ChatRuntime { inner: Arc>>, last_sessions: Arc>>, + last_unread_count: Arc>, } impl ChatRuntime { @@ -274,6 +275,7 @@ impl ChatRuntime { Self { inner: Arc::new(Mutex::new(None)), last_sessions: Arc::new(Mutex::new(Vec::new())), + last_unread_count: Arc::new(Mutex::new(0)), } } @@ -301,6 +303,7 @@ impl ChatRuntime { let base_clone = sanitized_base.clone(); let token_clone = token.clone(); let last_sessions = self.last_sessions.clone(); + let last_unread_count = self.last_unread_count.clone(); let join_handle = tauri::async_runtime::spawn(async move { crate::log_info!("Chat polling iniciado"); @@ -379,10 +382,15 @@ impl ChatRuntime { } // 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 { crate::log_info!( - "Chat: {} mensagens nao lidas", - result.total_unread + "Chat: {} mensagens nao lidas (prev={})", + result.total_unread, + prev_unread ); let _ = app.emit( "raven://chat/unread-update", @@ -391,6 +399,37 @@ impl ChatRuntime { "sessions": result.sessions }), ); + + // 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}" + ); + } + + // Focar janela de chat se existir + if let Some(session) = result.sessions.first() { + let label = format!("chat-{}", session.ticket_id); + if let Some(window) = app.get_webview_window(&label) { + let _ = window.show(); + let _ = window.set_focus(); + } + } + } } } else { // Sem sessoes ativas diff --git a/convex/liveChat.ts b/convex/liveChat.ts index 14fae77..42ce9ac 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -594,3 +594,93 @@ export const listAgentSessions = query({ return result.sort((a, b) => b.lastActivityAt - a.lastActivityAt) }, }) + +// Historico de sessoes de chat de um ticket (para exibicao no painel) +export const getTicketChatHistory = query({ + args: { + ticketId: v.id("tickets"), + viewerId: v.id("users"), + }, + handler: async (ctx, { ticketId, viewerId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + return { sessions: [], totalMessages: 0 } + } + + const viewer = await ctx.db.get(viewerId) + if (!viewer || viewer.tenantId !== ticket.tenantId) { + return { sessions: [], totalMessages: 0 } + } + + // Buscar todas as sessoes do ticket (ativas e finalizadas) + const sessions = await ctx.db + .query("liveChatSessions") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .collect() + + if (sessions.length === 0) { + return { sessions: [], totalMessages: 0 } + } + + // Buscar todas as mensagens do ticket + const allMessages = await ctx.db + .query("ticketChatMessages") + .withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId)) + .collect() + + // Agrupar mensagens por sessao (baseado no timestamp) + // Mensagens entre startedAt e endedAt pertencem a sessao + const sessionResults = await Promise.all( + sessions + .sort((a, b) => b.startedAt - a.startedAt) // Mais recente primeiro + .map(async (session) => { + const sessionMessages = allMessages.filter((msg) => { + // Mensagem criada durante a sessao + if (msg.createdAt < session.startedAt) return false + if (session.endedAt && msg.createdAt > session.endedAt) return false + return true + }) + + // Obter nome da maquina + let machineName = "Cliente" + if (ticket.machineId) { + const machine = await ctx.db.get(ticket.machineId) + if (machine?.hostname) { + machineName = machine.hostname + } + } + + return { + sessionId: session._id, + agentName: session.agentSnapshot?.name ?? "Agente", + agentEmail: session.agentSnapshot?.email ?? null, + agentAvatarUrl: session.agentSnapshot?.avatarUrl ?? null, + machineName, + status: session.status, + startedAt: session.startedAt, + endedAt: session.endedAt ?? null, + messageCount: sessionMessages.length, + messages: sessionMessages + .sort((a, b) => a.createdAt - b.createdAt) + .map((msg) => ({ + id: msg._id, + body: msg.body, + authorName: msg.authorSnapshot?.name ?? "Usuario", + authorId: String(msg.authorId), + createdAt: msg.createdAt, + attachments: (msg.attachments ?? []).map((att) => ({ + storageId: att.storageId, + name: att.name, + type: att.type ?? null, + })), + })), + } + }) + ) + + return { + sessions: sessionResults, + totalMessages: allMessages.length, + } + }, +}) diff --git a/src/components/chat/chat-widget.tsx b/src/components/chat/chat-widget.tsx index 14912a6..79b6ad5 100644 --- a/src/components/chat/chat-widget.tsx +++ b/src/components/chat/chat-widget.tsx @@ -29,6 +29,7 @@ import { FileText, Image as ImageIcon, Download, + ExternalLink, } from "lucide-react" const MAX_MESSAGE_LENGTH = 4000 @@ -444,10 +445,16 @@ export function ChatWidget() { )} {activeSession && ( -

- #{activeSession.ticketRef} - {machineHostname && ` - ${machineHostname}`} -

+ + #{activeSession.ticketRef} + {machineHostname && - {machineHostname}} + + )} @@ -725,9 +732,15 @@ export function ChatWidget() { > {totalUnread > 0 && ( - - {totalUnread} - + <> + {/* Anel pulsante externo */} + + + + {totalUnread > 99 ? "99+" : totalUnread} + + + )} )} diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx index 2f4588b..f689372 100644 --- a/src/components/portal/portal-ticket-detail.tsx +++ b/src/components/portal/portal-ticket-detail.tsx @@ -34,6 +34,7 @@ import { import { TicketStatusBadge } from "@/components/tickets/status-badge" import { Spinner } from "@/components/ui/spinner" import { TicketCsatCard } from "@/components/tickets/ticket-csat-card" +import { TicketChatHistory } from "@/components/tickets/ticket-chat-history" import { TicketCustomFieldsList } from "@/components/tickets/ticket-custom-fields" import { mapTicketCustomFields, formatTicketCustomFieldValue } from "@/lib/ticket-custom-fields" @@ -504,6 +505,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { ) : null} +
diff --git a/src/components/tickets/ticket-chat-history.tsx b/src/components/tickets/ticket-chat-history.tsx new file mode 100644 index 0000000..2fb41ea --- /dev/null +++ b/src/components/tickets/ticket-chat-history.tsx @@ -0,0 +1,298 @@ +"use client" + +import { useState } from "react" +import { useQuery, useAction } from "convex/react" +import { formatDistanceToNow, format } from "date-fns" +import { ptBR } from "date-fns/locale" +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { useAuth } from "@/lib/auth-client" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Spinner } from "@/components/ui/spinner" +import { cn } from "@/lib/utils" +import { + MessageCircle, + ChevronDown, + ChevronRight, + User, + Headphones, + Clock, + Download, + FileText, + Paperclip, +} from "lucide-react" + +type ChatHistoryProps = { + ticketId: string +} + +type ChatMessage = { + id: string + body: string + authorName: string + authorId: string + createdAt: number + attachments: Array<{ + storageId: string + name: string + type: string | null + }> +} + +type ChatSession = { + sessionId: string + agentName: string + agentEmail: string | null + agentAvatarUrl: string | null + machineName: string + status: string + startedAt: number + endedAt: number | null + messageCount: number + messages: ChatMessage[] +} + +const MESSAGES_PER_PAGE = 20 + +function MessageAttachmentPreview({ attachment }: { attachment: { storageId: string; name: string; type: string | null } }) { + const getFileUrl = useAction(api.files.getUrl) + const [loading, setLoading] = useState(false) + + const handleDownload = async () => { + setLoading(true) + try { + const url = await getFileUrl({ storageId: attachment.storageId as Id<"_storage"> }) + if (url) { + const a = document.createElement("a") + a.href = url + a.download = attachment.name + a.target = "_blank" + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + } + } catch (error) { + console.error("Erro ao baixar arquivo:", error) + } finally { + setLoading(false) + } + } + + const isImage = attachment.type?.startsWith("image/") + + return ( + + ) +} + +function ChatSessionCard({ session, isExpanded, onToggle }: { session: ChatSession; isExpanded: boolean; onToggle: () => void }) { + const [visibleMessages, setVisibleMessages] = useState(MESSAGES_PER_PAGE) + + const isActive = session.status === "ACTIVE" + const duration = session.endedAt + ? Math.round((session.endedAt - session.startedAt) / 60000) + : Math.round((Date.now() - session.startedAt) / 60000) + + const loadMore = () => { + setVisibleMessages((prev) => prev + MESSAGES_PER_PAGE) + } + + const displayedMessages = session.messages.slice(0, visibleMessages) + const hasMore = session.messages.length > visibleMessages + + return ( +
+ {/* Header da sessao */} + + + {/* Mensagens */} + {isExpanded && ( +
+ {session.messages.length === 0 ? ( +
+ Nenhuma mensagem nesta sessao +
+ ) : ( + <> +
+ {displayedMessages.map((msg) => { + // Verificar se eh do agente (nao eh da maquina) + const isAgent = msg.authorName === session.agentName + + return ( +
+
+ {isAgent ? : } +
+
+

+ {msg.authorName} +

+ {msg.body && ( +

{msg.body}

+ )} + {msg.attachments.length > 0 && ( +
+ {msg.attachments.map((att, i) => ( + + ))} +
+ )} +

+ {format(msg.createdAt, "HH:mm", { locale: ptBR })} +

+
+
+ ) + })} +
+ + {/* Carregar mais */} + {hasMore && ( +
+ +
+ )} + + )} + + {/* Rodape com info de encerramento */} + {session.endedAt && ( +
+

+ + Encerrado em {format(session.endedAt, "dd/MM/yyyy 'as' HH:mm", { locale: ptBR })} +

+
+ )} +
+ )} +
+ ) +} + +export function TicketChatHistory({ ticketId }: ChatHistoryProps) { + const { convexUserId } = useAuth() + const [expandedSessions, setExpandedSessions] = useState>(new Set()) + + const chatHistory = useQuery( + api.liveChat.getTicketChatHistory, + convexUserId + ? { ticketId: ticketId as Id<"tickets">, viewerId: convexUserId as Id<"users"> } + : "skip" + ) + + const toggleSession = (sessionId: string) => { + setExpandedSessions((prev) => { + const next = new Set(prev) + if (next.has(sessionId)) { + next.delete(sessionId) + } else { + next.add(sessionId) + } + return next + }) + } + + // Nao mostrar se nao ha historico + if (!chatHistory || chatHistory.sessions.length === 0) { + return null + } + + return ( + + + + + Historico de Chat + + {chatHistory.sessions.length} {chatHistory.sessions.length === 1 ? "sessao" : "sessoes"} - {chatHistory.totalMessages} mensagens + + + + + {chatHistory.sessions.map((session: ChatSession) => ( + toggleSession(session.sessionId)} + /> + ))} + + + ) +} diff --git a/src/components/tickets/ticket-detail-view.tsx b/src/components/tickets/ticket-detail-view.tsx index 8362b7f..a66d18e 100644 --- a/src/components/tickets/ticket-detail-view.tsx +++ b/src/components/tickets/ticket-detail-view.tsx @@ -17,6 +17,7 @@ import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel"; import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header"; import { TicketTimeline } from "@/components/tickets/ticket-timeline"; import { TicketCsatCard } from "@/components/tickets/ticket-csat-card"; +import { TicketChatHistory } from "@/components/tickets/ticket-chat-history"; import { useAuth } from "@/lib/auth-client"; export function TicketDetailView({ id }: { id: string }) { @@ -107,6 +108,7 @@ export function TicketDetailView({ id }: { id: string }) {
+