feat(desktop): adiciona hub de chats para multiplas sessoes

- Cria ChatSessionList, ChatSessionItem e ChatHubWidget no desktop
- Adiciona comandos Rust para gerenciar hub window
- Quando ha multiplas sessoes, abre hub ao inves de janela individual
- Hub lista todas as sessoes ativas com badge de nao lidos
- Clicar em sessao abre/foca janela de chat especifica
- Menu do tray abre hub quando ha multiplas sessoes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-15 12:13:47 -03:00
parent 95ab1b5f0c
commit 29fbbfaa26
6 changed files with 560 additions and 38 deletions

View file

@ -0,0 +1,212 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Loader2, MessageCircle, ChevronUp } from "lucide-react"
import { ChatSessionList } from "./ChatSessionList"
import type { ChatSession, NewMessageEvent, SessionStartedEvent, SessionEndedEvent, UnreadUpdateEvent } from "./types"
import { getMachineStoreConfig } from "./machineStore"
/**
* Hub Widget - Lista todas as sessoes de chat ativas
* Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket
*/
export function ChatHubWidget() {
const [sessions, setSessions] = useState<ChatSession[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isMinimized, setIsMinimized] = useState(true) // Inicia minimizado
const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null)
const ensureConfig = useCallback(async () => {
const cfg = configRef.current ?? (await getMachineStoreConfig())
configRef.current = cfg
return cfg
}, [])
// Buscar sessoes do backend
const loadSessions = useCallback(async () => {
try {
const cfg = await ensureConfig()
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineToken: cfg.token }),
})
if (!response.ok) {
throw new Error(`Falha ao buscar sessoes: ${response.status}`)
}
const data = await response.json() as { sessions: ChatSession[] }
setSessions(data.sessions || [])
setError(null)
} catch (err) {
console.error("Erro ao carregar sessoes:", err)
setError(err instanceof Error ? err.message : "Erro desconhecido")
} finally {
setIsLoading(false)
}
}, [ensureConfig])
// Carregar sessoes na montagem
useEffect(() => {
loadSessions()
}, [loadSessions])
// Escutar eventos de atualizacao
useEffect(() => {
const unlisteners: (() => void)[] = []
// Quando nova sessao inicia
listen<SessionStartedEvent>("raven://chat/session-started", () => {
loadSessions()
}).then((unlisten) => unlisteners.push(unlisten))
// Quando sessao encerra
listen<SessionEndedEvent>("raven://chat/session-ended", () => {
loadSessions()
}).then((unlisten) => unlisteners.push(unlisten))
// Quando contador de nao lidos muda
listen<UnreadUpdateEvent>("raven://chat/unread-update", (event) => {
setSessions(event.payload.sessions || [])
}).then((unlisten) => unlisteners.push(unlisten))
// Quando nova mensagem chega
listen<NewMessageEvent>("raven://chat/new-message", (event) => {
setSessions(event.payload.sessions || [])
}).then((unlisten) => unlisteners.push(unlisten))
return () => {
unlisteners.forEach((unlisten) => unlisten())
}
}, [loadSessions])
// Sincronizar estado minimizado com tamanho da janela
useEffect(() => {
const mountTime = Date.now()
const STABILIZATION_DELAY = 500
const handler = () => {
if (Date.now() - mountTime < STABILIZATION_DELAY) {
return
}
const h = window.innerHeight
setIsMinimized(h < 100)
}
window.addEventListener("resize", handler)
return () => window.removeEventListener("resize", handler)
}, [])
const handleSelectSession = async (ticketId: string, ticketRef: number) => {
try {
await invoke("open_chat_window", { ticketId, ticketRef })
} catch (err) {
console.error("Erro ao abrir janela de chat:", err)
}
}
const handleMinimize = async () => {
setIsMinimized(true)
try {
await invoke("set_hub_minimized", { minimized: true })
} catch (err) {
console.error("Erro ao minimizar hub:", err)
}
}
const handleExpand = async () => {
setIsMinimized(false)
try {
await invoke("set_hub_minimized", { minimized: false })
} catch (err) {
console.error("Erro ao expandir hub:", err)
}
}
const handleClose = () => {
invoke("close_hub_window")
}
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
// Loading
if (isLoading) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm font-medium">Carregando...</span>
</div>
</div>
)
}
// Erro
if (error) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<button
type="button"
onClick={() => {
setIsLoading(true)
loadSessions()
}}
className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg transition hover:bg-red-200/60"
title="Tentar novamente"
>
<span className="text-sm font-medium">Erro - Tentar novamente</span>
</button>
</div>
)
}
// Sem sessoes ativas - mostrar chip cinza
if (sessions.length === 0) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
<MessageCircle className="size-4" />
<span className="text-sm font-medium">Sem chats</span>
</div>
</div>
)
}
// Minimizado - mostrar chip com contador
if (isMinimized) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
<button
onClick={handleExpand}
className="pointer-events-auto relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
>
<MessageCircle className="size-4" />
<span className="text-sm font-medium">
{sessions.length} chat{sessions.length !== 1 ? "s" : ""}
</span>
<span className="size-2 rounded-full bg-emerald-400" />
<ChevronUp className="size-4" />
{totalUnread > 0 && (
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
{totalUnread > 9 ? "9+" : totalUnread}
</span>
)}
</button>
</div>
)
}
// Expandido - mostrar lista
return (
<div className="flex h-screen flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
<ChatSessionList
sessions={sessions}
onSelectSession={handleSelectSession}
onClose={handleClose}
onMinimize={handleMinimize}
/>
</div>
)
}

View file

@ -0,0 +1,82 @@
import { MessageCircle } from "lucide-react"
import type { ChatSession } from "./types"
type ChatSessionItemProps = {
session: ChatSession
isActive?: boolean
onClick: () => void
}
function formatTime(timestamp: number) {
const now = Date.now()
const diff = now - timestamp
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return "Agora"
if (minutes < 60) return `${minutes}min`
if (hours < 24) return `${hours}h`
if (days === 1) return "Ontem"
return new Date(timestamp).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
})
}
export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemProps) {
const hasUnread = session.unreadCount > 0
return (
<button
type="button"
onClick={onClick}
className={`flex w-full items-start gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors hover:bg-slate-50 ${
isActive ? "bg-slate-100" : ""
} ${hasUnread ? "bg-red-50/50 hover:bg-red-50" : ""}`}
>
{/* Avatar/Icone */}
<div
className={`flex size-10 shrink-0 items-center justify-center rounded-full ${
hasUnread ? "bg-red-100 text-red-600" : "bg-slate-100 text-slate-600"
}`}
>
<MessageCircle className="size-5" />
</div>
{/* Conteudo */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={`text-sm font-semibold ${hasUnread ? "text-red-700" : "text-slate-900"}`}>
#{session.ticketRef}
</span>
{/* Indicador online - sessao ativa significa online */}
<span className="size-2 rounded-full bg-emerald-500" title="Online" />
</div>
<span className="text-xs text-slate-400">
{formatTime(session.lastActivityAt)}
</span>
</div>
<p className="mt-0.5 truncate text-sm text-slate-600">
{session.ticketSubject}
</p>
<p className="mt-0.5 truncate text-xs text-slate-400">
{session.agentName}
</p>
</div>
{/* Badge de nao lidos */}
{hasUnread && (
<div className="flex shrink-0 items-center justify-center">
<span className="flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{session.unreadCount > 9 ? "9+" : session.unreadCount}
</span>
</div>
)}
</button>
)
}

View file

@ -0,0 +1,99 @@
import { useMemo } from "react"
import { MessageCircle, X } from "lucide-react"
import { ChatSessionItem } from "./ChatSessionItem"
import type { ChatSession } from "./types"
type ChatSessionListProps = {
sessions: ChatSession[]
onSelectSession: (ticketId: string, ticketRef: number) => void
onClose: () => void
onMinimize: () => void
}
export function ChatSessionList({
sessions,
onSelectSession,
onClose,
onMinimize,
}: ChatSessionListProps) {
// Ordenar: nao lidos primeiro, depois por ultima atividade (desc)
const sortedSessions = useMemo(() => {
return [...sessions].sort((a, b) => {
// Nao lidos primeiro
if (a.unreadCount > 0 && b.unreadCount === 0) return -1
if (a.unreadCount === 0 && b.unreadCount > 0) return 1
// Depois por ultima atividade (mais recente primeiro)
return b.lastActivityAt - a.lastActivityAt
})
}, [sessions])
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
return (
<div className="flex h-full flex-col">
{/* Header - arrastavel */}
<div
data-tauri-drag-region
className="flex items-center justify-between rounded-t-2xl border-b border-slate-200 bg-slate-50 px-4 py-3"
>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
</div>
<div>
<p className="text-sm font-semibold text-slate-900">Chats</p>
<p className="text-xs text-slate-500">
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""}
{totalUnread > 0 && (
<span className="ml-1 text-red-500">
({totalUnread} nao lida{totalUnread !== 1 ? "s" : ""})
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={onMinimize}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Minimizar"
>
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<button
onClick={onClose}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Lista de sessoes */}
<div className="flex-1 overflow-y-auto bg-white">
{sortedSessions.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center p-4 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-slate-100">
<MessageCircle className="size-6 text-slate-400" />
</div>
<p className="mt-3 text-sm font-medium text-slate-600">Nenhum chat ativo</p>
<p className="mt-1 text-xs text-slate-400">
Os chats aparecerao aqui quando iniciados
</p>
</div>
) : (
sortedSessions.map((session) => (
<ChatSessionItem
key={session.ticketId}
session={session}
onClick={() => onSelectSession(session.ticketId, session.ticketRef)}
/>
))
)}
</div>
</div>
)
}

View file

@ -1,21 +1,22 @@
import { ChatWidget } from "./ChatWidget"
import { ChatHubWidget } from "./ChatHubWidget"
export function ChatApp() {
// Obter ticketId e ticketRef da URL
const params = new URLSearchParams(window.location.search)
const ticketId = params.get("ticketId")
const ticketRef = params.get("ticketRef")
const isHub = params.get("hub") === "true"
if (!ticketId) {
return (
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
<p className="text-sm text-red-600">Erro: ticketId não fornecido</p>
</div>
)
// Modo hub - lista de todas as sessoes
if (isHub || !ticketId) {
return <ChatHubWidget />
}
// Modo chat - conversa de um ticket especifico
return <ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
}
export { ChatWidget }
export { ChatHubWidget }
export * from "./types"