Fix chat session management and add floating widget

- Fix session sync: events now send complete ChatSession data instead of
  partial ChatSessionSummary, ensuring proper ticket/agent info display
- Add session-ended event detection to remove closed sessions from client
- Add ChatFloatingWidget component for in-app chat experience
- Restrict endSession to ADMIN/MANAGER/AGENT roles only
- Improve polling logic to detect new and ended sessions properly

🤖 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 11:16:56 -03:00
parent e4f8f465de
commit 88a3b37f2f
5 changed files with 680 additions and 92 deletions

View file

@ -43,3 +43,27 @@ export interface SendMessageResponse {
export interface SessionStartedEvent {
session: ChatSession
}
export interface UnreadUpdateEvent {
totalUnread: number
sessions: ChatSession[]
}
export interface NewMessageEvent {
totalUnread: number
newCount: number
sessions: ChatSession[]
}
export interface SessionEndedEvent {
sessionId: string
ticketId: string
}
export interface ChatHistorySession {
sessionId: string
startedAt: number
endedAt: number | null
agentName: string
messages: ChatMessage[]
}

View file

@ -0,0 +1,436 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { invoke } from "@tauri-apps/api/core"
import { Store } from "@tauri-apps/plugin-store"
import { MessageCircle, X, Minus, Send, Loader2, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react"
import { cn } from "../lib/utils"
import type { ChatSession, ChatMessage, ChatMessagesResponse, SendMessageResponse, ChatHistorySession } from "../chat/types"
interface ChatFloatingWidgetProps {
sessions: ChatSession[]
totalUnread: number
isOpen: boolean
onToggle: () => void
onMinimize: () => void
}
export function ChatFloatingWidget({
sessions,
totalUnread,
isOpen,
onToggle,
onMinimize,
}: ChatFloatingWidgetProps) {
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [isSending, setIsSending] = useState(false)
const [historyExpanded, setHistoryExpanded] = useState(false)
const [historySessions] = useState<ChatHistorySession[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null)
const lastFetchRef = useRef<number>(0)
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Selecionar ticket mais recente automaticamente
useEffect(() => {
if (sessions.length > 0 && !selectedTicketId) {
// Ordenar por lastActivityAt e pegar o mais recente
const sorted = [...sessions].sort((a, b) => b.lastActivityAt - a.lastActivityAt)
setSelectedTicketId(sorted[0].ticketId)
}
}, [sessions, selectedTicketId])
// Scroll para o final quando novas mensagens chegam
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [])
useEffect(() => {
scrollToBottom()
}, [messages, scrollToBottom])
// Carregar configuracao do store
const loadConfig = useCallback(async () => {
try {
const store = await Store.load("machine-agent.json")
const token = await store.get<string>("token")
const config = await store.get<{ apiBaseUrl: string }>("config")
if (!token || !config?.apiBaseUrl) {
return null
}
return { token, baseUrl: config.apiBaseUrl }
} catch {
return null
}
}, [])
// Buscar mensagens
const fetchMessages = useCallback(async (baseUrl: string, token: string, ticketId: string, since?: number) => {
try {
const response = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
baseUrl,
token,
ticketId,
since: since ?? null,
})
if (response.messages.length > 0) {
if (since) {
setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id))
const newMsgs = response.messages.filter(m => !existingIds.has(m.id))
return [...prev, ...newMsgs]
})
} else {
setMessages(response.messages)
}
lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt))
}
return response
} catch (err) {
console.error("Erro ao buscar mensagens:", err)
return null
}
}, [])
// Inicializar e fazer polling quando ticket selecionado
useEffect(() => {
if (!selectedTicketId || !isOpen) return
let mounted = true
const init = async () => {
setIsLoading(true)
const config = await loadConfig()
if (!config || !mounted) {
setIsLoading(false)
return
}
const { baseUrl, token } = config
// Buscar mensagens iniciais
await fetchMessages(baseUrl, token, selectedTicketId)
if (!mounted) return
setIsLoading(false)
// Iniciar polling (2 segundos)
pollIntervalRef.current = setInterval(async () => {
await fetchMessages(baseUrl, token, selectedTicketId, lastFetchRef.current)
}, 2000)
}
init()
return () => {
mounted = false
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current)
pollIntervalRef.current = null
}
}
}, [selectedTicketId, isOpen, loadConfig, fetchMessages])
// Limpar mensagens quando trocar de ticket
useEffect(() => {
setMessages([])
lastFetchRef.current = 0
}, [selectedTicketId])
// Enviar mensagem
const handleSend = async () => {
if (!inputValue.trim() || isSending || !selectedTicketId) return
const messageText = inputValue.trim()
setInputValue("")
setIsSending(true)
try {
const config = await loadConfig()
if (!config) {
setIsSending(false)
return
}
const response = await invoke<SendMessageResponse>("send_chat_message", {
baseUrl: config.baseUrl,
token: config.token,
ticketId: selectedTicketId,
body: messageText,
})
setMessages(prev => [...prev, {
id: response.messageId,
body: messageText,
authorName: "Voce",
isFromMachine: true,
createdAt: response.createdAt,
attachments: [],
}])
lastFetchRef.current = response.createdAt
} catch (err) {
console.error("Erro ao enviar mensagem:", err)
setInputValue(messageText)
} finally {
setIsSending(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const currentSession = sessions.find(s => s.ticketId === selectedTicketId)
// Botao flutuante (fechado)
if (!isOpen) {
return (
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={onToggle}
className="relative flex size-14 items-center justify-center rounded-full bg-black text-white shadow-lg transition hover:bg-black/90"
>
<MessageCircle className="size-6" />
{totalUnread > 0 && (
<>
<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>
</div>
)
}
// Widget expandido
return (
<div className="fixed bottom-4 right-4 z-50 flex h-[520px] w-[380px] flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl">
<div className="flex items-center gap-3">
{sessions.length > 1 && selectedTicketId && (
<button
onClick={() => setSelectedTicketId(null)}
className="rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<ChevronLeft className="size-4" />
</button>
)}
<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">
{currentSession?.agentName ?? "Suporte"}
</p>
{currentSession && (
<p className="text-xs text-slate-500">
Chamado #{currentSession.ticketRef}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
{/* Tabs de tickets (se houver mais de 1) */}
{sessions.length > 1 && (
<div className="mr-2 flex items-center gap-1">
{sessions.slice(0, 3).map((session) => (
<button
key={session.ticketId}
onClick={() => setSelectedTicketId(session.ticketId)}
className={cn(
"rounded px-2 py-1 text-xs font-medium transition",
session.ticketId === selectedTicketId
? "bg-black text-white"
: "bg-slate-200 text-slate-600 hover:bg-slate-300"
)}
>
#{session.ticketRef}
{session.unreadCount > 0 && (
<span className="ml-1 inline-flex size-4 items-center justify-center rounded-full bg-red-500 text-[10px] text-white">
{session.unreadCount}
</span>
)}
</button>
))}
{sessions.length > 3 && (
<span className="text-xs text-slate-400">+{sessions.length - 3}</span>
)}
</div>
)}
<button
onClick={onMinimize}
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<Minus className="size-4" />
</button>
<button
onClick={onMinimize}
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Selecao de ticket (se nenhum selecionado e ha multiplos) */}
{!selectedTicketId && sessions.length > 1 ? (
<div className="flex-1 overflow-y-auto p-4">
<p className="mb-3 text-sm font-medium text-slate-700">Selecione um chamado:</p>
<div className="space-y-2">
{sessions.map((session) => (
<button
key={session.ticketId}
onClick={() => setSelectedTicketId(session.ticketId)}
className="w-full rounded-lg border border-slate-200 p-3 text-left transition hover:border-slate-300 hover:bg-slate-50"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-900">
#{session.ticketRef}
</span>
{session.unreadCount > 0 && (
<span className="inline-flex size-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">
{session.unreadCount}
</span>
)}
</div>
<p className="mt-1 truncate text-xs text-slate-500">
{session.ticketSubject}
</p>
<p className="mt-1 text-xs text-slate-400">
{session.agentName}
</p>
</button>
))}
</div>
</div>
) : (
<>
{/* Area de mensagens */}
<div className="flex-1 overflow-y-auto p-4">
{/* Historico de sessoes anteriores */}
{historySessions.length > 0 && (
<div className="mb-4">
<button
onClick={() => setHistoryExpanded(!historyExpanded)}
className="flex w-full items-center justify-between rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-600"
>
<span>Historico ({historySessions.length} sessoes)</span>
{historyExpanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
{historyExpanded && (
<div className="mt-2 space-y-2 rounded-lg border border-slate-200 p-2">
{historySessions.map((session) => (
<div key={session.sessionId} className="text-xs text-slate-500">
<p className="font-medium">{session.agentName}</p>
<p>{session.messages.length} mensagens</p>
</div>
))}
</div>
)}
</div>
)}
{isLoading ? (
<div className="flex h-full flex-col items-center justify-center">
<Loader2 className="size-8 animate-spin text-slate-400" />
<p className="mt-2 text-sm text-slate-500">Carregando...</p>
</div>
) : messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<p className="text-sm text-slate-400">
Nenhuma mensagem ainda
</p>
<p className="mt-1 text-xs text-slate-400">
O agente iniciara a conversa em breve
</p>
</div>
) : (
<div className="space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.isFromMachine ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
msg.isFromMachine
? "bg-black text-white"
: "bg-slate-100 text-slate-900"
}`}
>
{!msg.isFromMachine && (
<p className="mb-1 text-xs font-medium text-slate-500">
{msg.authorName}
</p>
)}
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
<p
className={`mt-1 text-right text-xs ${
msg.isFromMachine ? "text-white/60" : "text-slate-400"
}`}
>
{formatTime(msg.createdAt)}
</p>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div className="border-t border-slate-200 p-3 rounded-b-2xl">
<div className="flex items-end gap-2">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="max-h-24 min-h-[40px] flex-1 resize-none rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-1 focus:ring-black"
rows={1}
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isSending}
className="flex size-10 items-center justify-center rounded-lg bg-black text-white transition hover:bg-black/90 disabled:opacity-50"
>
{isSending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</button>
</div>
</div>
</>
)}
</div>
)
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp)
return date.toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
})
}

View file

@ -10,6 +10,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
import { cn } from "./lib/utils"
import { ChatApp } from "./chat"
import { DeactivationScreen } from "./components/DeactivationScreen"
import { ChatFloatingWidget } from "./components/ChatFloatingWidget"
import type { ChatSession, SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types"
type MachineOs = {
name: string
@ -338,6 +340,11 @@ function App() {
const emailRegex = useRef(/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i)
const isEmailValid = useMemo(() => emailRegex.current.test(collabEmail.trim()), [collabEmail])
// Estados do chat
const [chatSessions, setChatSessions] = useState<ChatSession[]>([])
const [chatUnreadCount, setChatUnreadCount] = useState(0)
const [isChatOpen, setIsChatOpen] = useState(false)
const ensureProfile = useCallback(async () => {
if (profile) return profile
const fresh = await invoke<MachineProfile>("collect_machine_profile")
@ -1032,6 +1039,81 @@ const resolvedAppUrl = useMemo(() => {
}
}, [store, config?.machineId, rustdeskInfo, isRustdeskProvisioning, ensureRustdesk, syncRemoteAccessDirect])
// Listeners de eventos do chat
useEffect(() => {
if (!token) return
let disposed = false
const unlisteners: Array<() => void> = []
// Listener para nova sessao de chat
listen<SessionStartedEvent>("raven://chat/session-started", (event) => {
if (disposed) return
logDesktop("chat:session-started", { ticketId: event.payload.session.ticketId, sessionId: event.payload.session.sessionId })
setChatSessions(prev => {
// Evitar duplicatas
if (prev.some(s => s.sessionId === event.payload.session.sessionId)) {
return prev
}
return [...prev, event.payload.session]
})
setIsChatOpen(true) // Abre automaticamente quando agente inicia chat
}).then(unlisten => {
if (disposed) unlisten()
else unlisteners.push(unlisten)
}).catch(err => console.error("Falha ao registrar listener session-started:", err))
// Listener para sessao encerrada
listen<SessionEndedEvent>("raven://chat/session-ended", (event) => {
if (disposed) return
logDesktop("chat:session-ended", { ticketId: event.payload.ticketId, sessionId: event.payload.sessionId })
setChatSessions(prev => prev.filter(s => s.sessionId !== event.payload.sessionId))
}).then(unlisten => {
if (disposed) unlisten()
else unlisteners.push(unlisten)
}).catch(err => console.error("Falha ao registrar listener session-ended:", err))
// Listener para atualizacao de mensagens nao lidas (sincroniza sessoes completas)
listen<UnreadUpdateEvent>("raven://chat/unread-update", (event) => {
if (disposed) return
logDesktop("chat:unread-update", { totalUnread: event.payload.totalUnread, sessionsCount: event.payload.sessions.length })
setChatUnreadCount(event.payload.totalUnread)
// Atualiza sessoes com dados completos do backend
if (event.payload.sessions && event.payload.sessions.length > 0) {
setChatSessions(event.payload.sessions)
} else if (event.payload.totalUnread === 0) {
// Sem sessoes ativas
setChatSessions([])
}
}).then(unlisten => {
if (disposed) unlisten()
else unlisteners.push(unlisten)
}).catch(err => console.error("Falha ao registrar listener unread-update:", err))
// Listener para nova mensagem (abre widget se fechado)
listen<NewMessageEvent>("raven://chat/new-message", (event) => {
if (disposed) return
logDesktop("chat:new-message", { totalUnread: event.payload.totalUnread, newCount: event.payload.newCount })
setChatUnreadCount(event.payload.totalUnread)
// Atualiza sessoes com dados completos do backend
if (event.payload.sessions && event.payload.sessions.length > 0) {
setChatSessions(event.payload.sessions)
}
// Abre o widget quando chega nova mensagem
if (event.payload.newCount > 0) {
setIsChatOpen(true)
}
}).then(unlisten => {
if (disposed) unlisten()
else unlisteners.push(unlisten)
}).catch(err => console.error("Falha ao registrar listener new-message:", err))
return () => {
disposed = true
unlisteners.forEach(unlisten => unlisten())
}
}, [token])
async function register() {
if (!profile) return
const trimmedCode = provisioningCode.trim().toLowerCase()
@ -1617,6 +1699,17 @@ const resolvedAppUrl = useMemo(() => {
)}
</div>
)}
{/* Chat Widget Flutuante - aparece quando provisionado e ha sessoes ativas */}
{token && isMachineActive && chatSessions.length > 0 && (
<ChatFloatingWidget
sessions={chatSessions}
totalUnread={chatUnreadCount}
isOpen={isChatOpen}
onToggle={() => setIsChatOpen(!isChatOpen)}
onMinimize={() => setIsChatOpen(false)}
/>
)}
</div>
)
}