fix(chat): melhora realtime e anexos no desktop

This commit is contained in:
esdrasrenan 2025-12-12 21:36:32 -03:00
parent 3d45fe3b04
commit 8cf13c43de
5 changed files with 603 additions and 141 deletions

View file

@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tauri::async_runtime::JoinHandle;
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
use tauri_plugin_notification::NotificationExt;
@ -509,14 +509,40 @@ impl ChatRuntime {
let is_connected = self.is_connected.clone();
let join_handle = tauri::async_runtime::spawn(async move {
crate::log_info!("Chat iniciando via Convex WebSocket");
crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)");
let mut backoff_ms: u64 = 1_000;
let max_backoff_ms: u64 = 30_000;
let poll_interval = Duration::from_secs(5);
let mut last_poll = Instant::now() - poll_interval;
loop {
if stop_clone.load(Ordering::Relaxed) {
break;
}
let client_result = ConvexClient::new(&convex_clone).await;
let mut client = match client_result {
Ok(c) => c,
Err(err) => {
is_connected.store(false, Ordering::Relaxed);
crate::log_warn!("Falha ao criar cliente Convex: {err:?}");
return;
if last_poll.elapsed() >= poll_interval {
poll_and_process_chat_update(
&base_clone,
&token_clone,
&app,
&last_sessions,
&last_unread_count,
)
.await;
last_poll = Instant::now();
}
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms);
continue;
}
};
@ -527,11 +553,28 @@ impl ChatRuntime {
let mut subscription = match subscribe_result {
Ok(sub) => {
is_connected.store(true, Ordering::Relaxed);
backoff_ms = 1_000;
sub
}
Err(err) => {
is_connected.store(false, Ordering::Relaxed);
crate::log_warn!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}");
return;
if last_poll.elapsed() >= poll_interval {
poll_and_process_chat_update(
&base_clone,
&token_clone,
&app,
&last_sessions,
&last_unread_count,
)
.await;
last_poll = Instant::now();
}
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms);
continue;
}
};
@ -581,7 +624,30 @@ impl ChatRuntime {
}
is_connected.store(false, Ordering::Relaxed);
crate::log_info!("Chat encerrado (Convex WebSocket finalizado)");
if stop_clone.load(Ordering::Relaxed) {
break;
}
crate::log_warn!("Chat realtime desconectado; aplicando fallback e tentando reconectar");
if last_poll.elapsed() >= poll_interval {
poll_and_process_chat_update(
&base_clone,
&token_clone,
&app,
&last_sessions,
&last_unread_count,
)
.await;
last_poll = Instant::now();
}
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms);
}
is_connected.store(false, Ordering::Relaxed);
crate::log_info!("Chat encerrado (realtime finalizado)");
});
let mut guard = self.inner.lock();
@ -610,6 +676,32 @@ impl ChatRuntime {
// SHARED UPDATE PROCESSING
// ============================================================================
async fn poll_and_process_chat_update(
base_url: &str,
token: &str,
app: &tauri::AppHandle,
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
last_unread_count: &Arc<Mutex<u32>>,
) {
match poll_chat_updates(base_url, token, None).await {
Ok(result) => {
process_chat_update(
base_url,
token,
app,
last_sessions,
last_unread_count,
result.has_active_sessions,
result.total_unread,
)
.await;
}
Err(err) => {
crate::log_warn!("Chat fallback poll falhou: {err}");
}
}
}
async fn process_chat_update(
base_url: &str,
token: &str,
@ -620,12 +712,21 @@ async fn process_chat_update(
total_unread: u32,
) {
// Buscar sessoes completas para ter dados corretos
let current_sessions = if has_active_sessions {
let mut current_sessions = if has_active_sessions {
fetch_sessions(base_url, token).await.unwrap_or_default()
} else {
Vec::new()
};
// Ordenar por ultima atividade (mais recente primeiro) para consistencia em UI/tray.
if current_sessions.len() > 1 {
current_sessions.sort_by(|a, b| {
b.last_activity_at
.cmp(&a.last_activity_at)
.then_with(|| b.started_at.cmp(&a.started_at))
});
}
// 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();
@ -706,8 +807,49 @@ async fn process_chat_update(
}),
);
// Escolher qual sessao/ticket deve ser mostrado quando ha multiplas sessoes.
// Preferencia: maior incremento de unread (delta) e, em empate, ultima atividade mais recente.
let mut best_session: Option<&ChatSession> = None;
let mut best_delta: u32 = 0;
for session in &current_sessions {
let prev_unread_for_ticket = prev_sessions
.iter()
.find(|s| s.ticket_id == session.ticket_id)
.map(|s| s.unread_count)
.unwrap_or(0);
let delta = session.unread_count.saturating_sub(prev_unread_for_ticket);
let is_better = if delta > best_delta {
true
} else if delta == best_delta {
match best_session {
Some(best) => session.last_activity_at > best.last_activity_at,
None => true,
}
} else {
false
};
if is_better {
best_delta = delta;
best_session = Some(session);
}
}
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
let session_to_show = if best_delta > 0 {
best_session
} else {
current_sessions.iter().max_by(|a, b| {
a.unread_count
.cmp(&b.unread_count)
.then_with(|| a.last_activity_at.cmp(&b.last_activity_at))
})
};
// Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra)
if let Some(session) = current_sessions.first() {
if let Some(session) = session_to_show {
let label = format!("chat-{}", session.ticket_id);
if let Some(window) = app.get_webview_window(&label) {
// Janela ja existe - apenas mostrar (NAO minimizar se estiver expandida)
@ -749,6 +891,46 @@ async fn process_chat_update(
// WINDOW MANAGEMENT
// ============================================================================
fn resolve_chat_window_position(
app: &tauri::AppHandle,
window: Option<&tauri::WebviewWindow>,
width: f64,
height: f64,
) -> (f64, f64) {
let margin = 20.0;
let taskbar_height = 50.0;
let monitor = window
.and_then(|w| w.current_monitor().ok().flatten())
.or_else(|| {
app.get_webview_window("main")
.and_then(|w| w.current_monitor().ok().flatten())
})
.or_else(|| app.available_monitors().ok().and_then(|monitors| monitors.into_iter().next()));
let Some(monitor) = monitor else {
return (100.0, 100.0);
};
let size = monitor.size();
let pos = monitor.position();
let scale = monitor.scale_factor();
// Converter coordenadas do monitor para coordenadas logicas (multi-monitor pode ter origem negativa).
let monitor_x = pos.x as f64 / scale;
let monitor_y = pos.y as f64 / scale;
let monitor_width = size.width as f64 / scale;
let monitor_height = size.height as f64 / scale;
let max_x = monitor_x + monitor_width - width - margin;
let max_y = monitor_y + monitor_height - height - margin - taskbar_height;
let x = if max_x.is_finite() { max_x.max(monitor_x) } else { 100.0 };
let y = if max_y.is_finite() { max_y.max(monitor_y) } else { 100.0 };
(x, y)
}
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> {
open_chat_window_with_state(app, ticket_id, ticket_ref, true) // Por padrao abre minimizada
}
@ -771,22 +953,8 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
(380.0, 520.0) // Tamanho expandido
};
// Obter tamanho da tela para posicionar no canto inferior direito
let monitors = app.available_monitors().map_err(|e| e.to_string())?;
let primary = monitors.into_iter().next();
let (x, y) = if let Some(monitor) = primary {
let size = monitor.size();
let scale = monitor.scale_factor();
let margin = 20.0;
let taskbar_height = 50.0;
(
(size.width as f64 / scale) - width - margin,
(size.height as f64 / scale) - height - margin - taskbar_height,
)
} else {
(100.0, 100.0)
};
// Posicionar no canto inferior direito (acima da barra de tarefas).
let (x, y) = resolve_chat_window_position(app, None, width, height);
// Usar query param ao inves de path para compatibilidade com SPA
let url_path = format!("index.html?view=chat&ticketId={}&ticketRef={}", ticket_id, ticket_ref);
@ -810,6 +978,10 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
.build()
.map_err(|e| e.to_string())?;
// Reaplica layout/posicao logo apos criar a janela.
// Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes.
let _ = set_chat_minimized(app, ticket_id, start_minimized);
crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label);
Ok(())
}
@ -846,19 +1018,8 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo
(380.0, 520.0) // Tamanho expandido
};
// Calcular posicao no canto inferior direito
let (x, y) = if let Some(monitor) = window.current_monitor().ok().flatten() {
let size = monitor.size();
let scale = monitor.scale_factor();
let margin = 20.0;
let taskbar_height = 50.0;
(
(size.width as f64 / scale) - width - margin,
(size.height as f64 / scale) - height - margin - taskbar_height,
)
} else {
(100.0, 100.0)
};
// Calcular posicao no canto inferior direito do monitor atual (com fallback seguro).
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
// Aplicar novo tamanho e posicao
window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;

View file

@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { open } from "@tauri-apps/plugin-dialog"
import { open as openDialog } from "@tauri-apps/plugin-dialog"
import { open as openExternal } from "@tauri-apps/plugin-opener"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react"
import type { ChatMessage, ChatMessagesResponse, NewMessageEvent } from "./types"
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react"
import type { ChatAttachment, ChatMessage, ChatMessagesResponse, NewMessageEvent, SessionEndedEvent } from "./types"
import { getMachineStoreConfig } from "./machineStore"
const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak
@ -32,6 +33,170 @@ function getFileIcon(fileName: string) {
return <File className="size-4" />
}
function isImageAttachment(attachment: ChatAttachment) {
if (attachment.type?.startsWith("image/")) return true
const ext = attachment.name.toLowerCase().split(".").pop() ?? ""
return ["jpg", "jpeg", "png", "gif", "webp"].includes(ext)
}
function formatAttachmentSize(size?: number) {
if (!size) return null
if (size < 1024) return `${size}B`
const kb = size / 1024
if (kb < 1024) return `${Math.round(kb)}KB`
return `${(kb / 1024).toFixed(1)}MB`
}
function MessageAttachment({
attachment,
isAgent,
loadUrl,
}: {
attachment: ChatAttachment
isAgent: boolean
loadUrl: (storageId: string) => Promise<string>
}) {
const [url, setUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [downloading, setDownloading] = useState(false)
const [downloaded, setDownloaded] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
loadUrl(attachment.storageId)
.then((resolved) => {
if (!cancelled) setUrl(resolved)
})
.catch((err) => {
console.error("Falha ao carregar URL do anexo:", err)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [attachment.storageId, loadUrl])
const handleView = async () => {
if (!url) return
try {
await openExternal(url)
} catch (err) {
console.error("Falha ao abrir anexo:", err)
}
}
const handleDownload = async () => {
if (!url || downloading) return
setDownloading(true)
try {
const response = await fetch(url)
const blob = await response.blob()
const downloadUrl = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = downloadUrl
a.download = attachment.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(downloadUrl)
setDownloaded(true)
setTimeout(() => setDownloaded(false), 2000)
} catch (err) {
console.error("Falha ao baixar anexo:", err)
// Fallback: abrir no navegador/sistema
await handleView()
} finally {
setDownloading(false)
}
}
const sizeLabel = formatAttachmentSize(attachment.size)
const isImage = isImageAttachment(attachment)
if (loading) {
return (
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
<Loader2 className="size-4 animate-spin" />
<span className="truncate">Carregando anexo...</span>
</div>
)
}
if (isImage && url) {
return (
<div className={`group relative overflow-hidden rounded-lg border ${isAgent ? "border-white/10" : "border-slate-200"}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={attachment.name}
className="size-24 cursor-pointer object-cover"
onClick={handleView}
/>
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={handleView}
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
title="Visualizar"
>
<Eye className="size-4 text-white" />
</button>
<button
onClick={handleDownload}
disabled={downloading}
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30 disabled:opacity-60"
title="Baixar"
>
{downloading ? (
<Loader2 className="size-4 animate-spin text-white" />
) : downloaded ? (
<Check className="size-4 text-emerald-300" />
) : (
<Download className="size-4 text-white" />
)}
</button>
</div>
</div>
)
}
return (
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
{getFileIcon(attachment.name)}
<button onClick={handleView} className="flex-1 truncate text-left hover:underline" title="Visualizar">
{attachment.name}
</button>
{sizeLabel && <span className="text-xs opacity-60">({sizeLabel})</span>}
<div className="ml-1 flex items-center gap-1">
<button
onClick={handleView}
className={`flex size-7 items-center justify-center rounded-md ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
title="Visualizar"
>
<Eye className="size-4" />
</button>
<button
onClick={handleDownload}
disabled={downloading}
className={`flex size-7 items-center justify-center rounded-md disabled:opacity-60 ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
title="Baixar"
>
{downloading ? (
<Loader2 className="size-4 animate-spin" />
) : downloaded ? (
<Check className="size-4 text-emerald-500" />
) : (
<Download className="size-4" />
)}
</button>
</div>
</div>
)
}
interface ChatWidgetProps {
ticketId: string
ticketRef?: number
@ -89,6 +254,37 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
return cfg
}, [])
const attachmentUrlCacheRef = useRef<Map<string, string>>(new Map())
const loadAttachmentUrl = useCallback(async (storageId: string) => {
const cached = attachmentUrlCacheRef.current.get(storageId)
if (cached) return cached
const cfg = await ensureConfig()
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineToken: cfg.token,
ticketId,
storageId,
}),
})
if (!response.ok) {
const text = await response.text().catch(() => "")
throw new Error(text || `Falha ao obter URL do anexo (${response.status})`)
}
const data = (await response.json()) as { url?: string }
if (!data.url) {
throw new Error("Resposta invalida ao obter URL do anexo")
}
attachmentUrlCacheRef.current.set(storageId, data.url)
return data.url
}, [ensureConfig, ticketId])
const loadMessages = useCallback(async () => {
try {
const cfg = await ensureConfig()
@ -151,6 +347,24 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
}
}, [ticketId, loadMessages])
// Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente)
useEffect(() => {
let unlisten: (() => void) | null = null
listen<SessionEndedEvent>("raven://chat/session-ended", (event) => {
if (event.payload?.ticketId === ticketId) {
loadMessages()
}
})
.then((u) => {
unlisten = u
})
.catch((err) => console.error("Falha ao registrar listener session-ended:", err))
return () => {
unlisten?.()
}
}, [ticketId, loadMessages])
// Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions
/* useEffect(() => {
setIsLoading(true)
@ -242,7 +456,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
if (isUploading || isSending) return
try {
const selected = await open({
const selected = await openDialog({
multiple: false,
filters: [{
name: "Arquivos permitidos",
@ -513,22 +727,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
{/* Anexos */}
{msg.attachments && msg.attachments.length > 0 && (
<div className="mt-2 space-y-1">
<div className="mt-2 space-y-2">
{msg.attachments.map((att) => (
<div
<MessageAttachment
key={att.storageId}
className={`flex items-center gap-2 rounded-lg p-2 text-xs ${
isAgent ? "bg-white/10" : "bg-slate-100"
}`}
>
{getFileIcon(att.name)}
<span className="truncate">{att.name}</span>
{att.size && (
<span className="text-xs opacity-60">
({Math.round(att.size / 1024)}KB)
</span>
)}
</div>
attachment={att}
isAgent={isAgent}
loadUrl={loadAttachmentUrl}
/>
))}
</div>
)}

View file

@ -487,7 +487,8 @@ export const listMachineMessages = query({
const messages = (await messagesQuery.take(limit)).reverse()
// Obter userId da máquina para verificar se é autor
const machineUserId = machine.assignedUserId ?? machine.linkedUserIds?.[0]
// Deve refletir o mesmo "userId" usado em postMachineMessage/markMachineMessagesRead
const machineUserId = machine.assignedUserId ?? machine.linkedUserIds?.[0] ?? ticket.requesterId
const result = messages.map((msg) => {
const isFromMachine = machineUserId
@ -626,6 +627,12 @@ export const listAgentSessions = query({
return []
}
const role = agent.role?.toUpperCase() ?? ""
if (!["ADMIN", "MANAGER", "AGENT"].includes(role)) {
// Nao expor sessoes de chat para usuarios nao-staff (ex.: portal/collaborator)
return []
}
// Buscar sessoes ativas do tenant do agente (limitado para evitar OOM)
const sessions = await ctx.db
.query("liveChatSessions")
@ -939,4 +946,3 @@ export const generateMachineUploadUrl = action({
return { uploadUrl }
},
})

View file

@ -0,0 +1,95 @@
import { z } from "zod"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
import { checkRateLimit, RATE_LIMITS, rateLimitHeaders } from "@/server/rate-limit"
const attachmentUrlSchema = z.object({
machineToken: z.string().min(1),
ticketId: z.string().min(1),
storageId: z.string().min(1),
})
const CORS_METHODS = "POST, OPTIONS"
export async function OPTIONS(request: Request) {
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
}
// POST /api/machines/chat/attachments/url
// Retorna URL assinada para download/preview de um anexo do chat (validado por machineToken).
export async function POST(request: Request) {
const origin = request.headers.get("origin")
let client
try {
client = createConvexClient()
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
}
throw error
}
let payload
try {
const raw = await request.json()
payload = attachmentUrlSchema.parse(raw)
} catch (error) {
return jsonWithCors(
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
400,
origin,
CORS_METHODS
)
}
const rateLimit = checkRateLimit(
`chat-attachment:${payload.machineToken}`,
RATE_LIMITS.CHAT_MESSAGES.maxRequests,
RATE_LIMITS.CHAT_MESSAGES.windowMs
)
if (!rateLimit.allowed) {
return jsonWithCors(
{ error: "Rate limit exceeded", retryAfterMs: rateLimit.retryAfterMs },
429,
origin,
CORS_METHODS,
rateLimitHeaders(rateLimit)
)
}
try {
const messagesResult = await client.query(api.liveChat.listMachineMessages, {
machineToken: payload.machineToken,
ticketId: payload.ticketId as Id<"tickets">,
limit: 200,
})
if (!messagesResult.hasSession) {
return jsonWithCors({ error: "Chat nao esta ativo" }, 403, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
}
const attachmentExists = messagesResult.messages.some((message) =>
(message.attachments ?? []).some((att) => String(att.storageId) === payload.storageId)
)
if (!attachmentExists) {
return jsonWithCors({ error: "Anexo nao encontrado" }, 404, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
}
const url = await client.action(api.files.getUrl, { storageId: payload.storageId as Id<"_storage"> })
if (!url) {
return jsonWithCors({ error: "URL nao disponivel" }, 404, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
}
return jsonWithCors({ url }, 200, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
} catch (error) {
console.error("[machines.chat.attachments.url] Falha ao obter URL de anexo", error)
const details = error instanceof Error ? error.message : String(error)
return jsonWithCors({ error: "Falha ao obter URL de anexo", details }, 500, origin, CORS_METHODS, rateLimitHeaders(rateLimit))
}
}

View file

@ -58,7 +58,9 @@ type ChatSession = {
ticketRef: number
ticketSubject: string
sessionId: string
agentId: string
unreadCount: number
lastActivityAt: number
}
type UploadedFile = {
@ -242,8 +244,8 @@ export function ChatWidget() {
// pois o chat nativo do Tauri ja esta disponivel
const isTauriContext = typeof window !== "undefined" && "__TAURI__" in window
const { convexUserId } = useAuth()
const viewerId = convexUserId ?? null
const { convexUserId, isStaff } = useAuth()
const viewerId = isStaff ? (convexUserId ?? null) : null
// Inicializar estado a partir do localStorage (para persistir entre reloads)
const [isOpen, setIsOpen] = useState(() => {
@ -290,8 +292,8 @@ export function ChatWidget() {
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const dropAreaRef = useRef<HTMLDivElement | null>(null)
const prevSessionCountRef = useRef<number>(-1) // -1 indica "ainda nao inicializado"
const hasRestoredStateRef = useRef<boolean>(false) // Flag para evitar sobrescrever estado do localStorage
const hasInitializedSessionsRef = useRef(false)
const prevSessionIdsRef = useRef<Set<string>>(new Set())
// Buscar sessões de chat ativas do agente
const activeSessions = useQuery(
@ -366,40 +368,32 @@ export function ChatWidget() {
}
}, [activeTicketId, activeSessions])
// Auto-abrir widget quando uma nova sessão é iniciada (apenas para sessoes NOVAS, nao na montagem inicial)
// Auto-abrir o widget quando ESTE agente iniciar uma nova sessão de chat.
// Nao roda na montagem inicial para nao sobrescrever o estado do localStorage.
useEffect(() => {
if (!activeSessions) return
const currentCount = activeSessions.length
const prevCount = prevSessionCountRef.current
// Primeira execucao: apenas inicializar o ref, nao abrir automaticamente
// Isso preserva o estado do localStorage (se usuario tinha minimizado, mantem minimizado)
if (prevCount === -1) {
prevSessionCountRef.current = currentCount
hasRestoredStateRef.current = true
const currentIds = new Set(activeSessions.map((s) => s.sessionId))
if (!hasInitializedSessionsRef.current) {
prevSessionIdsRef.current = currentIds
hasInitializedSessionsRef.current = true
return
}
// Se aumentou o número de sessões APOS a montagem inicial, é uma nova sessão - abrir o widget expandido
if (currentCount > prevCount && hasRestoredStateRef.current) {
// O estado do widget e definido com base nas nao lidas.
// Selecionar a sessão mais recente (última da lista ou primeira se única)
const newestSession = activeSessions[activeSessions.length - 1] ?? activeSessions[0]
const hasUnreadForAgent = (newestSession?.unreadCount ?? 0) > 0
const newSessions = activeSessions.filter((s) => !prevSessionIdsRef.current.has(s.sessionId))
prevSessionIdsRef.current = currentIds
if (newSessions.length === 0) return
if (!viewerId) return
const mine = newSessions.find((s) => s.agentId === viewerId) ?? null
if (!mine) return
if (!isOpen) {
setIsOpen(true)
setIsMinimized(!hasUnreadForAgent)
} else if (isMinimized && hasUnreadForAgent) {
setIsMinimized(false)
}
if (newestSession) {
setActiveTicketId(newestSession.ticketId)
}
}
prevSessionCountRef.current = currentCount
}, [activeSessions, isOpen, isMinimized])
setActiveTicketId(mine.ticketId)
}, [activeSessions, viewerId])
// Scroll para última mensagem
useEffect(() => {