Runbook — Bate Papo ao vivo do Sagazchat

Este runbook é um manual operacional para uma IA (ou pessoa) navegar, auditar e operar o Bate Papo ao vivo do Sagazchat via Playwright. Ele consolida o conhecimento de UI, seletore

8 min de leitura Atualizado em 13 de jun. de 2026

Este runbook é um manual operacional para uma IA (ou pessoa) navegar, auditar e operar o Bate Papo ao vivo do Sagazchat via Playwright. Ele consolida o conhecimento de UI, seletores estáveis, comportamentos disparadores, snippets prontos para cada operação e as lições aprendidas em 2026-05-09. A documentação user-facing (src/content/atendimento/bate-papo-vivo.md) descreve as funções para o usuário humano; este arquivo descreve como acessá-las e executá-las programaticamente sem quebrar nada.

Status de validação: trechos marcados com [validado] foram executados e confirmados nesta sessão. Trechos com [a validar] são inferidos a partir do mapeamento da UI — confirme antes de usar em produção. Quando a UI mudar, atualize as marcações.


1. Pré-requisitos

  • Playwright local (node_modules/playwright) — usado via npm install no projeto. Versão validada: 1.59.1.
  • Chromium do Playwright já instalado em %USERPROFILE%\AppData\Local\ms-playwright\chromium-1217\ (não precisa baixar de novo).
  • Credenciais de teste: passar via variáveis de ambiente (SAGAZ_EMAIL, SAGAZ_PASSWORD) na linha de execução. Nunca hardcodar em arquivo.
  • Avast HTTPS scanning quebra npm install. Se precisar de pacotes novos, peça ao usuário para desativar HTTPS scanning ou use download direto via Invoke-WebRequest (URLs do GitHub funcionam sem MITM; URLs de gyan.dev/CDN podem travar no redirect).
  • FFmpeg do Playwright é build mínimo (--disable-everything) — só tem scale/crop/pad. Sem encoder de GIF. Para gerar GIFs, instalar FFmpeg full em %USERPROFILE%\bin\ffmpeg\ (download manual de GitHub release).

2. Sessão persistente (recomendado)

Para evitar relogin a cada execução e manter a conversa aberta entre fases:

  • scripts/sagaz-session.mjs — abre Chromium em background com --remote-debugging-port=9242, faz login (ou usa state.json salvo), abre a conversa de exemplo e expõe o CDP em scripts/_out/cdp.txt. Mantém vivo até TaskStop.
  • scripts/_lib/connect.mjs — helper. Lê cdp.txt e devolve { browser, ctx, page } via chromium.connectOverCDP. Cada fase chama connect(), faz seu trabalho e await browser.close() (que apenas desconecta — o Chromium continua aberto).

Importante: use connectOverCDP, não chromium.connect. O segundo isola contextos entre conexões e o browser.contexts()[0] retorna undefined.

Depois do login, o storage state fica salvo em scripts/_out/state.json. Em re-sessões basta carregar o state e ir direto para /live-chat-whatsapp.

3. Mapa da UI por área

Coordenadas e seletores válidos para viewport 1440x900. Em viewport ≥ ~1700 o painel CRM aparece automaticamente à direita; em telas menores ele só abre clicando no nome do contato no header.

3.1 Sidebar (menu lateral, x ≤ 280)

Hierarquia:

  • Geral › Dashboard / Bate Papo ao vivo (expansível)
    • WhatsApp, WA Oficial, Instagram, Widget Chat, Email, WhatsApp LEGADO
  • Criação › Kanban’s, Assistentes IA, …

Não tem <a> nem <nav> — itens são <div role="button">. Use getByText('WhatsApp', { exact: false }) ou regex (text=/^WhatsApp$/).

3.2 Topo da lista (acima das abas)

  • Busca <input placeholder="Encontre conversas"> em (~315, 60).
  • Kebab “Mais opções” em (592, 32) — aria-label="Mais opções". Menu: Novo atendimento, Chat interno, Agendamento.

3.3 Abas

  • “Atendente N”, “Aguardando N”, “Resolvidos” — texto inclui o badge numérico, então use regex: page.locator('text=/^Atendente/').first(). Nunca getByText('Atendente', { exact: true }).

3.4 Linha de filtros (logo abaixo da busca, y≈114)

Três botões, da esquerda para a direita:

Pos xariaFunção
311(sem)Filtrar por etiqueta
347(sem)Filtrar por atendente
384”Filtros”Filtros gerais (Departamentos, WhatsApps, Status)

Os dois primeiros não têm aria-label — identifique pela ordem e posição.

3.5 Linha da conversa na lista

Cada linha (~62px de altura) é totalmente clicável (cursor:pointer). Ao hover aparece um chevron à direita (~575, 182) que abre o menu de ações da conversa:

Enviar fluxo, Remarketing, Agendar mensagem,
Transferir contato, Marcar como não lida,
Fechar conversa, Deletar conversa

O ícone de relógio com seta ao lado do timer (“2m”, “1hora”) é decorativo — só indica há quanto tempo foi a última mensagem. Não é botão.

3.6 Header da conversa aberta

O conteúdo do header muda conforme o status do ticket:

Conversa em “Atendente” — 4 botões à direita (todos com aria-label):

PosariaFunçãoRisco
1291,36”Retornar”Devolve a conversa para AguardandoDisparador
1330,36”Atalho”Abre painel de respostas rápidas + fluxosSeguro
1373,36”Resolver” (fundo verde #17C75A)Marca como resolvidaDisparador
1417,36(sem aria) — kebab Submenu: Agendar mensagem, Transferir contato, Deletar conversaSeguro abrir

Conversa em “Aguardando” — 1 botão único:

  • “Aceitar Conversa” em (~1360, 36). Composer fica bloqueado (“Reabra ou aceite este ticket para enviar uma mensagem.”).

Conversa em “Resolvidos” — 1 botão único:

  • “Reabrir” em (~1391, 36). Reabrir devolve a conversa para a aba Atendente (com o atendente original).

Os ícones em x=1467 e x=1764 que aparecem no scan são do painel CRM lateral, não do header da conversa. Filtre r.left < 1450 para evitar confusão.

3.7 Tags + Etiquetas (faixa abaixo do header)

Há um único <input placeholder="Etiquetas"> em (~880, 103). O label “Tags” ao lado é só texto — clicar em qualquer parte da faixa foca o mesmo input. Ao focar, abre dropdown com as etiquetas cadastradas. Para aplicar uma etiqueta: await input.click()page.locator('text=Cliente Ativo').click().

3.8 Composer (barra de ações abaixo do campo de mensagem)

7 botões. Identificação confiável pelo SVG path (todos são ícones Lucide):

#Pos xariaSVG path (início)FunçãoRisco
0663”Emoji”M8 14s1.5 2 4 2 4-2 4-2 (smile)Picker de emojiSeguro
1711(sem)m16 6-8.414 8.586 (paperclip)Anexar arquivo (drop zone)Seguro
2759(sem)m21 17-2.156-1.868 (signature)Toggle de assinatura (liga/desliga prefixo nome:)Seguro
3807(sem)(sem paths visíveis no SVG)Inserir/remover fluxo da conversaDisparador quando há fluxo
4855”Abrir mensagens rápidas”M4 14a1 1 0 0 1-.78-1.63 (zap)Respostas rápidasSeguro
51289(sem)M12 14c1.66 0 3-1.34 3-3 (mic)Gravar áudio — INICIA NA HORADisparador
61366(sem)M13 19V7.83l4.88 4.88 (arrow up)Enviar — disabled com campo vazioSeguro

⚠️ Botão 5 (microfone) inicia gravação imediatamente ao clicar. Se acionar acidentalmente, procure o ícone de lixeira que aparece ao lado durante a gravação para descartar — só recarregue a página em último caso.

3.9 Painel CRM lateral

Em viewport ≥ ~1700 ele aparece automaticamente em x ≥ 1450; em telas menores, abre clicando no nome do contato no header.

Não tem abas — é um único bloco scrollável com seções empilhadas:

  1. Header “Dados do contato” + botão de edição (lápis ✏️) à direita.
  2. Identidade — avatar grande, nome, “Criado em DD/MM/AAAA”.
  3. Telefone, E-mail, Instagram (campos vazios mostram “Não informado”).
  4. Fluxos — colapsável, expande no chevron.
  5. Remarketing — botões para cancelar/trocar sequência ativa.
  6. Observações — contador 0 + botão +. “Sem observações registradas.” quando vazio.
  7. Outras informações — campos personalizados. “Nenhuma informação adicional” quando vazio.

4. Ações seguras vs disparadoras

Princípio: no primeiro pass de auditoria, fazer hover-only (sem click) capturando tooltips e SVG paths. Só clicar em botões com função identificada como segura.

Ações seguras (idempotentes — abrem popovers/pickers):

  • Filtros (etiqueta, atendente, gerais)
  • Kebab “Mais opções” da lista
  • Picker de emoji
  • Drop zone de anexo
  • Toggle de assinatura
  • Respostas rápidas/Atalho
  • Chevron da linha da conversa (abre menu)
  • Kebab do header da conversa (Atendente)
  • Input de Etiquetas
  • Click no nome do contato (abre painel CRM)
  • Click em aba (Atendente/Aguardando/Resolvidos)

Ações disparadoras (modificam estado real — não clicar sem necessidade):

  • Botão Resolver (✓ verde) — fecha a conversa
  • Botão Retornar — devolve para Aguardando
  • Botão Aceitar Conversa (em Aguardando)
  • Botão Gravar áudio — inicia gravação na hora
  • Itens do menu da conversa (chevron) que disparam: Enviar fluxo, Remarketing, Agendar mensagem, Transferir contato, Fechar conversa, Deletar conversa
  • Itens do kebab do header: Agendar mensagem, Transferir contato, Deletar conversa
  • Botão Remover fluxo (composer, ícone 3) — quando há fluxo ativo

5. Quirks e pegadinhas

  • Nome do contato é “Fulano Exemplo” com I maiúsculo. A fonte do app pode parecer exemplo (l minúsculo). Sempre use Fulano Exemplo em seletores.
  • O painel CRM existe no DOM mesmo quando visualmente fechado em viewports estreitas — document.querySelectorAll retorna seus elementos com x > 1450. Para não confundir com elementos do header, sempre filtre coordenadas.
  • O label “Tags” ao lado do campo “Etiquetas” não é um campo separado — é só texto decorativo. O foco sempre cai no input “Etiquetas”.
  • Texto das abas inclui o badge numérico (“Atendente1”). Use regex: text=/^Atendente/.
  • Após page.reload() ou page.goto(), a conversa selecionada é perdida — reabra antes de continuar a auditoria.
  • page.bringToFront() antes de cliques visuais — Chromium pode ficar atrás de outras janelas.

6. Regras de departamento (para entender o que está na fila)

  • A visibilidade no bate-papo é restrita pelos departamentos atribuídos ao atendente.
  • Sem fluxo ativo na chegada, a conversa entra sem departamento e fica visível para todos.
  • Cliente sem departamento → todos veem; workaround é criar departamento “Geral” e atribuir à chegada.
  • Atendente em múltiplos departamentos vê a fila combinada; usa o filtro de Departamentos para focar.
  • O filtro “Departamentos” só lista os departamentos a que o atendente tem acesso.
  • Transferência muda visibilidade na hora: o departamento antigo perde acesso.

Detalhe completo: src/content/atendimento/bate-papo-vivo.md seção “Abas de atendimento”.

7. Scripts disponíveis em scripts/

ScriptPropósito
sagaz-session.mjsInicia sessão persistente em background
_lib/connect.mjsHelper para conectar via CDP
sagaz-login.mjsLogin standalone (headed, slowMo 350) — útil pra demonstrar visualmente
sagaz-probe.mjsProbe da página de login (sem submeter)
sagaz-audit-1-list-icons.mjsAudita os 3 filtros acima da lista
sagaz-audit-2-composer.mjsMapeia os 7 ícones do composer (clica todos — risco de gravação)
sagaz-audit-2b-tooltips.mjsHover-only no composer (mais seguro)
sagaz-audit-2c-isolated.mjsClick isolado em ícones específicos com reload entre eles
sagaz-audit-3*-row*.mjsLinha da conversa + chevron menu
sagaz-audit-4-conv-header.mjsHeader da conversa em Atendente
sagaz-audit-5*-tags*.mjsTags + Etiquetas
sagaz-audit-6*-painel*.mjsPainel CRM lateral
sagaz-audit-7-list-kebab-and-aguardando.mjsKebab da lista + header em Aguardando
sagaz-stop-recording.mjsReload de emergência (matar gravação acidental)

8. Operações comuns (snippets)

Todos os snippets assumem que você já chamou connect() do helper e tem { browser, ctx, page }. Cada bloco tem:

  • Objetivo — o que a operação faz e estado pós-execução.
  • Snippet — código Playwright pronto.
  • Confirmação — como detectar sucesso visual/programaticamente.
  • Status[validado] se foi executado nesta sessão; [a validar] caso contrário.

8.1 Abrir uma conversa específica pelo nome

await page.getByText('Fulano Exemplo', { exact: true }).first().click();
await page.waitForTimeout(2500); // header + composer renderizam

Confirmação: header da conversa carrega o nome em < y=50. Status: [validado].

8.2 Trocar de aba (Atendente / Aguardando / Resolvidos)

O texto da aba inclui badge numérico (“Atendente1”). Use regex:

await page.locator('text=/^Aguardando/').first().click();
await page.waitForTimeout(1500);

Status: [validado].

8.3 Aceitar uma conversa em “Aguardando”

Pré-condição: aba “Aguardando” aberta, conversa selecionada. Header mostra botão único “Aceitar Conversa” em (~1360, 36).

await page.getByRole('button', { name: 'Aceitar Conversa' }).click();
await page.waitForTimeout(1500);
// Esperar o header trocar para os 4 botões (Retornar/Atalho/Resolver/Mais opções)
await page.getByRole('button', { name: 'Resolver' }).waitFor({ state: 'visible', timeout: 5000 });

Confirmação: botão “Resolver” aparece, composer libera o placeholder normal “Comece a escrever uma mensagem.”. Status: [validado] em 2026-05-09 — ciclo completo Aceitar → conversa em Atendente confirmado.

8.4 Enviar mensagem de texto

const composer = page.getByPlaceholder(/Comece a escrever/i).first();
await composer.fill('Olá, em que posso ajudar?');
await page.getByRole('button', { name: 'Enviar' }).click();
await page.waitForTimeout(2000);

Confirmação: a mensagem nova aparece no histórico do lado direito da conversa, com timestamp recente. O texto é prefixado com nome: se a assinatura estiver ativa (botão de assinatura no composer com fundo destacado). Status: [validado] em 2026-05-09 — mensagem com timestamp confirmou aparição no histórico.

8.5 Ligar/desligar assinatura do atendente

Botão índice 2 do composer (em x≈759). Toggle visual — quando ligado, fica com fundo destacado.

// Verificar estado e clicar pra inverter
const compBox = await page.getByPlaceholder(/Comece a escrever/i).boundingBox();
await page.mouse.click(759, compBox.y + compBox.height + 31);

Confirmação: comparar background-color do botão antes/depois. Estado desligado: rgba(0, 0, 0, 0). Estado ligado: rgba(199, 240, 0, 0.15) (amarelo claro com transparência). Status: [validado] em 2026-05-09.

8.6 Inserir resposta rápida ou fluxo (composer)

Atalho: digitar / no campo de mensagem abre a lista de atalhos (placeholder confirma: “Coloque / para acessar os atalhos”). Alternativa: clicar no ícone de raio em (x≈855).

const composer = page.getByPlaceholder(/Comece a escrever/i).first();
await composer.click();
await composer.type('/');
await page.waitForTimeout(800);
// Listar opções e escolher

Status: [validado] em 2026-05-09 — digitar / abre um popover acima do composer listando as respostas rápidas cadastradas. O popover não tem [role=listbox] nem [role=menu]; localize por text= ou pelo container do popper.

8.7 Inserir resposta rápida + fluxos (header — botão “Atalho”)

Botão “Atalho” no header da conversa (em x≈1330) abre um painel lateral completo no lugar do painel CRM (sim, substitui o painel CRM enquanto está aberto), com:

  • Header com nome do contato + data de criação + ícone de pena (criar nova) + X (fechar).
  • Duas abas: “Mensagens” (respostas rápidas cadastradas) e “Fluxos”.
  • Linha de filtros por tipo de conteúdo (texto, arquivo, áudio, imagem, vídeo, etc.).
  • Lista das mensagens/fluxos disponíveis com botão de envio direto.
await page.getByRole('button', { name: 'Atalho' }).click();
await page.waitForTimeout(1500);
// Trocar de aba
await page.getByRole('tab', { name: 'Fluxos' }).click();
// Fechar (X no canto superior direito do painel)

Status: [validado] em 2026-05-09 — confirmado que abre painel lateral com abas Mensagens/Fluxos.

8.8 Aplicar etiqueta ao contato

// Click no campo "Etiquetas" (placeholder visível abaixo do header)
await page.getByPlaceholder('Etiquetas').first().click();
await page.waitForTimeout(800);
// O dropdown abre com a lista de etiquetas cadastradas; clicar pra aplicar
await page.locator('.MuiAutocomplete-popper li').filter({ hasText: 'Cliente Ativo' }).first().click();
await page.waitForTimeout(500);
await page.keyboard.press('Escape'); // fecha dropdown

Confirmação: etiqueta aparece como pill colorida na faixa abaixo do header (chip MUI). Para remover, clique na chip — o ícone de X é um SVG, então use dispatchEvent(new MouseEvent('click', { bubbles: true })) em vez de .click() (que falha em SVG). Status: [validado] em 2026-05-09 — aplicação confirmada (chips antes/depois conferem); remoção testada com SVG dispatch.

8.9 Adicionar observação interna no contato

Painel CRM precisa estar aberto (clicar no nome do contato no header — ver 8.23).

A seção “Observações” é um MuiAccordion que já vem expandido por padrão. O + que aparece ao lado do contador “0” abre uma <textarea> inline para digitar a nova observação.

⚠️ Pegadinha: o + está embedado dentro do <button MuiAccordionSummary> que controla o expand/collapse do accordion. page.mouse.click(x, y) na coordenada do + é capturado pelo button do accordion (que apenas COLAPSA a seção). Para acionar o handler do +, dispare o click diretamente no <span> que envolve o SVG, via DOM API:

// Painel CRM já aberto (ver 8.23)
await page.evaluate(() => {
  const path = document.querySelector('path[d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"]');
  const span = path.closest('span');
  span.click();
});
await page.waitForTimeout(1500);

// Aparece <textarea placeholder="Insira aqui a informação que deseja registrar">
const obsTextarea = page.getByPlaceholder('Insira aqui a informação que deseja registrar');
await obsTextarea.fill('Cliente solicitou retorno depois de amanhã');
// (botão de salvar/enviar a observação aparece no painel — confirmar localização e clicar)

Status: [validado] em 2026-05-09 — textarea de criação localizada (placeholder="Insira aqui a informação que deseja registrar", ~273×97 px); botão de salvar a observação [a validar].

8.10 Transferir conversa para outro departamento/atendente

Pelo menu da linha (chevron):

// Hover na linha pra revelar o chevron
const row = page.getByText('Fulano Exemplo', { exact: true }).first();
await row.hover();
await page.waitForTimeout(500);
// Click no chevron (em x≈575 da linha)
const rowBox = await row.boundingBox();
await page.mouse.click(575, rowBox.y + rowBox.height / 2 + 8);
await page.waitForTimeout(1000);
// Click em "Transferir contato"
await page.getByRole('menuitem', { name: 'Transferir contato' }).click();
await page.waitForTimeout(1500);
// Modal de transferência abre — preencher destino

Pelo kebab do header (mais opções): mesmo “Transferir contato” no submenu.

await page.locator('button[aria-label="Mais opções"], button:has(svg path[d*="M12 8c1.1 0 2-.9"])').first().click();
await page.getByRole('menuitem', { name: 'Transferir contato' }).click();

Modal “Transferir Ticket” abre com:

  • Input de busca de usuário (Digite para buscar usuários).
  • Seletor de departamento — <input name="queueId"> (Transferir para departamento).
  • Botões: Cancelar, Transferir.

Confirmação após Transferir: conversa some da lista atual (regra de visibilidade por departamento). Status: [validado] em 2026-05-09 — modal abrindo confirmado; transferência efetiva [a validar].

8.11 Marcar como resolvida (Resolver)

Botão verde no header (em x≈1373) — só aparece em conversas Atendente.

await page.getByRole('button', { name: 'Resolver' }).click();
await page.waitForTimeout(3000);

Confirmação: conversa some da aba “Atendente” e aparece em “Resolvidos”. Status: [validado] em 2026-05-09.

8.11a Reabrir conversa resolvida

Em conversas dentro da aba “Resolvidos”, o header tem um botão único “Reabrir” em (~1391, 36).

await page.locator('text=/^Resolvidos/').first().click();
await page.waitForTimeout(1500);
await page.getByText('NOME', { exact: true }).first().click();
await page.waitForTimeout(2500);
await page.getByRole('button', { name: /reabrir/i }).first().click();

Confirmação: conversa volta para Atendente (com o atendente que tinha antes). Status: [validado] em 2026-05-09.

8.12 Devolver para “Aguardando” (Retornar)

await page.getByRole('button', { name: 'Retornar' }).click();
await page.waitForTimeout(3000);

Confirmação: header muda para botão único “Aceitar Conversa”; conversa volta pra fila de Aguardando. Status: [validado] em 2026-05-09.

8.13 Marcar como não lida

const row = page.getByText('Fulano Exemplo', { exact: true }).first();
await row.hover();
await page.waitForTimeout(400);
const rowBox = await row.boundingBox();
await page.mouse.click(575, rowBox.y + rowBox.height / 2 + 8); // chevron
await page.getByRole('menuitem', { name: 'Marcar como não lida' }).click();

⚠️ Item ficar desabilitado (aria-disabled="true"): a opção só fica habilitada quando há mensagem do contato não lida. Se o atendente foi o último a mandar mensagem, ou se todas as mensagens já foram visualizadas, “Marcar como não lida” aparece em cinza no menu e o click é ignorado.

Status: [validado] em 2026-05-09 (item localizado no menu; restrição de habilitação confirmada).

8.14 Fechar conversa (do menu)

Fechar conversa no menu do chevron tem o mesmo efeito que o botão Resolver do header — move a conversa para a aba Resolvidos. Use o que for mais conveniente:

// Caminho A — botão Resolver (mais direto, exige conversa aberta)
await page.getByRole('button', { name: 'Resolver' }).click();

// Caminho B — Fechar conversa via chevron (não exige abrir a conversa)
const row = page.getByText('NOME', { exact: true }).first();
await row.hover();
const chev = await /* ...locate chevron... */;
await page.mouse.click(chev.x, chev.y);
await page.getByRole('menuitem', { name: 'Fechar conversa' }).click();

Status: [validado] em 2026-05-09 — caminho A (Resolver) confirmado movendo para Resolvidos; caminho B presumido equivalente conforme confirmação do usuário.

8.15 Deletar conversa (DESTRUTIVO)

⚠️ Apaga a conversa em definitivo. Sempre exige autorização explícita do usuário — não execute autonomamente em hipótese alguma.

const row = page.getByText('NOME', { exact: true }).first();
await row.hover();
await page.mouse.click(575, /* chevron y */);
await page.getByRole('menuitem', { name: 'Deletar conversa' }).click();
// Provavelmente abre modal de confirmação — capturar e respeitar.

Status: [não validado por decisão] em 2026-05-09 — operação destrutiva, deliberadamente não testada na conversa de exemplo. Quando for executar pela primeira vez, capture e documente o modal de confirmação esperado.

8.16 Enviar fluxo manualmente

const row = page.getByText('Fulano Exemplo', { exact: true }).first();
await row.hover();
await page.mouse.click(575, /* chevron y */);
await page.getByRole('menuitem', { name: 'Enviar fluxo' }).click();
await page.waitForTimeout(1500);
// Modal "Disparar fluxo" abre com:
//   - Texto: "Este ticket é individual. Disparo via Flow normal."
//   - <input placeholder="Selecione um fluxo"> — autocomplete
//   - Botões: Cancelar, Disparar

Status: [validado] em 2026-05-09 — modal abrindo confirmado; disparo [a validar].

8.17 Inscrever em remarketing

await page.getByRole('menuitem', { name: 'Remarketing' }).click();
// Modal "Disparar remarketing" abre com:
//   - Label: "Selecione um remarketing"
//   - Input autocomplete
//   - Botões: Cancelar, Disparar

Status: [validado] em 2026-05-09 — modal abrindo confirmado; disparo [a validar].

8.18 Agendar mensagem

⚠️ Existem dois “Agendar mensagem” com comportamentos diferentes:

Caminho A — Chevron da linha da conversa (visualização):

// Hover na linha + click no chevron + click "Agendar mensagem"
// Abre modal "Mensagens agendadas" — apenas LISTA dos agendamentos existentes.
// Sem botão "Nova" ou form de criação. Use só para conferir o que já está agendado.

Caminho B — Kebab do header da conversa (criação):

await page.getByText('Fulano Exemplo', { exact: true }).first().click(); // garante conversa aberta
await page.waitForTimeout(2000);
await page.mouse.click(1417, 36); // kebab do header (ao lado de Resolver)
await page.waitForTimeout(1000);
await page.getByRole('menuitem', { name: 'Agendar mensagem' }).click();
await page.waitForTimeout(2000);
// Modal "Mensagem Pendente" abre com:
//   - <textarea name="body"> — mensagem
//   - <input type="datetime-local" name="sendAt"> — data e hora
//   - Botões: fechar (aria-label="fechar"), Cancelar, Adicionar
await page.locator('textarea[name="body"]').fill('Mensagem agendada via runbook');
await page.locator('input[name="sendAt"]').fill('2026-05-10T10:00');
await page.getByRole('button', { name: 'Adicionar' }).click();

Confirmação: após “Adicionar”, a mensagem aparece na lista quando você abre o modal de visualização (Caminho A).

Status: [validado] em 2026-05-09 — caminho B confirmado abrindo o formulário “Mensagem Pendente”; criar registro em si [a validar].

8.19 Buscar conversa por nome ou telefone

const search = page.getByPlaceholder('Encontre conversas');
await search.fill('55119XXXXXXX');
await page.waitForTimeout(800);
// Lista filtra em tempo real

A busca afeta todas as abas simultaneamente (Atendente/Aguardando/Resolvidos) — os badges das abas são recalculados em tempo real pra refletir os matches dentro de cada uma.

Status: [validado] em 2026-05-09.

8.20 Filtrar a lista por etiqueta

// Clica no ícone de etiqueta (em x≈311, y≈114)
await page.mouse.click(311, 114);
await page.waitForTimeout(800);
// Dropdown abre; clicar na etiqueta desejada
await page.locator('text=Cliente Ativo').first().click();
await page.keyboard.press('Escape');

Status: [validado] para abrir; aplicar filtro [a validar].

8.21 Filtrar por atendente

Mesmo padrão da etiqueta, mas com clique em (347, 114).

8.22 Filtrar por departamento/WhatsApp/status (funil)

await page.getByRole('button', { name: 'Filtros' }).click();
await page.waitForTimeout(800);
// Dropdown com Departamentos / WhatsApps / Status
await page.getByText('Departamentos', { exact: true }).click();
// Lista de departamentos disponíveis abre — clicar no desejado

Status: [validado] para abertura; seleção [a validar].

8.23 Abrir painel CRM e editar dados do contato

⚠️ Sempre é necessário clicar no nome do contato no header para abrir o painel — o tamanho do viewport não importa. A premissa anterior de que viewport ≥ 1700 abriria automaticamente estava errada.

// 1. Clicar no nome do contato no header (no topo da conversa, y < 50)
const headerName = await page.evaluate(() => {
  const c = Array.from(document.querySelectorAll('p, span'))
    .find((el) => el.textContent?.trim() === 'Fulano Exemplo' && el.getBoundingClientRect().top < 50);
  if (!c) return null;
  const r = c.getBoundingClientRect();
  return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
});
await page.mouse.click(headerName.x, headerName.y);
await page.waitForTimeout(2500);

// 2. Click no lápis (Material Edit path "M3 17.25V21")
const editBtn = page.locator('button').filter({
  has: page.locator('svg path[d^="M3 17.25V21"]')
}).first();
await editBtn.scrollIntoViewIfNeeded();
await editBtn.click();

Modal “Editar contato” abre com:

  • Labels: “Dados do contato”, “Nome”, “E-mail”, “Informações adicionais”.
  • <input name="name"> com nome atual.
  • <input type="tel"> com placeholder formato BR +55 (11) 99877-4455.
  • <input name="email">.
  • Botões: “adicionar informação” (campos personalizados), “Cancelar”, “Salvar”.

Status: [validado] em 2026-05-09 — modal abrindo confirmado; salvar [a validar].

8.24 Acessar menu “Mais opções” da lista (Novo atendimento, Chat interno, Agendamento)

Os três itens têm comportamentos diferentes:

  • Novo atendimento: abre modal “Criar atendimento” com input “Digite para pesquisar o contato” + Cancelar/Salvar.
  • Chat interno: substitui a área da conversa por um painel embutido “Chat Interno” (não é dialog). Tem botão verde ”+ Novo” pra iniciar conversa com a equipe; estado vazio mostra “Inicie uma nova conversa interna” e “Chat não encontrado”.
  • Agendamento: substitui a área da conversa por um painel embutido “Agendamentos” (não é dialog). Tem botão verde ”+ Novo Agendamento” no canto superior direito + barra de pesquisa; estado vazio mostra “Nenhum agendamento encontrado”.
// Abrir Novo atendimento (modal)
await page.getByRole('button', { name: 'Mais opções' }).click();
await page.getByRole('menuitem', { name: 'Novo atendimento' }).click();
await page.waitForTimeout(2000);

// Abrir Chat interno (painel embutido)
await page.getByRole('button', { name: 'Mais opções' }).click();
await page.getByRole('menuitem', { name: 'Chat interno' }).click();
await page.waitForTimeout(2000);
// Pra fechar e voltar pra conversa: clicar em uma conversa da lista, ou navegar
await page.getByText('Fulano Exemplo', { exact: true }).first().click();

Status: [validado] em 2026-05-09.

9. Detecção de sucesso (toasts MUI)

O Sagazchat usa toasts no canto superior direito (acima do painel CRM ou no canto da tela) para confirmar ações. Exemplos vistos:

  • “Login efetuado com sucesso!”
  • “Fluxo desta conversa foi removido!”

Padrão de captura:

const toast = await page.locator('[role=alert], .MuiAlert-root, .Toastify__toast').first();
const text = await toast.textContent({ timeout: 5000 }).catch(() => null);
console.log('toast:', text);

Para esperar sucesso depois de uma ação destrutiva, prefira: Promise.race([toastShown(), errorShown(), timeout]).

10. Recuperação e tratamento de erros

  • Click acidental em botão disparador (gravar áudio, etc.): procure ícone de lixeira/cancelar dentro da própria UI antes de recarregar. Recarregar é último recurso porque pode descartar trabalho não salvo.
  • Conversa some da lista após ação: verificar regras de departamento (seção 6) — após transferência, atendentes do antigo perdem acesso na hora.
  • Timeout em getByText('Atendente', exact: true): o texto inclui o badge. Use regex text=/^Atendente/.
  • browser.contexts()[0] retorna undefined ao conectar: você usou chromium.connect em vez de chromium.connectOverCDP. Troque.
  • Painel CRM existe no DOM mas não aparece visualmente: viewport pequena. Aumente para ≥ 1700 com page.setViewportSize ou clique no nome do contato.
  • Avast bloqueia download: se for de gyan.dev (redirect), troque pra URL direta do GitHub release. Se for npm install, peça pra desativar HTTPS scanning temporariamente.

11. Como iniciar uma nova auditoria do zero

# 1. Iniciar sessão persistente (background)
$env:SAGAZ_EMAIL='...'; $env:SAGAZ_PASSWORD='...';
node .\scripts\sagaz-session.mjs   # rodar com run_in_background no harness

# 2. Aguardar scripts/_out/cdp.txt aparecer

# 3. Rodar a fase desejada
node .\scripts\sagaz-audit-<fase>.mjs

# 4. Ao terminar, parar a sessão (TaskStop no harness)