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:
parent
e4f8f465de
commit
88a3b37f2f
5 changed files with 680 additions and 92 deletions
|
|
@ -322,102 +322,131 @@ impl ChatRuntime {
|
|||
Ok(result) => {
|
||||
last_checked_at = Some(chrono::Utc::now().timestamp_millis());
|
||||
|
||||
if result.has_active_sessions {
|
||||
// Verificar novas sessoes
|
||||
let prev_sessions: Vec<String> = {
|
||||
last_sessions.lock().iter().map(|s| s.session_id.clone()).collect()
|
||||
};
|
||||
// Buscar sessoes completas para ter dados corretos
|
||||
let current_sessions = if result.has_active_sessions {
|
||||
fetch_sessions(&base_clone, &token_clone).await.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Buscar detalhes das sessoes
|
||||
if let Ok(sessions) = fetch_sessions(&base_clone, &token_clone).await {
|
||||
for session in &sessions {
|
||||
if !prev_sessions.contains(&session.session_id) {
|
||||
// Nova sessao! Emitir evento
|
||||
crate::log_info!(
|
||||
"Nova sessao de chat: ticket={}",
|
||||
session.ticket_id
|
||||
);
|
||||
let _ = app.emit(
|
||||
"raven://chat/session-started",
|
||||
SessionStartedEvent {
|
||||
session: session.clone(),
|
||||
},
|
||||
);
|
||||
// Verificar sessoes anteriores
|
||||
let prev_sessions: Vec<ChatSession> = last_sessions.lock().clone();
|
||||
let prev_session_ids: Vec<String> = prev_sessions.iter().map(|s| s.session_id.clone()).collect();
|
||||
let current_session_ids: Vec<String> = current_sessions.iter().map(|s| s.session_id.clone()).collect();
|
||||
|
||||
// Enviar notificacao nativa do Windows
|
||||
// A janela de chat NAO abre automaticamente -
|
||||
// o usuario deve clicar na notificacao ou no tray
|
||||
let notification_title = format!(
|
||||
"Chat iniciado - Chamado #{}",
|
||||
session.ticket_ref
|
||||
);
|
||||
let notification_body = format!(
|
||||
"{} iniciou um chat de suporte.\nClique no icone do Raven para abrir.",
|
||||
session.agent_name
|
||||
);
|
||||
if let Err(e) = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title(¬ification_title)
|
||||
.body(¬ification_body)
|
||||
.show()
|
||||
{
|
||||
crate::log_warn!(
|
||||
"Falha ao enviar notificacao: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Atualizar cache
|
||||
*last_sessions.lock() = sessions;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Detectar novas sessoes
|
||||
for session in ¤t_sessions {
|
||||
if !prev_session_ids.contains(&session.session_id) {
|
||||
// Nova sessao! Emitir evento
|
||||
crate::log_info!(
|
||||
"Chat: {} mensagens nao lidas (prev={})",
|
||||
result.total_unread,
|
||||
prev_unread
|
||||
"Nova sessao de chat: ticket={}, session={}",
|
||||
session.ticket_id,
|
||||
session.session_id
|
||||
);
|
||||
let _ = app.emit(
|
||||
"raven://chat/unread-update",
|
||||
serde_json::json!({
|
||||
"totalUnread": result.total_unread,
|
||||
"sessions": result.sessions
|
||||
}),
|
||||
"raven://chat/session-started",
|
||||
SessionStartedEvent {
|
||||
session: session.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
// 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(¬ification_body)
|
||||
.show()
|
||||
{
|
||||
crate::log_warn!(
|
||||
"Falha ao enviar notificacao de nova mensagem: {e}"
|
||||
);
|
||||
}
|
||||
// NAO foca a janela automaticamente - usuario abre manualmente
|
||||
// Enviar notificacao nativa do Windows
|
||||
let notification_title = format!(
|
||||
"Chat iniciado - Chamado #{}",
|
||||
session.ticket_ref
|
||||
);
|
||||
let notification_body = format!(
|
||||
"{} iniciou um chat de suporte.\nClique no icone do Raven para abrir.",
|
||||
session.agent_name
|
||||
);
|
||||
if let Err(e) = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title(¬ification_title)
|
||||
.body(¬ification_body)
|
||||
.show()
|
||||
{
|
||||
crate::log_warn!(
|
||||
"Falha ao enviar notificacao de nova sessao: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sem sessoes ativas
|
||||
*last_sessions.lock() = Vec::new();
|
||||
}
|
||||
|
||||
// Detectar sessoes encerradas
|
||||
for prev_session in &prev_sessions {
|
||||
if !current_session_ids.contains(&prev_session.session_id) {
|
||||
// Sessao foi encerrada! Emitir evento
|
||||
crate::log_info!(
|
||||
"Sessao de chat encerrada: ticket={}, session={}",
|
||||
prev_session.ticket_id,
|
||||
prev_session.session_id
|
||||
);
|
||||
let _ = app.emit(
|
||||
"raven://chat/session-ended",
|
||||
serde_json::json!({
|
||||
"sessionId": prev_session.session_id,
|
||||
"ticketId": prev_session.ticket_id
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Atualizar cache de sessoes
|
||||
*last_sessions.lock() = current_sessions.clone();
|
||||
|
||||
// Verificar mensagens nao lidas
|
||||
let prev_unread = *last_unread_count.lock();
|
||||
let new_messages = result.total_unread > prev_unread;
|
||||
*last_unread_count.lock() = result.total_unread;
|
||||
|
||||
// Sempre emitir unread-update com sessoes completas
|
||||
let _ = app.emit(
|
||||
"raven://chat/unread-update",
|
||||
serde_json::json!({
|
||||
"totalUnread": result.total_unread,
|
||||
"sessions": current_sessions
|
||||
}),
|
||||
);
|
||||
|
||||
// Notificar novas mensagens (quando aumentou)
|
||||
if new_messages && result.total_unread > 0 {
|
||||
let new_count = result.total_unread - prev_unread;
|
||||
|
||||
crate::log_info!(
|
||||
"Chat: {} novas mensagens (total={})",
|
||||
new_count,
|
||||
result.total_unread
|
||||
);
|
||||
|
||||
// Emitir evento para o frontend atualizar UI
|
||||
let _ = app.emit(
|
||||
"raven://chat/new-message",
|
||||
serde_json::json!({
|
||||
"totalUnread": result.total_unread,
|
||||
"newCount": new_count,
|
||||
"sessions": current_sessions
|
||||
}),
|
||||
);
|
||||
|
||||
// Enviar notificacao nativa do Windows
|
||||
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(¬ification_body)
|
||||
.show()
|
||||
{
|
||||
crate::log_warn!(
|
||||
"Falha ao enviar notificacao de nova mensagem: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
436
apps/desktop/src/components/ChatFloatingWidget.tsx
Normal file
436
apps/desktop/src/components/ChatFloatingWidget.tsx
Normal 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",
|
||||
})
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ export const startSession = mutation({
|
|||
},
|
||||
})
|
||||
|
||||
// Agente encerra sessao de chat
|
||||
// Agente encerra sessao de chat (somente ADMIN, MANAGER ou AGENT podem encerrar)
|
||||
export const endSession = mutation({
|
||||
args: {
|
||||
sessionId: v.id("liveChatSessions"),
|
||||
|
|
@ -152,12 +152,18 @@ export const endSession = mutation({
|
|||
throw new ConvexError("Sessão não encontrada")
|
||||
}
|
||||
|
||||
// Verificar permissao
|
||||
const agent = await ctx.db.get(actorId)
|
||||
if (!agent || agent.tenantId !== session.tenantId) {
|
||||
// Verificar permissao do usuario
|
||||
const actor = await ctx.db.get(actorId)
|
||||
if (!actor || actor.tenantId !== session.tenantId) {
|
||||
throw new ConvexError("Acesso negado")
|
||||
}
|
||||
|
||||
// Somente ADMIN, MANAGER ou AGENT podem encerrar sessoes de chat
|
||||
const role = actor.role?.toUpperCase() ?? ""
|
||||
if (!["ADMIN", "MANAGER", "AGENT"].includes(role)) {
|
||||
throw new ConvexError("Apenas agentes de suporte podem encerrar sessões de chat")
|
||||
}
|
||||
|
||||
if (session.status !== "ACTIVE") {
|
||||
throw new ConvexError("Sessão já encerrada")
|
||||
}
|
||||
|
|
@ -179,7 +185,7 @@ export const endSession = mutation({
|
|||
payload: {
|
||||
sessionId,
|
||||
agentId: actorId,
|
||||
agentName: agent.name,
|
||||
agentName: actor.name,
|
||||
durationMs,
|
||||
startedAt: session.startedAt,
|
||||
endedAt: now,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue