Add chat widget improvements and chat history component

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 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-07 03:20:22 -03:00
parent b194d77d57
commit d766de4fda
6 changed files with 453 additions and 9 deletions

View file

@ -267,6 +267,7 @@ impl ChatPollerHandle {
pub struct ChatRuntime { pub struct ChatRuntime {
inner: Arc<Mutex<Option<ChatPollerHandle>>>, inner: Arc<Mutex<Option<ChatPollerHandle>>>,
last_sessions: Arc<Mutex<Vec<ChatSession>>>, last_sessions: Arc<Mutex<Vec<ChatSession>>>,
last_unread_count: Arc<Mutex<u32>>,
} }
impl ChatRuntime { impl ChatRuntime {
@ -274,6 +275,7 @@ impl ChatRuntime {
Self { Self {
inner: Arc::new(Mutex::new(None)), inner: Arc::new(Mutex::new(None)),
last_sessions: Arc::new(Mutex::new(Vec::new())), 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 base_clone = sanitized_base.clone();
let token_clone = token.clone(); let token_clone = token.clone();
let last_sessions = self.last_sessions.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 { let join_handle = tauri::async_runtime::spawn(async move {
crate::log_info!("Chat polling iniciado"); crate::log_info!("Chat polling iniciado");
@ -379,10 +382,15 @@ impl ChatRuntime {
} }
// Verificar mensagens nao lidas e emitir evento // 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 { if result.total_unread > 0 {
crate::log_info!( crate::log_info!(
"Chat: {} mensagens nao lidas", "Chat: {} mensagens nao lidas (prev={})",
result.total_unread result.total_unread,
prev_unread
); );
let _ = app.emit( let _ = app.emit(
"raven://chat/unread-update", "raven://chat/unread-update",
@ -391,6 +399,37 @@ impl ChatRuntime {
"sessions": result.sessions "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(&notification_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 { } else {
// Sem sessoes ativas // Sem sessoes ativas

View file

@ -594,3 +594,93 @@ export const listAgentSessions = query({
return result.sort((a, b) => b.lastActivityAt - a.lastActivityAt) 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,
}
},
})

View file

@ -29,6 +29,7 @@ import {
FileText, FileText,
Image as ImageIcon, Image as ImageIcon,
Download, Download,
ExternalLink,
} from "lucide-react" } from "lucide-react"
const MAX_MESSAGE_LENGTH = 4000 const MAX_MESSAGE_LENGTH = 4000
@ -444,10 +445,16 @@ export function ChatWidget() {
)} )}
</div> </div>
{activeSession && ( {activeSession && (
<p className="text-xs text-slate-500"> <a
#{activeSession.ticketRef} href={`/dashboard/tickets/${activeTicketId}`}
{machineHostname && ` - ${machineHostname}`} target="_blank"
</p> rel="noopener noreferrer"
className="group flex items-center gap-1 text-xs text-slate-500 hover:text-primary transition-colors"
>
<span>#{activeSession.ticketRef}</span>
{machineHostname && <span> - {machineHostname}</span>}
<ExternalLink className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</a>
)} )}
</div> </div>
</div> </div>
@ -725,9 +732,15 @@ export function ChatWidget() {
> >
<MessageCircle className="size-6" /> <MessageCircle className="size-6" />
{totalUnread > 0 && ( {totalUnread > 0 && (
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold"> <>
{totalUnread} {/* Anel pulsante externo */}
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{totalUnread > 99 ? "99+" : totalUnread}
</span> </span>
</span>
</>
)} )}
</button> </button>
)} )}

View file

@ -34,6 +34,7 @@ import {
import { TicketStatusBadge } from "@/components/tickets/status-badge" import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card" 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 { TicketCustomFieldsList } from "@/components/tickets/ticket-custom-fields"
import { mapTicketCustomFields, formatTicketCustomFieldValue } from "@/lib/ticket-custom-fields" import { mapTicketCustomFields, formatTicketCustomFieldValue } from "@/lib/ticket-custom-fields"
@ -504,6 +505,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<TicketCsatCard ticket={ticket} /> <TicketCsatCard ticket={ticket} />
</div> </div>
) : null} ) : null}
<TicketChatHistory ticketId={ticketId} />
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"> <div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm"> <Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center justify-between px-5 py-4"> <CardHeader className="flex flex-row items-center justify-between px-5 py-4">

View file

@ -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 (
<button
onClick={handleDownload}
disabled={loading}
className="flex items-center gap-1.5 rounded-md border border-slate-200 bg-slate-50 px-2 py-1 text-xs hover:bg-slate-100 disabled:opacity-50"
>
{loading ? (
<Spinner className="size-3" />
) : isImage ? (
<FileText className="size-3 text-slate-500" />
) : (
<Paperclip className="size-3 text-slate-500" />
)}
<span className="max-w-[100px] truncate">{attachment.name}</span>
<Download className="size-3 text-slate-400" />
</button>
)
}
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 (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
{/* Header da sessao */}
<button
onClick={onToggle}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex size-9 items-center justify-center rounded-full",
isActive ? "bg-emerald-100 text-emerald-600" : "bg-slate-100 text-slate-600"
)}
>
<MessageCircle className="size-4" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-slate-900">
Chat com {session.agentName}
</p>
{isActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
<span className="size-1.5 rounded-full bg-emerald-500 animate-pulse" />
Ativo
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span>{format(session.startedAt, "dd/MM/yyyy 'as' HH:mm", { locale: ptBR })}</span>
<span>-</span>
<span>{session.messageCount} mensagens</span>
<span>-</span>
<span>{duration} min</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="size-4 text-slate-400" />
) : (
<ChevronRight className="size-4 text-slate-400" />
)}
</div>
</button>
{/* Mensagens */}
{isExpanded && (
<div className="border-t border-slate-100">
{session.messages.length === 0 ? (
<div className="flex items-center justify-center py-6 text-sm text-slate-400">
Nenhuma mensagem nesta sessao
</div>
) : (
<>
<div className="max-h-[400px] overflow-y-auto p-4 space-y-3 bg-slate-50/50">
{displayedMessages.map((msg) => {
// Verificar se eh do agente (nao eh da maquina)
const isAgent = msg.authorName === session.agentName
return (
<div
key={msg.id}
className={cn("flex gap-2", isAgent ? "flex-row" : "flex-row-reverse")}
>
<div
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full",
isAgent ? "bg-primary/10 text-primary" : "bg-slate-200 text-slate-600"
)}
>
{isAgent ? <Headphones className="size-3.5" /> : <User className="size-3.5" />}
</div>
<div
className={cn(
"max-w-[80%] rounded-2xl px-3 py-2",
isAgent
? "rounded-bl-md bg-white border border-slate-200 text-slate-900"
: "rounded-br-md bg-slate-700 text-white"
)}
>
<p className="mb-0.5 text-xs font-medium opacity-70">
{msg.authorName}
</p>
{msg.body && (
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
)}
{msg.attachments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{msg.attachments.map((att, i) => (
<MessageAttachmentPreview key={i} attachment={att} />
))}
</div>
)}
<p className={cn("mt-1 text-right text-xs", isAgent ? "text-slate-400" : "text-white/60")}>
{format(msg.createdAt, "HH:mm", { locale: ptBR })}
</p>
</div>
</div>
)
})}
</div>
{/* Carregar mais */}
{hasMore && (
<div className="border-t border-slate-100 p-3 text-center">
<Button variant="ghost" size="sm" onClick={loadMore}>
Carregar mais ({session.messages.length - visibleMessages} restantes)
</Button>
</div>
)}
</>
)}
{/* Rodape com info de encerramento */}
{session.endedAt && (
<div className="border-t border-slate-100 px-4 py-2 bg-slate-50">
<p className="flex items-center gap-1.5 text-xs text-slate-500">
<Clock className="size-3" />
Encerrado em {format(session.endedAt, "dd/MM/yyyy 'as' HH:mm", { locale: ptBR })}
</p>
</div>
)}
</div>
)}
</div>
)
}
export function TicketChatHistory({ ticketId }: ChatHistoryProps) {
const { convexUserId } = useAuth()
const [expandedSessions, setExpandedSessions] = useState<Set<string>>(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 (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="border-b border-slate-100 bg-slate-50/50 px-4 py-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-slate-900">
<MessageCircle className="size-4" />
Historico de Chat
<span className="ml-auto text-xs font-normal text-slate-500">
{chatHistory.sessions.length} {chatHistory.sessions.length === 1 ? "sessao" : "sessoes"} - {chatHistory.totalMessages} mensagens
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-4 space-y-3">
{chatHistory.sessions.map((session: ChatSession) => (
<ChatSessionCard
key={session.sessionId}
session={session}
isExpanded={expandedSessions.has(session.sessionId)}
onToggle={() => toggleSession(session.sessionId)}
/>
))}
</CardContent>
</Card>
)
}

View file

@ -17,6 +17,7 @@ import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header"; import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline"; import { TicketTimeline } from "@/components/tickets/ticket-timeline";
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card"; import { TicketCsatCard } from "@/components/tickets/ticket-csat-card";
import { TicketChatHistory } from "@/components/tickets/ticket-chat-history";
import { useAuth } from "@/lib/auth-client"; import { useAuth } from "@/lib/auth-client";
export function TicketDetailView({ id }: { id: string }) { export function TicketDetailView({ id }: { id: string }) {
@ -107,6 +108,7 @@ export function TicketDetailView({ id }: { id: string }) {
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]"> <div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6"> <div className="space-y-6">
<TicketComments ticket={ticket} /> <TicketComments ticket={ticket} />
<TicketChatHistory ticketId={id} />
<TicketTimeline ticket={ticket} /> <TicketTimeline ticket={ticket} />
</div> </div>
<TicketDetailsPanel ticket={ticket} /> <TicketDetailsPanel ticket={ticket} />