- 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>
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|