Compare commits

..

2 commits

Author SHA1 Message Date
esdrasrenan
3f9461a18f fix(desktop-chat): estabiliza janelas e melhora multi-conversas
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
Quality Checks / Lint, Test and Build (push) Successful in 4m41s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 5m30s
2025-12-17 01:44:28 -03:00
esdrasrenan
380b2e44e9 fix(ci): deploy atomico no Forgejo (symlink) 2025-12-17 01:44:00 -03:00
4 changed files with 178 additions and 122 deletions

View file

@ -189,48 +189,38 @@ jobs:
chown -R 1000:1000 /target chown -R 1000:1000 /target
echo "Permissoes do build corrigidas" echo "Permissoes do build corrigidas"
- name: Publish build to stable APP_DIR directory - name: Atualizar symlink do APP_DIR estavel (deploy atomico)
run: | run: |
set -e set -euo pipefail
DEST="$HOME/apps/sistema" ROOT="$HOME/apps"
PARENT_DIR="$HOME/apps" STABLE_LINK="$ROOT/sistema.current"
# SOLUCAO DEFINITIVA: Limpar completamente o destino usando Docker (root) mkdir -p "$ROOT"
# Isso evita erros de permissao de arquivos criados por Docker em deploys anteriores
if [ -d "$DEST" ]; then # Sanidade: se esses arquivos nao existirem, o container vai falhar no boot.
echo "Limpando diretorio destino: $DEST" test -f "$EFFECTIVE_APP_DIR/scripts/start-web.sh" || { echo "ERROR: scripts/start-web.sh nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; }
# Preservar apenas o .env (configuracoes de producao) test -f "$EFFECTIVE_APP_DIR/stack.yml" || { echo "ERROR: stack.yml nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; }
if [ -f "$DEST/.env" ]; then test -d "$EFFECTIVE_APP_DIR/node_modules" || { echo "ERROR: node_modules nao encontrado em $EFFECTIVE_APP_DIR (necessario para next start)" >&2; exit 1; }
docker run --rm -v "$DEST":/src -v /tmp:/backup alpine:3 \ test -d "$EFFECTIVE_APP_DIR/.next" || { echo "ERROR: .next nao encontrado em $EFFECTIVE_APP_DIR (build nao gerado)" >&2; exit 1; }
cp /src/.env /backup/.env.backup
echo ".env salvo em /tmp/.env.backup" PREV=""
if [ -L "$STABLE_LINK" ]; then
PREV="$(readlink -f "$STABLE_LINK" || true)"
fi fi
# Remover o diretorio COMPLETAMENTE usando Docker Alpine como root echo "PREV_APP_DIR=$PREV" >> "$GITHUB_ENV"
docker run --rm -v "$PARENT_DIR":/parent alpine:3 \
rm -rf /parent/sistema ln -sfn "$EFFECTIVE_APP_DIR" "$STABLE_LINK"
echo "Diretorio removido"
# 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 fi
# Recriar o diretorio (sera criado com permissoes do usuario runner) echo "APP_DIR estavel -> $(readlink -f "$STABLE_LINK")"
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"
- name: Swarm deploy (stack.yml) - name: Swarm deploy (stack.yml)
run: | run: |
APP_DIR_STABLE="$HOME/apps/sistema" APP_DIR_STABLE="$HOME/apps/sistema.current"
if [ ! -d "$APP_DIR_STABLE" ]; then if [ ! -d "$APP_DIR_STABLE" ]; then
echo "ERROR: Stable APP_DIR does not exist: $APP_DIR_STABLE" >&2; exit 1 echo "ERROR: Stable APP_DIR does not exist: $APP_DIR_STABLE" >&2; exit 1
fi fi
@ -261,8 +251,24 @@ jobs:
fi fi
sleep 10 sleep 10
done done
echo "AVISO: Timeout aguardando servicos. Status atual:" echo "ERRO: Timeout aguardando servicos. Status atual:"
docker service ls --filter "label=com.docker.stack.namespace=sistema" 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) - name: Cleanup old build workdirs (keep last 2)
run: | run: |
@ -270,7 +276,7 @@ jobs:
ROOT="$HOME/apps" ROOT="$HOME/apps"
KEEP=2 KEEP=2
PATTERN='web.build.*' 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" echo "Scanning $ROOT for old $PATTERN dirs"
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true) LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true

View file

@ -1000,35 +1000,15 @@ async fn process_chat_update(
} }
} }
// Se ha multiplas sessoes ativas, usar o hub; senao, abrir janela do chat individual // Se ha multiplas sessoes ativas, usar o hub; senao, abrir janela do chat individual.
// SIMPLIFICADO: Removido inner_size() que bloqueava a UI thread //
// 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 { if current_sessions.len() > 1 {
// Multiplas sessoes - usar hub window
// Primeiro, fechar todas as janelas individuais de chat para evitar sobreposicao
for session in &current_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); let _ = open_hub_window(app);
} else { } else {
// Hub ja existe - mostrar e trazer para frente // Uma sessao - nao precisa de hub
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) { let _ = close_hub_window(app);
let _ = hub.show();
let _ = hub.set_focus();
let _ = hub.unminimize();
}
}
} 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();
}
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente. // Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
let session_to_show = if best_delta > 0 { let session_to_show = if best_delta > 0 {
@ -1041,21 +1021,11 @@ 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 { 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);
} }
} }
}
// Notificacao nativa // Notificacao nativa
let notification_title = "Nova mensagem de suporte"; let notification_title = "Nova mensagem de suporte";
@ -1087,6 +1057,9 @@ async fn process_chat_update(
// WINDOW MANAGEMENT // WINDOW MANAGEMENT
// ============================================================================ // ============================================================================
// Serializa operacoes de janela para evitar race/deadlock no Windows (winit/WebView2).
static WINDOW_OP_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
fn resolve_chat_window_position( fn resolve_chat_window_position(
app: &tauri::AppHandle, app: &tauri::AppHandle,
window: Option<&tauri::WebviewWindow>, 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> { 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) open_chat_window_with_state(app, ticket_id, ticket_ref, start_minimized)
} }
/// Abre janela de chat com estado inicial de minimizacao configuravel /// 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> { 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); 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 // Verificar se ja existe
if let Some(window) = app.get_webview_window(&label) { 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.show().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())?; window.set_focus().map_err(|e| e.to_string())?;
}
// Expandir a janela se estiver minimizada (quando clicado na lista) // Expandir a janela se estiver minimizada (quando clicado na lista)
if !start_minimized { 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(()); 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 // 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); 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, app,
&label, &label,
WebviewUrl::App(url_path.into()), 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 .transparent(true) // Permite fundo transparente
.shadow(false) // Desabilitar sombra para transparencia funcionar corretamente .shadow(false) // Desabilitar sombra para transparencia funcionar corretamente
.resizable(false) // Desabilitar redimensionamento manual .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) .skip_taskbar(true)
.focused(true) .focused(!start_minimized)
.visible(true) .visible(true)
.build() .build()
.map_err(|e| e.to_string())?; .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. // Reaplica layout/posicao logo apos criar a janela.
// Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes. // 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); crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label);
Ok(()) 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> { 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); let label = format!("chat-{}", ticket_id);
if let Some(window) = app.get_webview_window(&label) { if let Some(window) = app.get_webview_window(&label) {
window.close().map_err(|e| e.to_string())?; 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> { 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); let label = format!("chat-{}", ticket_id);
if let Some(window) = app.get_webview_window(&label) { if let Some(window) = app.get_webview_window(&label) {
window.hide().map_err(|e| e.to_string())?; 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 /// 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 label = format!("chat-{}", ticket_id);
let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?; 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); let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
// Aplicar novo tamanho e posicao // 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())?; 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())?; 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); crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
Ok(()) 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) // 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"; const HUB_WINDOW_LABEL: &str = "chat-hub";
pub fn open_hub_window(app: &tauri::AppHandle) -> Result<(), String> { 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 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> { fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> Result<(), String> {
// Verificar se ja existe // Verificar se ja existe
if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) { 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.show().map_err(|e| e.to_string())?;
let _ = window.unminimize();
if !start_minimized {
window.set_focus().map_err(|e| e.to_string())?; window.set_focus().map_err(|e| e.to_string())?;
}
return Ok(()); return Ok(());
} }
@ -1275,9 +1297,10 @@ fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) ->
.transparent(true) .transparent(true)
.shadow(false) .shadow(false)
.resizable(false) // Desabilitar redimensionamento manual .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) .skip_taskbar(true)
.focused(true) .focused(!start_minimized)
.visible(true) .visible(true)
.build() .build()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -1285,8 +1308,10 @@ fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) ->
// IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through) // IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through)
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) { if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
let _ = hub.set_ignore_cursor_events(false); let _ = hub.set_ignore_cursor_events(false);
if !start_minimized {
let _ = hub.set_focus(); let _ = hub.set_focus();
} }
}
// REMOVIDO TEMPORARIAMENTE: set_hub_minimized logo apos build pode causar // REMOVIDO TEMPORARIAMENTE: set_hub_minimized logo apos build pode causar
// "resize em cima do resize" no timing errado do WebView2 // "resize em cima do resize" no timing errado do WebView2
@ -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> { 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) { if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) {
window.close().map_err(|e| e.to_string())?; 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> { 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 window = app.get_webview_window(HUB_WINDOW_LABEL).ok_or("Hub window não encontrada")?;
let (width, height) = if minimized { 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_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())?; window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?;
// Reforcar foco apos resize // Foco apenas quando expandir (evita roubar foco ao minimizar apos abrir um chat).
if !minimized {
let _ = window.set_focus(); let _ = window.set_focus();
}
crate::log_info!("Hub -> minimized={}, size={}x{}, pos=({},{})", minimized, width, height, x, y); crate::log_info!("Hub -> minimized={}, size={}x{}, pos=({},{})", minimized, width, height, x, y);
Ok(()) Ok(())

View file

@ -39,19 +39,11 @@ export function ChatHubWidget() {
return () => window.removeEventListener("resize", handler) 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) => { const handleSelectSession = async (ticketId: string, ticketRef: number) => {
console.log("handleSelectSession CALLED", { ticketId, ticketRef })
try { try {
// Tauri 2.x auto-converts snake_case (Rust) to camelCase (JS) // Tauri 2.x auto-converts snake_case (Rust) to camelCase (JS)
const result = await invoke("open_chat_window", { ticketId, ticketRef }) await invoke("open_chat_window", { ticketId, ticketRef })
console.log("open_chat_window SUCCESS", result) await invoke("close_hub_window")
} catch (err) { } catch (err) {
console.error("open_chat_window FAILED:", err) console.error("open_chat_window FAILED:", err)
} }
@ -67,10 +59,8 @@ export function ChatHubWidget() {
} }
const handleExpand = async () => { const handleExpand = async () => {
console.log("handleExpand CALLED")
try { try {
const result = await invoke("set_hub_minimized", { minimized: false }) await invoke("set_hub_minimized", { minimized: false })
console.log("set_hub_minimized SUCCESS", result)
setTimeout(() => setIsMinimized(false), 100) setTimeout(() => setIsMinimized(false), 100)
} catch (err) { } catch (err) {
console.error("set_hub_minimized FAILED:", err) console.error("set_hub_minimized FAILED:", err)
@ -127,13 +117,9 @@ export function ChatHubWidget() {
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3"> <div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
<button <button
onClick={(e) => { onClick={(e) => {
console.log("EXPAND BUTTON CLICKED")
e.stopPropagation() e.stopPropagation()
handleExpand() handleExpand()
}} }}
onPointerDown={(e) => console.log("EXPAND POINTER DOWN", e.target)}
onMouseDown={(e) => console.log("EXPAND MOUSE DOWN", e.target)}
onMouseUp={(e) => console.log("EXPAND MOUSE UP", e.target)}
className="pointer-events-auto relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90" className="pointer-events-auto relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
> >
<MessageCircle className="size-4" /> <MessageCircle className="size-4" />
@ -175,14 +161,14 @@ export function ChatHubWidget() {
<button <button
onClick={handleMinimize} onClick={handleMinimize}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100" className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Minimizar" aria-label="Minimizar lista de chats"
> >
<Minimize2 className="size-4" /> <Minimize2 className="size-4" />
</button> </button>
<button <button
onClick={handleClose} onClick={handleClose}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100" className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar" aria-label="Fechar lista de chats"
> >
<X className="size-4" /> <X className="size-4" />
</button> </button>
@ -213,7 +199,6 @@ function SessionItem({
onClick: () => void onClick: () => void
}) { }) {
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
console.log("SESSION ITEM CLICKED", session.ticketRef)
e.stopPropagation() e.stopPropagation()
onClick() onClick()
} }
@ -221,7 +206,6 @@ function SessionItem({
return ( return (
<button <button
onClick={handleClick} onClick={handleClick}
onPointerDown={() => console.log("SESSION POINTER DOWN", session.ticketRef)}
className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition hover:bg-slate-50" className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition hover:bg-slate-50"
> >
{/* Avatar */} {/* Avatar */}

View file

@ -12,9 +12,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { open as openDialog } from "@tauri-apps/plugin-dialog" import { open as openDialog } from "@tauri-apps/plugin-dialog"
import { openUrl as openExternal } from "@tauri-apps/plugin-opener" import { openUrl as openExternal } from "@tauri-apps/plugin-opener"
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react" import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check, MessagesSquare } from "lucide-react"
import type { Id } from "@convex/_generated/dataModel" import type { Id } from "@convex/_generated/dataModel"
import { useMachineMessages, usePostMachineMessage, useMarkMachineMessagesRead, type MachineMessage } from "./useConvexMachineQueries" import { useMachineMessages, useMachineSessions, usePostMachineMessage, useMarkMachineMessagesRead, type MachineMessage } from "./useConvexMachineQueries"
import { useConvexMachine } from "./ConvexMachineProvider" import { useConvexMachine } from "./ConvexMachineProvider"
const MAX_MESSAGES_IN_MEMORY = 200 const MAX_MESSAGES_IN_MEMORY = 200
@ -178,7 +178,7 @@ function MessageAttachment({
<button <button
onClick={handleView} onClick={handleView}
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30" className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
title="Visualizar" aria-label="Visualizar anexo"
> >
<Eye className="size-4 text-white" /> <Eye className="size-4 text-white" />
</button> </button>
@ -186,7 +186,7 @@ function MessageAttachment({
onClick={handleDownload} onClick={handleDownload}
disabled={downloading} disabled={downloading}
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30 disabled:opacity-60" 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 ? ( {downloading ? (
<Loader2 className="size-4 animate-spin text-white" /> <Loader2 className="size-4 animate-spin text-white" />
@ -204,7 +204,11 @@ function MessageAttachment({
return ( return (
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}> <div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
{getFileIcon(attachment.name)} {getFileIcon(attachment.name)}
<button onClick={handleView} className="flex-1 truncate text-left hover:underline" title="Visualizar"> <button
onClick={handleView}
className="flex-1 truncate text-left hover:underline"
aria-label={`Visualizar anexo ${attachment.name}`}
>
{attachment.name} {attachment.name}
</button> </button>
{sizeLabel && <span className="text-xs opacity-60">({sizeLabel})</span>} {sizeLabel && <span className="text-xs opacity-60">({sizeLabel})</span>}
@ -212,7 +216,7 @@ function MessageAttachment({
<button <button
onClick={handleView} onClick={handleView}
className={`flex size-7 items-center justify-center rounded-md ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`} className={`flex size-7 items-center justify-center rounded-md ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
title="Visualizar" aria-label="Visualizar anexo"
> >
<Eye className="size-4" /> <Eye className="size-4" />
</button> </button>
@ -220,7 +224,7 @@ function MessageAttachment({
onClick={handleDownload} onClick={handleDownload}
disabled={downloading} disabled={downloading}
className={`flex size-7 items-center justify-center rounded-md disabled:opacity-60 ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`} 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 ? ( {downloading ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@ -250,6 +254,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
// Convex hooks // Convex hooks
const { apiBaseUrl, machineToken } = useConvexMachine() const { apiBaseUrl, machineToken } = useConvexMachine()
const { sessions: machineSessions = [] } = useMachineSessions()
const { messages: convexMessages, hasSession, unreadCount, isLoading } = useMachineMessages( const { messages: convexMessages, hasSession, unreadCount, isLoading } = useMachineMessages(
ticketId as Id<"tickets">, ticketId as Id<"tickets">,
{ limit: MAX_MESSAGES_IN_MEMORY } { limit: MAX_MESSAGES_IN_MEMORY }
@ -276,6 +281,22 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
const unreadAgentMessageIds = useMemo(() => getUnreadAgentMessageIds(messages, unreadCount), [messages, unreadCount]) const unreadAgentMessageIds = useMemo(() => getUnreadAgentMessageIds(messages, unreadCount), [messages, unreadCount])
const firstUnreadAgentMessageId = unreadAgentMessageIds[0] ?? null 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 updateIsAtBottom = useCallback(() => {
const el = messagesContainerRef.current const el = messagesContainerRef.current
if (!el) return if (!el) return
@ -562,7 +583,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
<button <button
onClick={handleClose} onClick={handleClose}
className="ml-1 rounded-full p-1 text-slate-600 hover:bg-slate-300/60" className="ml-1 rounded-full p-1 text-slate-600 hover:bg-slate-300/60"
title="Fechar" aria-label="Fechar chat"
> >
<X className="size-4" /> <X className="size-4" />
</button> </button>
@ -621,17 +642,31 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{machineSessions.length > 1 && (
<button
onClick={handleOpenHub}
className="relative rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
aria-label="Abrir lista de chats"
>
<MessagesSquare className="size-4" />
{otherUnreadCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
{otherUnreadCount > 9 ? "9+" : otherUnreadCount}
</span>
)}
</button>
)}
<button <button
onClick={handleMinimize} onClick={handleMinimize}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100" className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Minimizar" aria-label="Minimizar chat"
> >
<Minimize2 className="size-4" /> <Minimize2 className="size-4" />
</button> </button>
<button <button
onClick={handleClose} onClick={handleClose}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100" className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar" aria-label="Fechar chat"
> >
<X className="size-4" /> <X className="size-4" />
</button> </button>
@ -772,6 +807,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
<button <button
onClick={() => handleRemoveAttachment(att.storageId)} onClick={() => handleRemoveAttachment(att.storageId)}
className="ml-1 rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600" className="ml-1 rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
aria-label={`Remover anexo ${att.name}`}
> >
<X className="size-3" /> <X className="size-3" />
</button> </button>
@ -792,7 +828,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
onClick={handleAttach} onClick={handleAttach}
disabled={isUploading || isSending} 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" 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 ? ( {isUploading ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@ -804,6 +840,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
onClick={handleSend} onClick={handleSend}
disabled={(!inputValue.trim() && pendingAttachments.length === 0) || isSending} 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" 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 ? ( {isSending ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />