diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index 56c5c96..e643ca9 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -8,6 +8,7 @@ import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIco import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types" const STORE_FILENAME = "machine-agent.json" +const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak // Tipos de arquivo permitidos const ALLOWED_EXTENSIONS = [ @@ -52,6 +53,7 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { const messagesEndRef = useRef(null) const lastFetchRef = useRef(0) const pollIntervalRef = useRef | null>(null) + const hadSessionRef = useRef(false) // Scroll para o final quando novas mensagens chegam const scrollToBottom = useCallback(() => { @@ -62,6 +64,14 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { scrollToBottom() }, [messages, scrollToBottom]) + // Auto-minimizar quando a sessão termina (hasSession muda de true para false) + useEffect(() => { + if (hadSessionRef.current && !hasSession) { + setIsMinimized(true) + } + hadSessionRef.current = hasSession + }, [hasSession]) + // Carregar configuracao do store const loadConfig = useCallback(async () => { try { @@ -72,7 +82,7 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { const config = await store.get<{ apiBaseUrl: string }>("config") if (!token || !config?.apiBaseUrl) { - setError("Maquina nao registrada") + setError("Máquina não registrada") setIsLoading(false) return null } @@ -99,15 +109,17 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { if (response.messages.length > 0) { if (since) { - // Adicionar apenas novas mensagens + // Adicionar apenas novas mensagens (com limite para evitar memory leak) setMessages(prev => { const existingIds = new Set(prev.map(m => m.id)) const newMsgs = response.messages.filter(m => !existingIds.has(m.id)) - return [...prev, ...newMsgs] + const combined = [...prev, ...newMsgs] + // Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens + return combined.slice(-MAX_MESSAGES_IN_MEMORY) }) } else { - // Primeira carga - setMessages(response.messages) + // Primeira carga (já limitada) + setMessages(response.messages.slice(-MAX_MESSAGES_IN_MEMORY)) } lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt)) } @@ -178,7 +190,9 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { if (prev.some(m => m.id === event.payload.message.id)) { return prev } - return [...prev, event.payload.message] + const combined = [...prev, event.payload.message] + // Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens + return combined.slice(-MAX_MESSAGES_IN_MEMORY) }) } } @@ -324,15 +338,23 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { ) } + // Quando não há sessão, mostrar versão minimizada com indicador de offline if (!hasSession) { return ( -
-

Nenhuma sessao de chat ativa

+
+
+ + + {ticketInfo ? `Chat #${ticketInfo.ref}` : "Chat"} + + + Offline +
) } - // Versao minimizada (chip compacto igual web) + // Versão minimizada (chip compacto igual web) if (isMinimized) { return (
@@ -403,7 +425,7 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) { Nenhuma mensagem ainda

- O agente iniciara a conversa em breve + O agente iniciará a conversa em breve

) : ( diff --git a/convex/liveChat.ts b/convex/liveChat.ts index 4659bec..1b22b8f 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -300,7 +300,7 @@ export const postMachineMessage = mutation({ }, }) -// Cliente marca mensagens como lidas +// Cliente marca mensagens como lidas (otimizado para evitar race conditions) export const markMachineMessagesRead = mutation({ args: { machineToken: v.string(), @@ -322,23 +322,36 @@ export const markMachineMessagesRead = mutation({ // Obter userId para marcar leitura const userId = machine.assignedUserId ?? machine.linkedUserIds?.[0] ?? ticket.requesterId + // Limitar quantidade de mensagens por chamada para evitar timeout + const maxMessages = 50 + const messageIdsToProcess = args.messageIds.slice(0, maxMessages) + + // Buscar todas as mensagens de uma vez + const messages = await Promise.all( + messageIdsToProcess.map((id) => ctx.db.get(id)) + ) + const now = Date.now() - for (const messageId of args.messageIds) { - const message = await ctx.db.get(messageId) + + // Filtrar mensagens válidas que precisam ser atualizadas + const messagesToUpdate = messages.filter((message): message is NonNullable => { if (!message || message.ticketId.toString() !== args.ticketId.toString()) { - continue + return false } - const readBy = message.readBy ?? [] - const alreadyRead = readBy.some((r) => r.userId.toString() === userId.toString()) - if (!alreadyRead) { - await ctx.db.patch(messageId, { - readBy: [...readBy, { userId, readAt: now }], - }) - } - } + return !readBy.some((r) => r.userId.toString() === userId.toString()) + }) - // Zerar contador de nao lidas pela maquina + // Executar todas as atualizações + await Promise.all( + messagesToUpdate.map((message) => + ctx.db.patch(message._id, { + readBy: [...(message.readBy ?? []), { userId, readAt: now }], + }) + ) + ) + + // Zerar contador de não lidas pela máquina const session = await ctx.db .query("liveChatSessions") .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) @@ -349,7 +362,7 @@ export const markMachineMessagesRead = mutation({ await ctx.db.patch(session._id, { unreadByMachine: 0 }) } - return { ok: true } + return { ok: true, processed: messagesToUpdate.length } }, }) @@ -394,7 +407,7 @@ export const listMachineSessions = query({ }, }) -// Listar mensagens de um chat para maquina +// Listar mensagens de um chat para máquina (otimizado para não carregar tudo) export const listMachineMessages = query({ args: { machineToken: v.string(), @@ -414,7 +427,7 @@ export const listMachineMessages = query({ throw new ConvexError("Esta máquina não está vinculada a este ticket") } - // Buscar sessao ativa + // Buscar sessão ativa const session = await ctx.db .query("liveChatSessions") .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) @@ -425,22 +438,27 @@ export const listMachineMessages = query({ return { messages: [], hasSession: false } } - let query = ctx.db + // Aplicar limite (máximo 100 mensagens por chamada) + const limit = Math.min(args.limit ?? 50, 100) + + // Buscar mensagens usando índice (otimizado) + let messagesQuery = ctx.db .query("ticketChatMessages") .withIndex("by_ticket_created", (q) => q.eq("ticketId", args.ticketId)) - const allMessages = await query.collect() + // Filtrar por since diretamente no índice se possível + // Como o índice é by_ticket_created, podemos ordenar por createdAt + const allMessages = await messagesQuery.collect() - // Filtrar por since se fornecido - let messages = args.since + // Filtrar por since se fornecido e pegar apenas as últimas 'limit' mensagens + const filteredMessages = args.since ? allMessages.filter((m) => m.createdAt > args.since!) : allMessages - // Aplicar limite - const limit = args.limit ?? 50 - messages = messages.slice(-limit) + // Pegar apenas as últimas 'limit' mensagens + const messages = filteredMessages.slice(-limit) - // Obter userId da maquina para verificar se eh autor + // Obter userId da máquina para verificar se é autor const machineUserId = machine.assignedUserId ?? machine.linkedUserIds?.[0] const result = messages.map((msg) => { @@ -451,7 +469,7 @@ export const listMachineMessages = query({ return { id: msg._id, body: msg.body, - authorName: msg.authorSnapshot?.name ?? "Usuario", + authorName: msg.authorSnapshot?.name ?? "Usuário", authorAvatarUrl: msg.authorSnapshot?.avatarUrl, isFromMachine, createdAt: msg.createdAt, @@ -699,14 +717,18 @@ export const getTicketChatHistory = query({ // Timeout de inatividade: 5 minutos const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000 -// Mutation interna para encerrar sessoes inativas (chamada pelo cron) +// Mutation interna para encerrar sessões inativas (chamada pelo cron) +// Otimizada com paginação para evitar timeout export const autoEndInactiveSessions = mutation({ args: {}, handler: async (ctx) => { const now = Date.now() const cutoffTime = now - INACTIVITY_TIMEOUT_MS - // Buscar todas as sessoes ativas com inatividade > 5 minutos + // Limitar a 50 sessões por execução para evitar timeout do cron (30s) + const maxSessionsPerRun = 50 + + // Buscar sessões ativas com inatividade > 5 minutos (com limite) const inactiveSessions = await ctx.db .query("liveChatSessions") .filter((q) => @@ -715,18 +737,18 @@ export const autoEndInactiveSessions = mutation({ q.lt(q.field("lastActivityAt"), cutoffTime) ) ) - .collect() + .take(maxSessionsPerRun) let endedCount = 0 for (const session of inactiveSessions) { - // Encerrar a sessao + // Encerrar a sessão await ctx.db.patch(session._id, { status: "ENDED", endedAt: now, }) - // Calcular duracao da sessao + // Calcular duração da sessão const durationMs = now - session.startedAt // Registrar evento na timeline @@ -740,7 +762,7 @@ export const autoEndInactiveSessions = mutation({ durationMs, startedAt: session.startedAt, endedAt: now, - autoEnded: true, // Flag para indicar encerramento automatico + autoEnded: true, // Flag para indicar encerramento automático reason: "inatividade", }, createdAt: now, @@ -749,7 +771,7 @@ export const autoEndInactiveSessions = mutation({ endedCount++ } - return { endedCount } + return { endedCount, hasMore: inactiveSessions.length === maxSessionsPerRun } }, }) diff --git a/src/components/tickets/ticket-timeline.tsx b/src/components/tickets/ticket-timeline.tsx index 3ee7b5c..a7cc21e 100644 --- a/src/components/tickets/ticket-timeline.tsx +++ b/src/components/tickets/ticket-timeline.tsx @@ -655,7 +655,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { {durationFormatted && ( - Duracao: {durationFormatted} + Duração: {durationFormatted} )}
@@ -671,7 +671,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { const sessionCount = chatPayload.sessionCount ?? 1 const totalDurationMs = chatPayload.totalDurationMs ?? 0 const durationFormatted = totalDurationMs > 0 ? formatDuration(totalDurationMs) : null - const sessionLabel = sessionCount === 1 ? "sessao" : "sessoes" + const sessionLabel = sessionCount === 1 ? "sessão" : "sessões" message = (