From 3f9461a18f2dac2ef7e39a74b9ecd6c4c81cb426 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Wed, 17 Dec 2025 01:44:28 -0300 Subject: [PATCH] fix(desktop-chat): estabiliza janelas e melhora multi-conversas --- apps/desktop/src-tauri/src/chat.rs | 133 +++++++++++++++--------- apps/desktop/src/chat/ChatHubWidget.tsx | 26 +---- apps/desktop/src/chat/ChatWidget.tsx | 59 +++++++++-- 3 files changed, 134 insertions(+), 84 deletions(-) diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 1203ca4..db8d8fb 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -1000,35 +1000,15 @@ async fn process_chat_update( } } - // Se ha multiplas sessoes ativas, usar o hub; senao, abrir janela do chat individual - // SIMPLIFICADO: Removido inner_size() que bloqueava a UI thread + // Se ha multiplas sessoes ativas, usar o hub; senao, abrir janela do chat individual. + // + // Importante (UX): em multiplas sessoes, NAO fecha a janela ativa quando chega mensagem em outra conversa. + // O hub + badge/notificacao sinalizam novas mensagens e o usuario decide quando alternar. if current_sessions.len() > 1 { - // Multiplas sessoes - usar hub window - // Primeiro, fechar todas as janelas individuais de chat para evitar sobreposicao - for session in ¤t_sessions { - let label = format!("chat-{}", session.ticket_id); - if let Some(window) = app.get_webview_window(&label) { - let _ = window.close(); - } - } - - if app.get_webview_window(HUB_WINDOW_LABEL).is_none() { - // Hub nao existe - criar minimizado - let _ = open_hub_window(app); - } else { - // Hub ja existe - mostrar e trazer para frente - if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) { - let _ = hub.show(); - let _ = hub.set_focus(); - let _ = hub.unminimize(); - } - } + let _ = open_hub_window(app); } else { - // Uma sessao - abrir janela individual - // Fechar o Hub se estiver aberto (nao precisa mais quando ha apenas 1 chat) - if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) { - let _ = hub.close(); - } + // Uma sessao - nao precisa de hub + let _ = close_hub_window(app); // Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente. let session_to_show = if best_delta > 0 { @@ -1041,19 +1021,9 @@ async fn process_chat_update( }) }; - // Mostrar janela de chat (se nao existe, cria minimizada; se existe, traz para frente) + // Mostrar janela de chat (sempre minimizada/nao intrusiva) 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 - mostrar e trazer para frente - let _ = window.show(); - let _ = window.set_focus(); - // Garantir que fique visivel mesmo se estava minimizada na taskbar - let _ = window.unminimize(); - } else { - // Criar nova janela ja minimizada (menos intrusivo) - let _ = open_chat_window_internal(app, &session.ticket_id, session.ticket_ref, true); - } + let _ = open_chat_window_internal(app, &session.ticket_id, session.ticket_ref, true); } } @@ -1087,6 +1057,9 @@ async fn process_chat_update( // WINDOW MANAGEMENT // ============================================================================ +// Serializa operacoes de janela para evitar race/deadlock no Windows (winit/WebView2). +static WINDOW_OP_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + fn resolve_chat_window_position( app: &tauri::AppHandle, window: Option<&tauri::WebviewWindow>, @@ -1128,21 +1101,36 @@ fn resolve_chat_window_position( } fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> { + let _guard = WINDOW_OP_LOCK.lock(); open_chat_window_with_state(app, ticket_id, ticket_ref, start_minimized) } /// Abre janela de chat com estado inicial de minimizacao configuravel fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> { let label = format!("chat-{}", ticket_id); + crate::log_info!( + "[WINDOW] open_chat_window: label={} ticket_ref={} start_minimized={}", + label, + ticket_ref, + start_minimized + ); // Verificar se ja existe if let Some(window) = app.get_webview_window(&label) { + let _ = window.set_ignore_cursor_events(false); + crate::log_info!("[WINDOW] {}: window existe -> show()", label); window.show().map_err(|e| e.to_string())?; - window.set_focus().map_err(|e| e.to_string())?; + let _ = window.unminimize(); + if !start_minimized { + crate::log_info!("[WINDOW] {}: window existe -> set_focus()", label); + window.set_focus().map_err(|e| e.to_string())?; + } // Expandir a janela se estiver minimizada (quando clicado na lista) if !start_minimized { - let _ = set_chat_minimized(app, ticket_id, false); + crate::log_info!("[WINDOW] {}: window existe -> set_chat_minimized(false)", label); + let _ = set_chat_minimized_unlocked(app, ticket_id, false); } + crate::log_info!("[WINDOW] {}: open_chat_window OK (reuso)", label); return Ok(()); } @@ -1159,7 +1147,17 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r // 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); - WebviewWindowBuilder::new( + crate::log_info!( + "[WINDOW] {}: build() inicio size={}x{} pos=({},{}) url={}", + label, + width, + height, + x, + y, + url_path + ); + + let window = WebviewWindowBuilder::new( app, &label, WebviewUrl::App(url_path.into()), @@ -1172,16 +1170,24 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r .transparent(true) // Permite fundo transparente .shadow(false) // Desabilitar sombra para transparencia funcionar corretamente .resizable(false) // Desabilitar redimensionamento manual - // REMOVIDO: always_on_top(true) causa competicao de Z-order com multiplas janelas + // Mantem o chat acessivel mesmo ao trocar de janela/app (skip_taskbar=true). + .always_on_top(true) .skip_taskbar(true) - .focused(true) + .focused(!start_minimized) .visible(true) .build() .map_err(|e| e.to_string())?; + crate::log_info!("[WINDOW] {}: build() OK", label); + + // IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through) + let _ = window.set_ignore_cursor_events(false); + + crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) inicio", label, start_minimized); // 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); + let _ = set_chat_minimized_unlocked(app, ticket_id, start_minimized); + crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) fim", label, start_minimized); crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label); Ok(()) @@ -1193,6 +1199,7 @@ pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64 } pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { + let _guard = WINDOW_OP_LOCK.lock(); let label = format!("chat-{}", ticket_id); if let Some(window) = app.get_webview_window(&label) { window.close().map_err(|e| e.to_string())?; @@ -1201,6 +1208,7 @@ pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), } pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { + let _guard = WINDOW_OP_LOCK.lock(); let label = format!("chat-{}", ticket_id); if let Some(window) = app.get_webview_window(&label) { window.hide().map_err(|e| e.to_string())?; @@ -1209,7 +1217,7 @@ pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<( } /// Redimensiona a janela de chat para modo minimizado (chip) ou expandido -pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> { +fn set_chat_minimized_unlocked(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> { let label = format!("chat-{}", ticket_id); let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?; @@ -1224,13 +1232,22 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo let (x, y) = resolve_chat_window_position(app, Some(&window), width, height); // Aplicar novo tamanho e posicao + crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size inicio", label, minimized); window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; + crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size OK", label, minimized); + crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position inicio", label, minimized); window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?; + crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position OK", label, minimized); crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized); Ok(()) } +pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> { + let _guard = WINDOW_OP_LOCK.lock(); + set_chat_minimized_unlocked(app, ticket_id, minimized) +} + // ============================================================================ // HUB WINDOW MANAGEMENT (Lista de todas as sessoes) // ============================================================================ @@ -1238,14 +1255,19 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo const HUB_WINDOW_LABEL: &str = "chat-hub"; pub fn open_hub_window(app: &tauri::AppHandle) -> Result<(), String> { + let _guard = WINDOW_OP_LOCK.lock(); 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) { + let _ = window.set_ignore_cursor_events(false); window.show().map_err(|e| e.to_string())?; - window.set_focus().map_err(|e| e.to_string())?; + let _ = window.unminimize(); + if !start_minimized { + window.set_focus().map_err(|e| e.to_string())?; + } return Ok(()); } @@ -1275,9 +1297,10 @@ fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> .transparent(true) .shadow(false) .resizable(false) // Desabilitar redimensionamento manual - // REMOVIDO: always_on_top(true) causa competicao de Z-order com multiplas janelas + // Mantem o hub acessivel mesmo ao trocar de janela/app (skip_taskbar=true). + .always_on_top(true) .skip_taskbar(true) - .focused(true) + .focused(!start_minimized) .visible(true) .build() .map_err(|e| e.to_string())?; @@ -1285,7 +1308,9 @@ fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> // IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through) if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) { let _ = hub.set_ignore_cursor_events(false); - let _ = hub.set_focus(); + if !start_minimized { + let _ = hub.set_focus(); + } } // REMOVIDO TEMPORARIAMENTE: set_hub_minimized logo apos build pode causar @@ -1297,6 +1322,7 @@ fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> } pub fn close_hub_window(app: &tauri::AppHandle) -> Result<(), String> { + let _guard = WINDOW_OP_LOCK.lock(); if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) { window.close().map_err(|e| e.to_string())?; } @@ -1304,6 +1330,7 @@ pub fn close_hub_window(app: &tauri::AppHandle) -> Result<(), String> { } pub fn set_hub_minimized(app: &tauri::AppHandle, minimized: bool) -> Result<(), String> { + let _guard = WINDOW_OP_LOCK.lock(); let window = app.get_webview_window(HUB_WINDOW_LABEL).ok_or("Hub window não encontrada")?; let (width, height) = if minimized { @@ -1318,8 +1345,10 @@ pub fn set_hub_minimized(app: &tauri::AppHandle, minimized: bool) -> Result<(), 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())?; - // Reforcar foco apos resize - let _ = window.set_focus(); + // Foco apenas quando expandir (evita roubar foco ao minimizar apos abrir um chat). + if !minimized { + let _ = window.set_focus(); + } crate::log_info!("Hub -> minimized={}, size={}x{}, pos=({},{})", minimized, width, height, x, y); Ok(()) diff --git a/apps/desktop/src/chat/ChatHubWidget.tsx b/apps/desktop/src/chat/ChatHubWidget.tsx index 64a0c57..04358d9 100644 --- a/apps/desktop/src/chat/ChatHubWidget.tsx +++ b/apps/desktop/src/chat/ChatHubWidget.tsx @@ -39,19 +39,11 @@ export function ChatHubWidget() { return () => window.removeEventListener("resize", handler) }, []) - // DEBUG: Detectar se a janela esta em modo click-through - useEffect(() => { - const onDown = (e: PointerEvent) => console.log("POINTER DOWN HUB", e.target) - window.addEventListener("pointerdown", onDown) - return () => window.removeEventListener("pointerdown", onDown) - }, []) - const handleSelectSession = async (ticketId: string, ticketRef: number) => { - console.log("handleSelectSession CALLED", { ticketId, ticketRef }) try { // Tauri 2.x auto-converts snake_case (Rust) to camelCase (JS) - const result = await invoke("open_chat_window", { ticketId, ticketRef }) - console.log("open_chat_window SUCCESS", result) + await invoke("open_chat_window", { ticketId, ticketRef }) + await invoke("close_hub_window") } catch (err) { console.error("open_chat_window FAILED:", err) } @@ -67,10 +59,8 @@ export function ChatHubWidget() { } const handleExpand = async () => { - console.log("handleExpand CALLED") try { - const result = await invoke("set_hub_minimized", { minimized: false }) - console.log("set_hub_minimized SUCCESS", result) + await invoke("set_hub_minimized", { minimized: false }) setTimeout(() => setIsMinimized(false), 100) } catch (err) { console.error("set_hub_minimized FAILED:", err) @@ -127,13 +117,9 @@ export function ChatHubWidget() {
@@ -213,7 +199,6 @@ function SessionItem({ onClick: () => void }) { const handleClick = (e: React.MouseEvent) => { - console.log("SESSION ITEM CLICKED", session.ticketRef) e.stopPropagation() onClick() } @@ -221,7 +206,6 @@ function SessionItem({ return ( @@ -186,7 +186,7 @@ function MessageAttachment({ 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" + aria-label="Baixar anexo" > {downloading ? ( @@ -204,7 +204,11 @@ function MessageAttachment({ return (
{getFileIcon(attachment.name)} - {sizeLabel && ({sizeLabel})} @@ -212,7 +216,7 @@ function MessageAttachment({ @@ -220,7 +224,7 @@ function MessageAttachment({ 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" + aria-label="Baixar anexo" > {downloading ? ( @@ -250,6 +254,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { // Convex hooks const { apiBaseUrl, machineToken } = useConvexMachine() + const { sessions: machineSessions = [] } = useMachineSessions() const { messages: convexMessages, hasSession, unreadCount, isLoading } = useMachineMessages( ticketId as Id<"tickets">, { limit: MAX_MESSAGES_IN_MEMORY } @@ -276,6 +281,22 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { const unreadAgentMessageIds = useMemo(() => getUnreadAgentMessageIds(messages, unreadCount), [messages, unreadCount]) const firstUnreadAgentMessageId = unreadAgentMessageIds[0] ?? null + const otherUnreadCount = useMemo(() => { + if (machineSessions.length <= 1) return 0 + return machineSessions.reduce((sum, session) => { + return sum + (session.ticketId === ticketId ? 0 : session.unreadCount) + }, 0) + }, [machineSessions, ticketId]) + + const handleOpenHub = useCallback(async () => { + try { + await invoke("open_hub_window") + await invoke("set_hub_minimized", { minimized: false }) + } catch (err) { + console.error("Erro ao abrir hub:", err) + } + }, []) + const updateIsAtBottom = useCallback(() => { const el = messagesContainerRef.current if (!el) return @@ -562,7 +583,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { @@ -621,17 +642,31 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
+ {machineSessions.length > 1 && ( + + )} @@ -772,6 +807,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { @@ -792,7 +828,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { onClick={handleAttach} disabled={isUploading || isSending} className="flex size-9 items-center justify-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 disabled:opacity-50" - title="Anexar arquivo" + aria-label="Anexar arquivo" > {isUploading ? ( @@ -804,6 +840,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { onClick={handleSend} disabled={(!inputValue.trim() && pendingAttachments.length === 0) || isSending} className="flex size-9 items-center justify-center rounded-lg bg-black text-white transition hover:bg-black/90 disabled:opacity-50" + aria-label="Enviar mensagem" > {isSending ? (