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
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 167 KiB |
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||