diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 691ac99..e0cd7e5 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -1000,37 +1000,58 @@ async fn process_chat_update( } } - // 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) = 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) - // Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens - let _ = window.show(); - // Verificar se esta expandida (altura > 100px significa expandido) - // Se estiver expandida, NAO minimizar - usuario esta usando o chat - if let Ok(size) = window.inner_size() { - let is_expanded = size.height > 100; - if !is_expanded { - // Janela esta minimizada, manter minimizada - let _ = set_chat_minimized(app, &session.ticket_id, true); - } - // Se esta expandida, nao faz nada - deixa o usuario continuar usando - } + // Se ha multiplas sessoes ativas, usar o hub; senao, abrir janela do chat individual + if current_sessions.len() > 1 { + // Multiplas sessoes - usar hub window + if app.get_webview_window(HUB_WINDOW_LABEL).is_none() { + // Hub nao existe - criar minimizado + let _ = open_hub_window(app); } else { - // Criar nova janela ja minimizada (menos intrusivo) - let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref); + // Hub ja existe - verificar se esta minimizado + if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) { + let _ = hub.show(); + if let Ok(size) = hub.inner_size() { + if size.height < 100 { + // Esta minimizado, manter assim + let _ = set_hub_minimized(app, true); + } + } + } + } + } else { + // Uma sessao - abrir janela individual + // 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) = 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) + // Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens + let _ = window.show(); + // Verificar se esta expandida (altura > 100px significa expandido) + // Se estiver expandida, NAO minimizar - usuario esta usando o chat + if let Ok(size) = window.inner_size() { + let is_expanded = size.height > 100; + if !is_expanded { + // Janela esta minimizada, manter minimizada + let _ = set_chat_minimized(app, &session.ticket_id, true); + } + // Se esta expandida, nao faz nada - deixa o usuario continuar usando + } + } else { + // Criar nova janela ja minimizada (menos intrusivo) + let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref); + } } } @@ -1201,3 +1222,85 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized); Ok(()) } + +// ============================================================================ +// HUB WINDOW MANAGEMENT (Lista de todas as sessoes) +// ============================================================================ + +const HUB_WINDOW_LABEL: &str = "chat-hub"; + +pub fn open_hub_window(app: &tauri::AppHandle) -> Result<(), String> { + open_hub_window_with_state(app, true) // Por padrao abre minimizada +} + +fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> Result<(), String> { + // Verificar se ja existe + if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + return Ok(()); + } + + // Dimensoes baseadas no estado inicial + let (width, height) = if start_minimized { + (200.0, 52.0) // Tamanho minimizado (chip) + } else { + (380.0, 480.0) // Tamanho expandido (lista) + }; + + // Posicionar no canto inferior direito + let (x, y) = resolve_chat_window_position(app, None, width, height); + + // URL para modo hub + let url_path = "index.html?view=chat&hub=true"; + + WebviewWindowBuilder::new( + app, + HUB_WINDOW_LABEL, + WebviewUrl::App(url_path.into()), + ) + .title("Chats de Suporte") + .inner_size(width, height) + .min_inner_size(200.0, 52.0) + .position(x, y) + .decorations(false) + .transparent(true) + .shadow(false) + .always_on_top(true) + .skip_taskbar(true) + .focused(true) + .visible(true) + .build() + .map_err(|e| e.to_string())?; + + // Reaplica layout/posicao + let _ = set_hub_minimized(app, start_minimized); + + crate::log_info!("Hub window aberta (minimizada={})", start_minimized); + Ok(()) +} + +pub fn close_hub_window(app: &tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) { + window.close().map_err(|e| e.to_string())?; + } + Ok(()) +} + +pub fn set_hub_minimized(app: &tauri::AppHandle, minimized: bool) -> Result<(), String> { + let window = app.get_webview_window(HUB_WINDOW_LABEL).ok_or("Hub window não encontrada")?; + + let (width, height) = if minimized { + (200.0, 52.0) // Chip minimizado + } else { + (380.0, 480.0) // Lista expandida + }; + + let (x, y) = resolve_chat_window_position(app, Some(&window), width, height); + + window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; + window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?; + + crate::log_info!("Hub -> minimized={}", minimized); + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2b3d54b..b059391 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -429,6 +429,21 @@ fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool) chat::set_chat_minimized(&app, &ticket_id, minimized) } +#[tauri::command] +fn open_hub_window(app: tauri::AppHandle) -> Result<(), String> { + chat::open_hub_window(&app) +} + +#[tauri::command] +fn close_hub_window(app: tauri::AppHandle) -> Result<(), String> { + chat::close_hub_window(&app) +} + +#[tauri::command] +fn set_hub_minimized(app: tauri::AppHandle, minimized: bool) -> Result<(), String> { + chat::set_hub_minimized(&app, minimized) +} + // ============================================================================ // Handler de Deep Link (raven://) // ============================================================================ @@ -598,7 +613,11 @@ pub fn run() { open_chat_window, close_chat_window, minimize_chat_window, - set_chat_minimized + set_chat_minimized, + // Hub commands + open_hub_window, + close_hub_window, + set_hub_minimized ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -680,7 +699,13 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> { // Abrir janela de chat se houver sessao ativa if let Some(chat_runtime) = tray.app_handle().try_state::() { let sessions = chat_runtime.get_sessions(); - if let Some(session) = sessions.first() { + if sessions.len() > 1 { + // Multiplas sessoes - abrir hub + if let Err(e) = chat::open_hub_window(tray.app_handle()) { + log_error!("Falha ao abrir hub de chat: {e}"); + } + } else if let Some(session) = sessions.first() { + // Uma sessao - abrir diretamente if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) { log_error!("Falha ao abrir janela de chat: {e}"); } diff --git a/apps/desktop/src/chat/ChatHubWidget.tsx b/apps/desktop/src/chat/ChatHubWidget.tsx new file mode 100644 index 0000000..464d48c --- /dev/null +++ b/apps/desktop/src/chat/ChatHubWidget.tsx @@ -0,0 +1,212 @@ +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([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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("raven://chat/session-started", () => { + loadSessions() + }).then((unlisten) => unlisteners.push(unlisten)) + + // Quando sessao encerra + listen("raven://chat/session-ended", () => { + loadSessions() + }).then((unlisten) => unlisteners.push(unlisten)) + + // Quando contador de nao lidos muda + listen("raven://chat/unread-update", (event) => { + setSessions(event.payload.sessions || []) + }).then((unlisten) => unlisteners.push(unlisten)) + + // Quando nova mensagem chega + listen("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 ( +
+
+ + Carregando... +
+
+ ) + } + + // Erro + if (error) { + return ( +
+ +
+ ) + } + + // Sem sessoes ativas - mostrar chip cinza + if (sessions.length === 0) { + return ( +
+
+ + Sem chats +
+
+ ) + } + + // Minimizado - mostrar chip com contador + if (isMinimized) { + return ( +
+ +
+ ) + } + + // Expandido - mostrar lista + return ( +
+ +
+ ) +} diff --git a/apps/desktop/src/chat/ChatSessionItem.tsx b/apps/desktop/src/chat/ChatSessionItem.tsx new file mode 100644 index 0000000..d43447f --- /dev/null +++ b/apps/desktop/src/chat/ChatSessionItem.tsx @@ -0,0 +1,82 @@ +import { MessageCircle } from "lucide-react" +import type { ChatSession } from "./types" + +type ChatSessionItemProps = { + session: ChatSession + isActive?: boolean + onClick: () => void +} + +function formatTime(timestamp: number) { + const now = Date.now() + const diff = now - timestamp + const minutes = Math.floor(diff / 60000) + const hours = Math.floor(diff / 3600000) + const days = Math.floor(diff / 86400000) + + if (minutes < 1) return "Agora" + if (minutes < 60) return `${minutes}min` + if (hours < 24) return `${hours}h` + if (days === 1) return "Ontem" + + return new Date(timestamp).toLocaleDateString("pt-BR", { + day: "2-digit", + month: "2-digit", + }) +} + +export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemProps) { + const hasUnread = session.unreadCount > 0 + + return ( + + ) +} diff --git a/apps/desktop/src/chat/ChatSessionList.tsx b/apps/desktop/src/chat/ChatSessionList.tsx new file mode 100644 index 0000000..2c43ed4 --- /dev/null +++ b/apps/desktop/src/chat/ChatSessionList.tsx @@ -0,0 +1,99 @@ +import { useMemo } from "react" +import { MessageCircle, X } from "lucide-react" +import { ChatSessionItem } from "./ChatSessionItem" +import type { ChatSession } from "./types" + +type ChatSessionListProps = { + sessions: ChatSession[] + onSelectSession: (ticketId: string, ticketRef: number) => void + onClose: () => void + onMinimize: () => void +} + +export function ChatSessionList({ + sessions, + onSelectSession, + onClose, + onMinimize, +}: ChatSessionListProps) { + // Ordenar: nao lidos primeiro, depois por ultima atividade (desc) + const sortedSessions = useMemo(() => { + return [...sessions].sort((a, b) => { + // Nao lidos primeiro + if (a.unreadCount > 0 && b.unreadCount === 0) return -1 + if (a.unreadCount === 0 && b.unreadCount > 0) return 1 + // Depois por ultima atividade (mais recente primeiro) + return b.lastActivityAt - a.lastActivityAt + }) + }, [sessions]) + + const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0) + + return ( +
+ {/* Header - arrastavel */} +
+
+
+ +
+
+

Chats

+

+ {sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""} + {totalUnread > 0 && ( + + ({totalUnread} nao lida{totalUnread !== 1 ? "s" : ""}) + + )} +

+
+
+
+ + +
+
+ + {/* Lista de sessoes */} +
+ {sortedSessions.length === 0 ? ( +
+
+ +
+

Nenhum chat ativo

+

+ Os chats aparecerao aqui quando iniciados +

+
+ ) : ( + sortedSessions.map((session) => ( + onSelectSession(session.ticketId, session.ticketRef)} + /> + )) + )} +
+
+ ) +} diff --git a/apps/desktop/src/chat/index.tsx b/apps/desktop/src/chat/index.tsx index 02e7f13..d20f75c 100644 --- a/apps/desktop/src/chat/index.tsx +++ b/apps/desktop/src/chat/index.tsx @@ -1,21 +1,22 @@ import { ChatWidget } from "./ChatWidget" +import { ChatHubWidget } from "./ChatHubWidget" export function ChatApp() { // Obter ticketId e ticketRef da URL const params = new URLSearchParams(window.location.search) const ticketId = params.get("ticketId") const ticketRef = params.get("ticketRef") + const isHub = params.get("hub") === "true" - if (!ticketId) { - return ( -
-

Erro: ticketId não fornecido

-
- ) + // Modo hub - lista de todas as sessoes + if (isHub || !ticketId) { + return } + // Modo chat - conversa de um ticket especifico return } export { ChatWidget } +export { ChatHubWidget } export * from "./types"