fix(inbox): o painel de CRM cabe na tela — e o header para de travar a largura

Em 1280px o painel de CRM não existia na tela. Não cortado, não parcial:
ausente. O atendente trabalhava sem contexto nenhum do cliente na resolução de
notebook mais comum que existe.

## A causa não era onde parecia

O suspeito óbvio era o grid (`xl:grid-cols-[300px_1fr_320px]`, e o `xl` dispara
justo em 1280). A medição apontou outro lugar:

    grid-template-columns resolvido, de 1280 a 1536:  "300px 706.953px 320px"
                                            em 1920:  "300px 1012px    320px"

A coluna do meio TRAVAVA em 706,95px — o `min-content` dela; `1fr` é
`minmax(auto, 1fr)` e não encolhe abaixo do conteúdo. Filho a filho: o thread
pedia 132px, o composer 370, e o ConversationHeader pedia 707, porque a barra de
ações era `shrink-0`. Um `shrink-0` numa fileira de botões estava definindo a
largura mínima da aplicação inteira.

## Correção de uma afirmação minha

No commit anterior escrevi que o painel era "inalcançável". Errado: o `main` tem
`overflow-x: auto` e `scrollWidth 1351 > clientWidth 1040` — havia scroll, no
`main`, não no documento; eu medira o do documento. Continua defeito (rolar o
inbox de lado para ver o CRM é ruim e ninguém descobre), mas menos grave.

## Escolhido por medição, não por palpite

Cada build custa ~2min, então as variantes foram injetadas por CSS no browser já
renderizado e medidas na hora, em 5 larguras. Três decisões saíram daí:

  * `minmax(0,1fr)` NÃO entrou — mediu idêntico a não tê-lo. Consertado o
    header, o `1fr` encolhe sozinho. Seria mudança sem efeito.
  * Só consertar o header deixava 372px de thread contra um piso de 370 do
    composer: 2px não é margem, é sorte. Duas faixas (compacta no `xl`, generosa
    no `2xl`) dão 424px — 54px de folga — e preservam as laterais onde há espaço.
  * Apertar o gap dos botões economizava 4-6px de altura; não paga a densidade.

## A lapidação que não era sobre caber

O placeholder passou a quebrar em duas linhas num campo de uma linha. Aqui a
screenshot me enganou e a medição corrigiu: eu li como "composer cortado", e
`composer_cortado` media 0 em todas as variantes — era texto, não layout.

O atalho saiu do placeholder para o diálogo de atalhos e o `title`. Ele some
assim que se digita a primeira letra — some exatamente quando você ia quebrar
linha. E o diálogo de atalhos não tinha o atalho mais usado do inbox.
"(só o time vê)" ficou: não é atalho, é consequência.

"Ver contato" aparecia DUAS VEZES na mesma tela — header e card CONTATO do
painel — e era ele que sobrava na segunda linha. Com `xl:hidden` (a mesma
largura em que o painel entra), os botões voltam a caber em uma linha em 1280 e
o header cai de 145px para 107. Abaixo de xl o painel não existe e o botão
continua lá. Não é esconder ação; é parar de repeti-la.

## Medido (tests/sonda-inbox-cabe-na-tela.ts, 10/10)

        vp   fora  painel  thread  hdr_h   acoes  portas
      1280      0     296     424    107     0/4       1     (antes: 311 fora, 707 travado)
      1366      0     296     510    107     0/4       1     (antes: 225 fora)
      1440      0     296     584    107     0/4       1     (antes: 151 fora)
      1536      0     320     628     63     0/4       1     (antes:  55 fora)
      1920      0     320    1012     63     0/4       1     (controle: já cabia)

Duas camadas de guarda, com a limitação declarada: a sonda é a medição de
verdade e NÃO roda no CI; `tests/unit/inbox-header-nao-trava.test.tsx` é a
catraca que roda, olha CLASSE e não pixel — jsdom não tem engine de layout, e um
teste de largura lá mediria zero em tudo e passaria feliz.

Sabotagens: `shrink-0` de volta → previsão 1, resultado 2 (troquei o className
inteiro, levando junto o `flex-wrap`); `flex-wrap` fora → 1 → 1.

## Defeitos meus, achados no caminho

  * mock de `useResumeAi` — módulo que NÃO existe (o real é
    `useResumeAiAttendance`). O teste passava porque o hook verdadeiro rodava: o
    mock não mockava nada e ninguém era avisado.
  * crases dentro de template literal na sonda — `tsc` reprovou; mesmo erro que
    já cometi com SQL.
  * duas imagens versionadas sem citação no commit 3227bf81 — o gate
    `evidencia-citada` reprovou. O pre-commit não roda a suíte, então aquele
    commit deixou o CI vermelho e eu não vi. Corrigido aqui.

typecheck 0 · lint 0 errors · 1819 unitários em 170 arquivos

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkS3mzwtXughmjVC5FCoNo
This commit is contained in:
Rafael Melgaço
2026-08-07 11:32:29 -03:00
co-authored by Claude Opus 5
parent 3227bf8136
commit c56416aad9
13 changed files with 508 additions and 8 deletions
+15 -2
View File
@@ -203,10 +203,23 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
}}
onKeyDown={onKeyDown}
rows={1}
// O atalho saiu do placeholder e foi para o diálogo de atalhos (`?`)
// e para o `title` aqui. Dois motivos, nesta ordem: ele some assim
// que se digita a primeira letra — isto é, some justamente quando
// você ia quebrar linha —; e, com a coluna do inbox mais estreita
// depois do conserto do layout, a frase quebrava em duas linhas
// dentro de um campo de uma linha só.
//
// "(só o time vê)" FICA: não é atalho, é consequência. Quem escreve
// uma nota interna precisa saber que ela não vai para o cliente, e
// essa informação não pode depender de abrir um diálogo.
placeholder={
mode === "note" ? "Escreva uma nota interna… (só o time vê)" : "Escreva uma mensagem…"
}
title={
mode === "note"
? "Escreva uma nota interna… (só o time vê)"
: "Escreva uma mensagem… (Enter envia, Shift+Enter quebra linha)"
? "Enter salva a nota · Shift+Enter quebra linha"
: "Enter envia · Shift+Enter quebra linha"
}
className={cn(
"min-h-9 max-h-40 flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm",
+28 -3
View File
@@ -59,7 +59,18 @@ export function ConversationHeader({ conversation }: Props) {
(silenciada || c?.force_human === true) && status !== "closed" && status !== "archived";
return (
<div className="flex items-center justify-between gap-3 border-b border-border bg-background px-4 py-3">
// `flex-wrap` porque este header travava a LARGURA DA TELA INTEIRA. Ele
// media 707px de `min-content` — a identidade do contato encolhia bem
// (`min-w-0` + `truncate`), mas a barra de ações era `shrink-0` e não
// quebrava. Como a coluna do meio do inbox é `1fr`, que é
// `minmax(auto, 1fr)`, ela não podia ficar menor que esses 707px, e o
// painel de CRM era empurrado 311px para fora da viewport em 1280px.
//
// Reorganizar em vez de esconder: acima de ~1440px o header fica IDÊNTICO ao
// de antes (uma linha), e quando aperta a barra desce para a linha de baixo.
// Nenhuma ação some — um menu "mais" esconderia o "Lembrar" que a spec
// `canais-baseline` clica, e, pior, esconderia ação de quem atende.
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-background px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold">{displayName}</h2>
@@ -82,7 +93,10 @@ export function ConversationHeader({ conversation }: Props) {
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{/* `shrink-0` saiu daqui: era ele que impunha o piso de largura. Agora a
barra pode encolher e quebrar internamente, e os botões continuam
todos visíveis e clicáveis — só que em duas linhas quando preciso. */}
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{isOpen && (
<Button
size="sm"
@@ -146,8 +160,19 @@ export function ConversationHeader({ conversation }: Props) {
Fechar
</Button>
)}
{/* `xl:hidden` porque a partir de 1280px o painel lateral de CRM entra
na tela — e ele já tem um "Ver contato", para o MESMO contato, a um
palmo de distância. Duas portas idênticas na mesma tela não são
redundância inofensiva: são a linha a mais que empurrava a barra de
ações para uma segunda fileira justo na largura mais apertada.
Medido: sem a duplicata, os botões voltam a caber em UMA linha em
1280px.
Abaixo de 1280 o painel não existe, e aí esta é a única porta para o
contato — por isso a condição é a mesma do painel, e não um valor
escolhido à parte. Não é esconder ação; é não repeti-la. */}
{c?.id && (
<Button asChild size="sm" variant="ghost">
<Button asChild size="sm" variant="ghost" className="xl:hidden">
<Link href={`/app/contacts/${c.id}`} className="flex items-center gap-1">
Ver contato
<ArrowRight size={12} weight="regular" aria-hidden />
+20 -1
View File
@@ -160,8 +160,27 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
//
// `dvh` em vez de `vh` porque no celular a `vh` ignora a barra do navegador — o
// mesmo corte, só que pior e mudando conforme se rola a página.
// TRÊS COLUNAS QUE CABEM — medido, não estimado.
//
// O `xl` do Tailwind dispara em 1280px, e era ali que a terceira coluna
// nascia: no ponto exato em que não havia espaço para ela. Com a barra de
// navegação (240px) sobram 1040px, e o grid pedia 300 + 707 + 320 = 1327 —
// o painel de CRM ficava 311px FORA da viewport, alcançável só rolando o
// `main` de lado, que ninguém faz. Em 1280 o atendente simplesmente não via
// contexto nenhum do cliente.
//
// Os 707px eram o `min-content` do `ConversationHeader` (a barra de ações
// era `shrink-0`), e `1fr` é `minmax(auto, 1fr)`: não encolhe abaixo disso.
// Consertado o header, o `1fr` volta a encolher sozinho — `minmax(0,1fr)`
// foi medido aqui e não mudou um pixel, então não entrou.
//
// Duas faixas em vez de uma: compacta onde aperta, generosa onde há espaço.
// Em 1280 isso dá 424px de conversa em vez de 372 — 54px de folga sobre o
// piso do composer (370px), em vez dos 2px que a versão de uma faixa só
// deixava. Margem de 2px não é margem, é sorte.
return (
<div className="grid h-[calc(100dvh-3.5rem-2*var(--space-6))] w-full grid-cols-1 md:grid-cols-[300px_1fr] xl:grid-cols-[300px_1fr_320px]">
<div className="grid h-[calc(100dvh-3.5rem-2*var(--space-6))] w-full grid-cols-1 md:grid-cols-[300px_1fr] xl:grid-cols-[272px_1fr_296px] 2xl:grid-cols-[300px_1fr_320px]">
<div className="flex h-full min-h-0 flex-col border-r border-border">
<InboxFilters value={filterValue} onChange={setFilterValue} />
<div className="min-h-0 flex-1 overflow-hidden">
+5
View File
@@ -10,6 +10,11 @@ const BINDINGS: { keys: string; description: string }[] = [
{ keys: "j", description: "Próxima conversa" },
{ keys: "k", description: "Conversa anterior" },
{ keys: "r", description: "Focar resposta" },
// O atalho mais usado do inbox não estava aqui — vivia só no placeholder do
// composer, que some no instante em que você começa a escrever, ou seja,
// exatamente quando ia precisar dele para quebrar linha.
{ keys: "Enter", description: "Enviar a mensagem" },
{ keys: "Shift + Enter", description: "Quebrar linha sem enviar" },
{ keys: "a", description: "Assumir conversa" },
{ keys: "e", description: "Fechar conversa" },
{ keys: "?", description: "Mostrar atalhos" },
+131 -1
View File
@@ -523,6 +523,9 @@ SOB FALHA (rota interceptada com 500):
oferece "Tentar de novo": true
```
Evidência: [caminho feliz](passo4-inbox-demandas.png) ·
[sob falha](passo4-inbox-demandas-falha.png).
### Sabotagens — previsão antes de rodar
| Sabotagem | Previsão | Resultado | |
@@ -580,10 +583,137 @@ teste unitário, caso 3, com sabotagem do flag `erro` confirmada 1→1, e (b) na
tela, na fase "SOB FALHA" da sonda nova. Mas a linha que escrevi naquele arquivo
segue sem execução, e isso fica declarado.
---
## O inbox cabe na tela (2026-08-07)
O achado da seção anterior virou trabalho. **Em 1280px o painel de CRM não
existia na tela** — nem cortado, nem parcial: ausente. Ver
[antes](inbox-antes-1280.png) e
[depois](inbox-cabe-1280.png), mesma conversa, mesma largura.
### A causa não era onde parecia
O suspeito óbvio era o grid (`xl:grid-cols-[300px_1fr_320px]`, e o `xl` do
Tailwind dispara justamente em 1280). Mas a medição apontou para outro lugar:
```
grid-template-columns resolvido, de 1280 a 1536: "300px 706.953px 320px"
em 1920: "300px 1012px 320px"
```
A coluna do meio **travava em 706,95px** — o `min-content` dela. `1fr` é
`minmax(auto, 1fr)` e não encolhe abaixo do conteúdo. Medindo filho a filho:
thread pedia 132px, composer 370px, e o **`ConversationHeader` pedia 707**,
porque sua barra de ações era `shrink-0`. Um `shrink-0` numa barra de botões
estava definindo a largura mínima da aplicação inteira.
### Correção da minha própria afirmação
No handoff anterior escrevi que o painel era "inalcançável". Errado: o `main`
tem `overflow-x: auto` e `scrollWidth 1351 > clientWidth 1040` — havia scroll,
no `main`, não no documento. Eu medira o do documento. Continua sendo defeito
(rolar o inbox de lado para ver o CRM é ruim e ninguém descobre), mas menos
grave do que afirmei.
### Como escolhi: prototipagem medida, não palpite
Cada `next build` custa ~2min. As variantes foram injetadas por CSS no browser
já renderizado e medidas na hora (`tests/__proto-layout.ts`, descartado depois),
em 5 larguras:
| variante | fora da viewport | thread @1280 | ações perdidas |
|---|---|---|---|
| V0 atual | 311 / 225 / 151 / 55 / 0 | 707 (travado) | 0/5 |
| V1 header reorganiza | **0 em todas** | 372 | 0/5 |
| V2 V1 + `minmax(0,1fr)` | 0 em todas | 372 | 0/5 |
| **V4 duas faixas** | **0 em todas** | **424** | 0/5 |
| V5 V4 + gap menor | 0 em todas | 424 | 0/5 |
Três decisões saíram daí, e nenhuma teria saído de raciocínio:
1. **`minmax(0,1fr)` não entrou.** V2 mediu idêntico a V1 — consertado o header,
o `1fr` volta a encolher sozinho. Seria mudança sem efeito.
2. **V1 sozinho é frágil.** 372px de thread contra um piso de 370 do composer é
2px de folga. Margem de 2px não é margem, é sorte. V4 (duas faixas: compacta
no `xl`, generosa no `2xl`) dá 424px — 54px de folga — e preserva as laterais
generosas onde há espaço.
3. **V5 descartado.** Apertar o gap dos botões economizava 4-6px de altura; o
ganho não paga a densidade visual.
### A lapidação que não era sobre caber
Com o layout corrigido, sobraram dois incômodos que só aparecem olhando:
**O placeholder passou a quebrar.** "Escreva uma mensagem… (Enter envia,
Shift+Enter quebra linha)" não cabe numa coluna de 424px e quebrava em duas
linhas dentro de um campo de uma linha só. Aqui a screenshot me enganou e a
medição corrigiu: eu havia lido como "composer cortado", e `composer_cortado`
media **0** em todas as variantes — era texto, não layout.
O atalho saiu do placeholder e foi para o diálogo de atalhos (`?`) e para o
`title`. Dois motivos, nesta ordem: **ele some assim que se digita a primeira
letra** — isto é, some exatamente quando você ia quebrar linha. E o diálogo de
atalhos, que é o lugar canônico, **não tinha o atalho mais usado do inbox**.
`"(só o time vê)"` da nota interna ficou: não é atalho, é consequência, e quem
escreve uma nota precisa saber que ela não vai para o cliente sem abrir diálogo
nenhum.
**"Ver contato" aparecia duas vezes na mesma tela** — no header e no card
CONTATO do painel — e era justamente ele que sobrava na segunda linha. Medido:
| | 1024 (sem painel) | 1280 | 1440 |
|---|---|---|---|
| com duplicata | 2 linhas, no header ✅ | 2 linhas, **duplicado** | 1 linha |
| sem duplicata ≥ xl | 2 linhas, no header ✅ | **1 linha** | 1 linha |
`xl:hidden` — a mesma largura em que o painel entra. Abaixo dela o painel não
existe e o botão é a única porta, então continua lá. Não é esconder ação; é
parar de repeti-la.
### Resultado medido (`tests/sonda-inbox-cabe-na-tela.ts`, 10/10, exit 0)
```
vp fora painel thread hdr_h acoes portas rola(main/doc)
1280 0 296 424 107 0/4 1 false/false
1366 0 296 510 107 0/4 1 false/false
1440 0 296 584 107 0/4 1 false/false
1536 0 320 628 63 0/4 1 false/false
1920 0 320 1012 63 0/4 1 false/false
```
Contra o antes: `fora` era 311/225/151/55/0 e `thread` era 707 travado.
[1280px](inbox-cabe-1280.png) ·
[1920px, o controle de que nada regrediu no largo](inbox-cabe-1920.png).
### Duas camadas de guarda, e a limitação declarada
`min-content`, quebra de flex e resolução de grid são **cálculo de layout**, e o
jsdom não tem engine de layout — um teste lá mediria zero em tudo e passaria
feliz. Por isso:
- `tests/sonda-inbox-cabe-na-tela.ts` — a medição de verdade, num browser, nas 5
larguras. **Não roda no CI.**
- `tests/unit/inbox-header-nao-trava.test.tsx` — catraca que roda no CI. Olha
CLASSE, não pixel, e isso está escrito no cabeçalho dela: pega a regressão
específica (alguém devolver `shrink-0` "para os botões não quebrarem") e nada
além disso.
Sabotagens: `shrink-0` de volta → previsão 1, **resultado 2** (minha sabotagem
trocou o `className` inteiro, levando junto `flex-wrap`); `flex-wrap` fora do
container → 1 → **1**.
### Defeitos meus, achados no caminho
| # | O quê | Como apareceu |
|---|---|---|
| 1 | Mock de `useResumeAi` — módulo que **não existe** (o real é `useResumeAiAttendance`) | O teste passava porque o hook verdadeiro rodava sob o provider: o mock não mockava nada e ninguém era avisado |
| 2 | Crases dentro de template literal na sonda | `tsc` reprovou — mesmo erro que já cometi com SQL em template literal |
| 3 | **Duas imagens versionadas sem citação** no commit `3227bf81` | O gate `evidencia-citada` reprovou. O pre-commit não roda a suíte, então aquele commit deixou o CI vermelho e eu não vi |
### Pendente
- **A `description` morta do catálogo** — decisão de desenho (48 de 51 divergem).
- **Issue do painel do inbox cortado** em 1280/1440px.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 167 KiB

+178
View File
@@ -0,0 +1,178 @@
/**
* O INBOX CABE NA TELA — regressão de layout, medida por ferramenta.
*
* ## O defeito que esta sonda existe para não deixar voltar
*
* O `xl` do Tailwind dispara em 1280px, e era ali que a terceira coluna do
* inbox nascia — no ponto exato em que não havia espaço para ela. Medido: o
* painel de CRM ficava **311px fora da viewport em 1280px** e 151px em 1440px.
* Alcançável só rolando o `main` de lado, que ninguém faz. Na prática, em
* 1280px o atendente não via contexto nenhum do cliente.
*
* A causa não era o grid: era o `ConversationHeader`, cujo `min-content` era
* **707px** porque a barra de ações era `shrink-0`. `1fr` é
* `minmax(auto, 1fr)` e não encolhe abaixo do conteúdo.
*
* ## Por que sonda de browser e não teste unitário
*
* `min-content`, quebra de flex e resolução de `grid-template-columns` são
* cálculo de layout. O jsdom não tem engine de layout: um teste lá mediria
* zero em tudo e passaria feliz — verde por ausência de motor, o pior falso
* verde que existe. Só um browser real responde a esta pergunta.
*
* A catraca barata que roda no CI é `tests/unit/inbox-header-nao-trava.test.tsx`;
* esta sonda é a medição de verdade.
*
* Uso: `npx tsx tests/sonda-inbox-cabe-na-tela.ts`
* Requer o app na 3100 apontando para o Supabase LOCAL.
*/
import { readFileSync } from "node:fs";
import { chromium } from "@playwright/test";
const BASE = process.env.E2E_PORT ? `http://127.0.0.1:${process.env.E2E_PORT}` : "http://127.0.0.1:3100";
const CONVERSA = "8b9fcd5f-252d-4f6b-9538-f7c2c538807f";
const c = JSON.parse(readFileSync("/Users/rafaelmelgaco/DeskcommCRM/.e2e-creds.json", "utf8"));
/** 1280 e 1366 são as apertadas; 1920 é o controle de que nada regrediu no largo. */
const LARGURAS = [1280, 1366, 1440, 1536, 1920];
/**
* Piso de leitura da conversa. Abaixo disso o thread fica mais estreito que o
* próprio composer (min-content medido: 370px) e a coluna volta a travar. O
* número é a régua, e viaja com o resultado — sem ele, "cabe" não diz se cabe
* com folga ou por um fio.
*/
const THREAD_MIN = 400;
async function main(): Promise<void> {
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } });
const p = await ctx.newPage();
const erros: string[] = [];
p.on("console", (m) => {
if (m.type() === "error") erros.push(m.text());
});
await p.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await p.click('input[type="email"]');
await p.locator('input[type="email"]').pressSequentially(c.users.manager.email, { delay: 6 });
await p.click('input[type="password"]');
await p.locator('input[type="password"]').pressSequentially(c.password, { delay: 6 });
await p.click('button[type="submit"]');
await p.waitForURL(/\/app/, { timeout: 30000 });
await p.goto(`${BASE}/app/inbox?id=${CONVERSA}`, { waitUntil: "networkidle" });
await p.waitForTimeout(2500);
const linhas: Array<Record<string, unknown>> = [];
for (const w of LARGURAS) {
await p.setViewportSize({ width: w, height: 900 });
await p.waitForTimeout(600);
linhas.push(
(await p.evaluate(`(() => {
var sec = document.querySelector('[data-testid="inbox-demandas"]');
if (!sec) return { viewport: window.innerWidth, sem_painel: true };
var aside = sec.closest("aside");
var grid = aside.parentElement;
while (grid && getComputedStyle(grid).display !== "grid") grid = grid.parentElement;
var meio = grid.children[1];
var hdr = meio.children[0];
var main = grid.closest("main");
var ar = aside.getBoundingClientRect();
var hr = hdr.getBoundingClientRect();
// Ação perdida = largura zero, ou fora do retângulo do próprio header.
// Reorganizar é aceitável; esconder ação de quem atende, não.
//
// "Ver contato" fica de FORA desta contagem por decisão explícita: a
// partir de xl ele some do header porque o painel lateral, que acabou de
// entrar na tela, tem a mesma porta para o mesmo contato. A verificação
// dele é o caso contato_alcancavel abaixo — que é a pergunta certa
// ("dá para chegar ao contato?"), e não "este botão específico está
// visível aqui".
var acoes = hdr.children[1];
var botoes = Array.prototype.slice
.call(acoes.querySelectorAll("button, a"))
.filter(function (x) { return !/Ver contato/.test(x.innerText || ""); });
// A porta para o contato existe em ALGUM lugar da tela — header ou painel.
var portas = Array.prototype.slice
.call(document.querySelectorAll("a"))
.filter(function (x) {
return /Ver contato/.test(x.innerText || "") && x.getBoundingClientRect().width > 0;
});
return {
viewport: window.innerWidth,
aside_fora: Math.round(Math.max(0, ar.right - window.innerWidth)),
aside_w: Math.round(ar.width),
thread_w: Math.round(meio.getBoundingClientRect().width),
header_h: Math.round(hr.height),
botoes: botoes.length,
botoes_perdidos: botoes.filter(function (x) {
var r = x.getBoundingClientRect();
return r.width < 1 || r.right > hr.right + 1;
}).length,
contato_alcancavel: portas.length > 0,
// Duas portas idênticas na mesma tela é a duplicata que empurrava o
// header para duas linhas em 1280px.
portas_para_o_contato: portas.length,
main_rola_de_lado: main ? main.scrollWidth > main.clientWidth : null,
doc_rola_de_lado:
document.documentElement.scrollWidth > document.documentElement.clientWidth
};
})()`)) as Record<string, unknown>,
);
}
await p.screenshot({ path: "evidence/inbox-cabe-1920.png" });
await p.setViewportSize({ width: 1280, height: 900 });
await p.waitForTimeout(500);
await p.screenshot({ path: "evidence/inbox-cabe-1280.png" });
await b.close();
console.log(
"vp".padStart(6) +
"fora".padStart(7) +
"painel".padStart(8) +
"thread".padStart(8) +
"hdr_h".padStart(7) +
"acoes".padStart(8) +
"portas".padStart(8) +
" rola(main/doc)",
);
for (const r of linhas) {
console.log(
String(r.viewport).padStart(6) +
String(r.aside_fora).padStart(7) +
String(r.aside_w).padStart(8) +
String(r.thread_w).padStart(8) +
String(r.header_h).padStart(7) +
`${r.botoes_perdidos}/${r.botoes}`.padStart(8) +
String(r.portas_para_o_contato).padStart(8) +
` ${r.main_rola_de_lado}/${r.doc_rola_de_lado}`,
);
}
const casos: Array<[string, boolean]> = [
["mediu todas as larguras (guarda de vacuidade)", linhas.length === LARGURAS.length],
["o painel de CRM existe em toda largura xl+", linhas.every((r) => !r.sem_painel)],
["o painel NUNCA fica fora da viewport", linhas.every((r) => r.aside_fora === 0)],
["nenhuma ação do header se perde", linhas.every((r) => r.botoes_perdidos === 0)],
["o header tem ações para perder (vacuidade)", linhas.every((r) => Number(r.botoes) > 0)],
[`a conversa nunca fica abaixo de ${THREAD_MIN}px`, linhas.every((r) => Number(r.thread_w) >= THREAD_MIN)],
["o inbox não rola de lado em largura nenhuma", linhas.every((r) => r.main_rola_de_lado === false)],
["dá para chegar ao contato em toda largura", linhas.every((r) => r.contato_alcancavel === true)],
["e por UMA porta só — sem duplicata na tela", linhas.every((r) => r.portas_para_o_contato === 1)],
["sem erro de console", erros.length === 0],
];
let falhas = 0;
console.log("");
for (const [nome, ok] of casos) {
console.log(`${ok ? " ok " : "FALHA "} ${nome}`);
if (!ok) falhas += 1;
}
console.log(falhas === 0 ? "\nTODOS OS CASOS PASSARAM" : `\n${falhas} CASO(S) FALHARAM`);
process.exit(falhas === 0 ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+3 -1
View File
@@ -21,7 +21,9 @@ import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { chromium } from "@playwright/test";
const BASE = "http://127.0.0.1:3100";
const BASE = process.env.E2E_PORT
? `http://127.0.0.1:${process.env.E2E_PORT}`
: "http://127.0.0.1:3100";
const CONVERSA = "8b9fcd5f-252d-4f6b-9538-f7c2c538807f";
const CONTATO = "564d1c58-52a1-4610-bd43-2d88df13064c";
const ORG = "ad365e5b-45e5-45d3-99fa-33b388501fec";
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConversationHeader } from "@/components/inbox/ConversationHeader";
/**
* CATRACA: o header do inbox não pode voltar a travar a largura da tela.
*
* ## O defeito, medido
*
* A barra de ações deste header era `shrink-0`. Como ela não encolhia nem
* quebrava, o `min-content` do header inteiro era **707px** — e a coluna do
* meio do inbox é `1fr`, que é `minmax(auto, 1fr)` e não encolhe abaixo do
* conteúdo. Resultado: o painel de CRM ficava **311px fora da viewport em
* 1280px**. Em uma resolução de trabalho comum, o atendente não via contexto
* nenhum do cliente.
*
* ## Por que este teste é o que é (e o que ele NÃO é)
*
* Este teste olha CLASSE, não pixel — e isso é uma limitação declarada, não um
* descuido: `min-content`, quebra de flex e resolução de grid são cálculo de
* layout, e o jsdom não tem engine de layout. Medir largura aqui devolveria
* zero em tudo e passaria feliz: verde por ausência de motor.
*
* A medição de verdade é `tests/sonda-inbox-cabe-na-tela.ts`, que roda num
* browser e afere as 5 larguras. Esta catraca existe porque aquela sonda não
* roda no CI, e a regressão específica — alguém devolver `shrink-0` à barra de
* ações "para os botões não quebrarem" — é textual e barata de pegar.
*
* Se um dia o CI ganhar um passo de browser, este arquivo pode morrer em favor
* da sonda. Enquanto isso, ele é a única coisa entre a regressão e a main.
*/
vi.mock("@/hooks/inbox/useClaimConversation", () => ({
useClaimConversation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/inbox/useCloseConversation", () => ({
useCloseConversation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/inbox/useReleaseConversation", () => ({
useReleaseConversation: () => ({ mutate: vi.fn(), isPending: false }),
}));
// O nome do módulo importa: a primeira versão deste arquivo mockava
// "useResumeAi", que NÃO EXISTE — o real é `useResumeAiAttendance`. O teste
// passou assim mesmo (o hook verdadeiro rodou sob o provider), ou seja, o mock
// não mockava nada e ninguém era avisado. Mock de caminho inexistente é ruído
// que parece cobertura.
vi.mock("@/hooks/inbox/useResumeAiAttendance", () => ({
useResumeAiAttendance: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/auth/AuthProvider", () => ({
useAuth: () => ({ user: { id: "u-1" }, activeOrg: { orgId: "org-1", role: "manager" } }),
}));
const conversation = {
id: "cv-1",
organization_id: "org-1",
contact_id: "ct-1",
status: "open",
assigned_to_user_id: null,
assignee_kind: "ai",
snooze_until: null,
tags: [],
contacts: { id: "ct-1", display_name: "Fulana", name: null, phone_number: "5511999" },
} as unknown as React.ComponentProps<typeof ConversationHeader>["conversation"];
function renderHeader() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ConversationHeader conversation={conversation} />
</QueryClientProvider>,
);
}
describe("header do inbox — não trava a largura da tela", () => {
it("a barra de ações NÃO é shrink-0 — era isso que impunha o piso de 707px", () => {
const { container } = renderHeader();
const header = container.firstElementChild as HTMLElement;
// Guarda de vacuidade: sem header renderizado, todas as asserções abaixo
// passariam por não haver o que verificar.
expect(header, "o header não renderizou").toBeTruthy();
const acoes = header.children[1] as HTMLElement;
expect(acoes, "a barra de ações não renderizou").toBeTruthy();
expect(
acoes.className.split(/\s+/),
"`shrink-0` de volta na barra de ações: o header volta a travar em 707px e o painel de CRM sai da tela em 1280px",
).not.toContain("shrink-0");
});
it("o header pode reorganizar em vez de esconder ação", () => {
const { container } = renderHeader();
const header = container.firstElementChild as HTMLElement;
const acoes = header.children[1] as HTMLElement;
// As duas pontas: o container quebra E a barra quebra internamente. Só uma
// das duas não basta — sem a de dentro, a barra desce inteira e continua
// pedindo a largura toda.
expect(header.className).toContain("flex-wrap");
expect(acoes.className).toContain("flex-wrap");
expect(acoes.className).toContain("min-w-0");
});
it("as ações continuam TODAS no header — reorganizar não é esconder", () => {
renderHeader();
// Se um dia alguém "resolver" o aperto colapsando ações num menu, este caso
// reprova. Esconder ação de quem atende é pior que uma segunda linha.
for (const rotulo of ["Assumir", "Transferir", "Fechar"]) {
expect(screen.getByText(rotulo), `a ação "${rotulo}" sumiu do header`).toBeTruthy();
}
});
it('"Ver contato" existe no DOM e só se cala onde há outra porta', () => {
renderHeader();
// Ele NÃO sai do markup: some por CSS a partir de `xl`, exatamente a largura
// em que o painel lateral entra na tela com um "Ver contato" próprio. A
// distinção importa — remover do DOM tiraria a ação de quem usa 1024px, que
// é onde o painel não existe e esta é a única porta para o contato.
const link = screen.getByText("Ver contato").closest("a, button") as HTMLElement;
expect(link, "o link para o contato sumiu do markup").toBeTruthy();
const classes = `${link.className} ${link.parentElement?.className ?? ""}`;
expect(
classes,
"sem `xl:hidden`, a duplicata volta e o header ganha uma segunda linha em 1280px",
).toContain("xl:hidden");
});
});