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

@ -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() {
)}
</div>
{activeSession && (
<p className="text-xs text-slate-500">
#{activeSession.ticketRef}
{machineHostname && ` - ${machineHostname}`}
</p>
<a
href={`/dashboard/tickets/${activeTicketId}`}
target="_blank"
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>
@ -725,9 +732,15 @@ export function ChatWidget() {
>
<MessageCircle className="size-6" />
{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}
</span>
<>
{/* 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>
</>
)}
</button>
)}

View file

@ -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) {
<TicketCsatCard ticket={ticket} />
</div>
) : null}
<TicketChatHistory ticketId={ticketId} />
<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">
<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 { 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 }) {
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketComments ticket={ticket} />
<TicketChatHistory ticketId={id} />
<TicketTimeline ticket={ticket} />
</div>
<TicketDetailsPanel ticket={ticket} />