diff --git a/.forgejo/workflows/ci-cd-web-desktop.yml b/.forgejo/workflows/ci-cd-web-desktop.yml index ebc4edd..db80c21 100644 --- a/.forgejo/workflows/ci-cd-web-desktop.yml +++ b/.forgejo/workflows/ci-cd-web-desktop.yml @@ -189,48 +189,38 @@ jobs: chown -R 1000:1000 /target echo "Permissoes do build corrigidas" - - name: Publish build to stable APP_DIR directory + - name: Atualizar symlink do APP_DIR estavel (deploy atomico) run: | - set -e - DEST="$HOME/apps/sistema" - PARENT_DIR="$HOME/apps" + set -euo pipefail + ROOT="$HOME/apps" + STABLE_LINK="$ROOT/sistema.current" - # SOLUCAO DEFINITIVA: Limpar completamente o destino usando Docker (root) - # Isso evita erros de permissao de arquivos criados por Docker em deploys anteriores - if [ -d "$DEST" ]; then - echo "Limpando diretorio destino: $DEST" - # Preservar apenas o .env (configuracoes de producao) - if [ -f "$DEST/.env" ]; then - docker run --rm -v "$DEST":/src -v /tmp:/backup alpine:3 \ - cp /src/.env /backup/.env.backup - echo ".env salvo em /tmp/.env.backup" - fi - # Remover o diretorio COMPLETAMENTE usando Docker Alpine como root - docker run --rm -v "$PARENT_DIR":/parent alpine:3 \ - rm -rf /parent/sistema - echo "Diretorio removido" + mkdir -p "$ROOT" + + # Sanidade: se esses arquivos nao existirem, o container vai falhar no boot. + test -f "$EFFECTIVE_APP_DIR/scripts/start-web.sh" || { echo "ERROR: scripts/start-web.sh nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; } + test -f "$EFFECTIVE_APP_DIR/stack.yml" || { echo "ERROR: stack.yml nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; } + test -d "$EFFECTIVE_APP_DIR/node_modules" || { echo "ERROR: node_modules nao encontrado em $EFFECTIVE_APP_DIR (necessario para next start)" >&2; exit 1; } + test -d "$EFFECTIVE_APP_DIR/.next" || { echo "ERROR: .next nao encontrado em $EFFECTIVE_APP_DIR (build nao gerado)" >&2; exit 1; } + + PREV="" + if [ -L "$STABLE_LINK" ]; then + PREV="$(readlink -f "$STABLE_LINK" || true)" + fi + echo "PREV_APP_DIR=$PREV" >> "$GITHUB_ENV" + + ln -sfn "$EFFECTIVE_APP_DIR" "$STABLE_LINK" + + # Compat: mantem $HOME/apps/sistema como symlink quando possivel (nao mexe se for pasta). + if [ -L "$ROOT/sistema" ] || [ ! -e "$ROOT/sistema" ]; then + ln -sfn "$STABLE_LINK" "$ROOT/sistema" fi - # Recriar o diretorio (sera criado com permissoes do usuario runner) - mkdir -p "$DEST" - - # Restaurar .env antes do rsync - if [ -f /tmp/.env.backup ]; then - cp /tmp/.env.backup "$DEST/.env" - rm /tmp/.env.backup - echo ".env restaurado" - fi - - # Copiar build completo (sem conflitos de permissao agora) - rsync -a --no-owner --no-group \ - --exclude '.pnpm-store' --exclude '.pnpm-store/**' \ - "$EFFECTIVE_APP_DIR"/ "$DEST"/ - - echo "Published build to: $DEST" + echo "APP_DIR estavel -> $(readlink -f "$STABLE_LINK")" - name: Swarm deploy (stack.yml) run: | - APP_DIR_STABLE="$HOME/apps/sistema" + APP_DIR_STABLE="$HOME/apps/sistema.current" if [ ! -d "$APP_DIR_STABLE" ]; then echo "ERROR: Stable APP_DIR does not exist: $APP_DIR_STABLE" >&2; exit 1 fi @@ -261,8 +251,24 @@ jobs: fi sleep 10 done - echo "AVISO: Timeout aguardando servicos. Status atual:" - docker service ls --filter "label=com.docker.stack.namespace=sistema" + echo "ERRO: Timeout aguardando servicos. Status atual:" + docker service ls --filter "label=com.docker.stack.namespace=sistema" || true + docker service ps sistema_web --no-trunc || true + docker service logs sistema_web --since 5m --raw 2>/dev/null | tail -n 200 || true + + if [ -n "${PREV_APP_DIR:-}" ]; then + echo "Rollback: revertendo APP_DIR estavel para: $PREV_APP_DIR" + ln -sfn "$PREV_APP_DIR" "$HOME/apps/sistema.current" + cd "$HOME/apps/sistema.current" + set -o allexport + if [ -f .env ]; then + . ./.env + fi + set +o allexport + APP_DIR="$HOME/apps/sistema.current" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema || true + fi + + exit 1 - name: Cleanup old build workdirs (keep last 2) run: | @@ -270,7 +276,7 @@ jobs: ROOT="$HOME/apps" KEEP=2 PATTERN='web.build.*' - ACTIVE="$HOME/apps/sistema" + ACTIVE="$(readlink -f "$HOME/apps/sistema.current" 2>/dev/null || true)" echo "Scanning $ROOT for old $PATTERN dirs" LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true) echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true 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 ? (