From a84c0183b130cf027da981818df5802936d1a5a8 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 16 Sep 2026 19:57:01 -0300 Subject: [PATCH] =?UTF-8?q?B203=20=E2=80=94=20Sollinha=20come=C3=A7a=20fec?= =?UTF-8?q?hado=20no=20desktop=20(Closes=20#1107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-16-b203.md | 1 + ...inha-fechado-por-padrao-no-desktop-impl.md | 110 ++++++++++++++++++ .../shell/ai/CampaignAISidebarContext.tsx | 36 ++---- .../shell/ai/CampaignAISidebarShell.tsx | 5 +- tests/e2e/campaignAiChatFollowUps.e2e.spec.ts | 12 ++ .../campaignAiChatOpeningChips.e2e.spec.ts | 12 ++ tests/e2e/campaignAiChatResize.e2e.spec.ts | 30 +++-- tests/e2e/campaignAiTranscribe.e2e.spec.ts | 6 + tests/e2e/campaignSollinhaContext.e2e.spec.ts | 46 +++++--- tests/e2e/campaignSollinhaWidth.e2e.spec.ts | 14 ++- 10 files changed, 216 insertions(+), 56 deletions(-) create mode 100644 docs/changelog/2026-09-16-b203.md create mode 100644 docs/plans/sollinha-fechado-por-padrao-no-desktop-impl.md diff --git a/docs/changelog/2026-09-16-b203.md b/docs/changelog/2026-09-16-b203.md new file mode 100644 index 00000000..697798a9 --- /dev/null +++ b/docs/changelog/2026-09-16-b203.md @@ -0,0 +1 @@ +**B203 — Sollinha começa fechado no desktop (2026-09-16):** sessão nova em `/campanha` no desktop abre com o painel fechado e o conteúdo em largura cheia; o efeito de settle que forçava `setOpen(true)` foi removido do `CampaignAISidebarProvider` (a prop `panelRef`, morta com ele, saiu da assinatura) e o `open` só sai de `false` via restore de sessão ou ação do usuário (FAB/botão do header, que seguem abrindo como hoje com `openBy: 'user'`). Largura B166/B167 (`25%`/cap 360px/resize lembrado), persistência B199 e mobile (drawer fechado por padrão) intocados; `sollinhaChatSession.ts` sem mudança (sessões `openBy: 'settle'` legadas continuam lidas). E2Es que pinavam o auto-open ganharam abertura explícita pelo header (incl. rewrite do teste OPS22 para o default-fechado e assertion de sessão não-vácua); `restoredSessionRef` write-only removido no simplify. Sem migration. Issue #1107. diff --git a/docs/plans/sollinha-fechado-por-padrao-no-desktop-impl.md b/docs/plans/sollinha-fechado-por-padrao-no-desktop-impl.md new file mode 100644 index 00000000..3e3b0ed4 --- /dev/null +++ b/docs/plans/sollinha-fechado-por-padrao-no-desktop-impl.md @@ -0,0 +1,110 @@ +# Impl: B203 — Sollinha começa fechado no desktop + +Status: aprovado (--auto) +Atualizado em: 2026-09-16 +Issue: #1107 +Intenção: docs/plans/sollinha-fechado-por-padrao-no-desktop.md +Appetite restante: ~0,25–0,5 dia eng (mantido; sem migration) + +## Leitura da intenção + +- **Outcome:** sessão nova no desktop abre `/campanha` com o Sollinha fechado e o conteúdo em largura cheia; FAB/botão do header abrem como hoje; a escolha (aberto/fechado) segue lembrada na sessão; mobile intocado; fechado = fechado, sem largura residual. +- **O que NÃO negociar:** a convenção de largura do painel aberto (B166/B167 — `CHAT_DEFAULT_PCT='25'`, `min(25%, 360px)`, resize livre, largura lembrada) e a persistência de sessão (B199 — `open` + `openBy`, restore, mid-stream) permanecem; a mudança é **só o default de sessão nova**, nunca o estado restaurado. Mobile (drawer fechado por padrão, só abre sob comando) intocado. Nenhum estado "meio aberto". +- **O que reavaliar:** a "Direção no codebase" apontava settle (`CampaignAISidebarContext.tsx`), shell (`CampaignAISidebarShell.tsx`), sessão (`sollinhaChatSession.ts`) e os e2es que pinam desktop-open — **confirmado como dono, com um corte importante**: `sollinhaChatSession.ts` **não precisa de mudança** (leitura fail-closed por role e escrita com `openBy` seguem corretas; ausência de sessão continua sendo o único sinal de sessão nova). O fix é a remoção do efeito de settle; o resto é ajuste de teste. `CampaignAISidebarShell.tsx` **não precisa de mudança funcional** (o `chatVisible` já trata o fechado; o zero-residual `b167-ai-chat-hidden` já existe) — ver decisão 2 sobre o frame pré-measured. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[CampaignAISidebarContext: remove efeito settle 160-166] --> B[open nasce false e fica false em sessão nova] + B --> C[Shell: chatVisible=false → Panel hidden + Separator hidden + conteúdo cheio] + C --> D[Header button toggle abre; persist grava open:true openBy:user] + D --> E[reload restaura aberto; fechar persiste fechado; sessão nova volta a fechado] +``` + +**Decisão 1 — como fazer o default fechado.** + +- **Opções:** A) remover o efeito de settle (`settledRef` + `useEffect` 160–166, o `setOpen(true)`); B) inverter o settle para colapsar o painel no desktop (`panel.collapse()` / nascer colapsado); C) manter o settle mas condicioná-lo a "só se houver sessão restaurada". +- **Recomendação:** **A — remover o efeito de settle.** É o fix barato e exato: `useState(false)` (linha 54) já é o estado inicial correto; o restore (88–103) já cobre sessão existente; o `chatVisible` do shell (linha 89) já cobre o fechado; o `b167-ai-chat-hidden` (`display:none !important`) já zera o residual. Remover ~8 linhas + o `settledRef`, sem tocar em restore/persist/`openByForWrite`. +- **Rejeitadas:** + - **B — nascer colapsado / tornar o Panel `collapsible`:** o shell documenta explicitamente (linhas 52–68) que o painel **não** é `collapsible` de propósito — o auto-collapse do RRP sobre painel `display:none` deixava o painel travado colapsado no cruzamento de breakpoint, sem `expand` confiável. Reintroduzir collapsible é regredir B167 para trocar um mecanismo que já funciona (CSS hide) por um que já falhou. + - **C — settle condicional ("só restaura"):** o restore 88–103 já faz exatamente isso; manter um settle que nunca abre é código morto com leitor (o próximo engenheiro relê o comentário B167 e conclui que o desktop abre sozinho). Corte: remover, e atualizar o comentário das linhas 149–158 para o novo default. +- **Caro vs barato:** A custa uma remoção + atualização de comentário; B custaria revalidar todo o cruzamento mobile↔desktop do B167 (testes de resize, foco, largura) — caro e sem ganho. + +**Decisão 2 — o frame pré-measured (`chatVisible = measured ? … : true`).** + +- **Opções:** A) manter `true` pré-measured (hidratação renderiza como desktop-com-chat, shell 85–89); B) virar para `false` pré-measured (primeiro paint já fechado). +- **Recomendação:** **A — manter.** O `true` pré-measured é anti-flash: com o default fechado, o pior caso é um flash de um frame com o painel visível antes do settle fechar via CSS — o mesmo flash que já existe hoje no caminho inverso, e o comentário do shell explica o porquê. Virar para `false` trocaria um flash de 1 frame (chat visível→escondido) por **outro** flash (conteúdo 100%→25%→100% se houver sessão restaurada aberta), quebrando o caso restore-aberto que a intenção manda preservar. +- **Rejeitadas:** B — vide acima; o aceite ("sessão restaurada aberta continua abrindo") pesa mais que 1 frame de painel em sessão nova. + +**Decisão 3 — `openBy: 'settle'` e os efeitos de persist (127–132, 143–147).** + +- **Opções:** A) não mexer; B) remover o valor `'settle'` do tipo e tratar tudo como `'user'`. +- **Recomendação:** **A — não mexer.** Com o settle removido, escritas `settle`-originadas simplesmente param de acontecer em sessão nova (o primeiro `open: true` persistido passa a vir do toggle do usuário → `openBy: 'user'`, que restaura em qualquer viewport — correto). Sessões antigas gravadas com `openBy: 'settle'` ainda podem existir no `sessionStorage` de abas abertas durante o deploy; o restore (`openBy==='user' || !isMobile`) continua tratando-as corretamente. Remover o valor quebraria a leitura dessas sessões (fail-closed → chat "perde" conversa no upgrade). +- **Rejeitadas:** B — migração de formato de `sessionStorage` sem ganho; o tipo documenta história real (OPS22), não intenção futura. + +**Decisão 4 — e2es que pinam o desktop-open.** + +- **Opções:** A) em cada spec afetado, abrir explicitamente pelo botão do header (`Sollinha — Assistente virtual`, `md:inline-flex`) antes de interagir com o chat; B) centralizar um helper "openSollinhaDesktop" e reescrever os specs; C) só relaxar asserções para "aberto ou fechado". +- **Recomendação:** **A — clique explícito no botão do header por spec.** Barato, local, e cada spec continua lendo como cenário real (usuário abre → interage). C deixa os specs sem dente; B é refatoração sem dor que a justifique (os specs já usam `mockSollinhaChat` + `waitForRouterSettled` compartilhados; só falta o clique). +- **Rejeitadas:** B (custo sem ganho — meia dúzia de cliques não pedem abstração) e C (aceite pede "abrir pelo botão funciona como hoje" — o e2e deve provar exatamente isso). + +### Componentes / mudanças + +- **`src/components/campaign/shell/ai/CampaignAISidebarContext.tsx`** (editar — dono do fix): + - Remover o bloco de settle (linhas 159–166: `settledRef` + `useEffect` com `setOpen(true)`). + - Reescrever o comentário das linhas 149–158: o default de sessão nova passa a ser **fechado em todos os viewports**; `open` só sai de `false` via restore de sessão (88–103) ou ação do usuário (`requestOpen`/`toggle`). Manter a explicação de que as duas superfícies derivam do mesmo `open`. + - **Não mexer:** `useState(false)` (54), restore (88–103), `requestOpen`/`toggle` (110–116, 168–171), `openByForWrite` (125–126), persist effects (127–132, 143–147). +- **`src/components/campaign/shell/ai/CampaignAISidebarShell.tsx`** (sem mudança funcional esperada; só comentário se citar "abre sozinho"): + - `chatVisible` (89), classe `b167-ai-chat-hidden` (113–115), Separator (161–166), Panel sempre montado + `{chatVisible ? : null}` (172–180) já implementam o fechado corretamente. O efeito de largura B166 (97–108) roda também com o painel escondido — `resize()` sobre painel `display:none` é inerte e as guardas (`hasUserSizedRef`, `isCollapsed()`) seguem válidas; nenhuma mudança. + - Revisar o comentário 85–89 ("treated as desktop-with-chat-open … settle reconciles"): continua verdadeiro para o frame pré-measured (decisão 2); ajustar a última oração se mencionar reconciliação que não existe mais. +- **`src/lib/sollinhaChatSession.ts`** (não mexer — decisão 3). +- **`CampaignAIFab.tsx` / `CampaignAIHeaderButton.tsx`** (não mexer): FAB `md:hidden` (mobile) + header button `hidden md:inline-flex` (desktop) já dividem as superfícies; `setOpen(true)`/`toggle()` já marcam `userToggledOpenRef` → persist `openBy: 'user'`. Verificar no tracer que o header button segue visível/descobrível com o chat fechado (guardrail da intenção). +- **Migration:** sem migration (zero schema — estado é `useState` + `sessionStorage`). +- **Access / Consent:** nenhum (client-only; sem chave nova, sem coleção). +- **UI:** Design UI N/A (intenção) — sem superfície nova; o estado fechado é o que já existe quando o usuário fecha (reuso, não criação). + +### Dados → forma (se aplicável) + +- N/A — nenhum número novo na tela (intenção §Dados: "Não"). + +## Fases verificáveis + +1. **Tracer — remove o settle e prova o outcome em dev (`pnpm dev`):** remover o efeito 160–166 + reescrever o comentário; rodar `pnpm dev` e verificar numa aba anônima/sessão nova em viewport desktop: (a) `/campanha` abre com conteúdo em largura cheia e `#ai-chat-panel` com `b167-ai-chat-hidden`; (b) clique no botão do header (`Sollinha — Assistente virtual`) abre o painel (~25%/cap 360px, convenção B166 intacta) com a conversa; (c) reload restaura aberto; (d) fechar pelo X persiste fechado após reload; (e) aba nova (sessão nova) volta a fechado; (f) mobile (devtools ou viewport estreito) intocado — drawer fechado, FAB abre. Unit `tests/unit/sollinhaChatSession.unit.spec.ts` deve seguir verde sem mudança (lib intocada). +2. **E2E — atualizar os specs que pinam o auto-open:** em cada spec abaixo, inserir abertura explícita pelo botão do header antes da primeira interação com o chat (mantendo `mockSollinhaChat` + `waitForRouterSettled` existentes): + - `tests/e2e/campaignAiChatResize.e2e.spec.ts` (~linhas 44, 54 — o desktop "abre sozinho"; o teste OPS22 de não-vazamento para mobile continua válido mas o setup "login desktop abre o chat" passa a exigir o clique); + - `tests/e2e/campaignSollinhaContext.e2e.spec.ts` (~linhas 55, 96–112 — `Olá! Eu sou o Sollinha` visível sem comando; o teste OPS22 linha ~192 "o settle do desktop abre" vira "abertura pelo usuário persiste `openBy: 'user'`"); + - `tests/e2e/campaignSollinhaWidth.e2e.spec.ts` (linhas 40–56 — largura padrão presume painel aberto no load); + - `tests/e2e/campaignActivity.e2e.spec.ts` (~linhas 222, 481 — comentário "chat que abriu sozinho" + interação); + - `tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts` (linha ~157–195 — "sidebar and Sollinha change the column stage" presume chat aberto; abrir via header antes de medir colunas). + Rodar os specs afetados isolados + `campaignAiChatFollowUps`/`campaignAiChatOpeningChips`/`campaignAiTranscribe` (consomem o chat aberto; avaliar se precisam do mesmo clique). +3. **Gates — `pnpm gate:fast` na iteração; `pnpm push` na entrega.** Sem changelog commitado aqui (o executor registra `docs/changelog/` conforme AGENTS.md se o fluxo da issue exigir). + +## Rabbit holes / Não escopo (engenharia) + +- Tornar o Panel `collapsible` ou desmontá-lo por breakpoint (remontar painel no cruzamento força navegação full-page — B167; rejeitado na decisão 1). +- Tocar em `openBy`/restore/persist (decisão 3) ou em `sollinhaChatSession.ts` (unit intocado). +- Trocar `chatVisible: true` pré-measured (decisão 2 — anti-flash do restore-aberto). +- Aplicar largura B166 "só se visível" — o `resize()` inerte sobre painel escondido é comportamento validado, não bug. +- Redesenho do painel, hint/onboarding de primeira vez, FAB/header novos (intenção: anti-goals; o botão existente é o convite). +- Persistir largura no estado fechado / "meio aberto" com largura residual (corte da intenção: fechado é fechado). +- Mexer na skill/agente Sollinha, no mobile drawer, ou na largura padrão quando aberto (fora de escopo da intenção). +- **Débito consciente (S3, simplify):** o bloco de abertura desktop nos specs (`waitForRouterSettled` + clique no header) está em ~8 call sites com helpers locais gêmeos (`openDesktopChat` em Resize/Context). Gatilho de extração para helper compartilhado de fixtures: ≥10 call sites **ou** 3ª mudança no bloco — até lá, a decisão 4 (clique explícito por spec) prevalece. + +## Riscos e mitigação + +- **Specs que assumem auto-open quebram em cadeia:** o grep mostra ~6 specs tocando o chat aberto no desktop. Mitigação: fase 2 lista cada um; o padrão de correção é mecânico (um clique no header button); rodar os afetados isolados antes do gate. +- **Teste OPS22 ("settle não vaza para mobile") perde o setup:** ele loga no desktop e conta com o settle para gravar `open: true` (settle-originated). Mitigação: reescrever o setup com abertura explícita pelo header (vira `openBy: 'user'` — e o caso mobile-first "sessão nova mobile não abre" continua coberto pelo default `false`); o invariante real (mobile nunca abre sozinho) fica **mais** forte, não mais fraco. +- **Flash de 1 frame com painel visível em sessão nova (decisão 2):** aceito e documentado; mitigação é não tentar "otimizar" sem evidência (mesmo padrão do B166: one-shot, revisit só com relato real). +- **`#ai-chat-panel` escondido mas com `CampaignAISidebar` desmontado (`chatVisible ? … : null`):** conversa vive no contexto (`useChat` no provider), não no componente montado — fechar nunca perde conversa (guardrail da intenção já garantido pela arquitetura). +- **Sessões `openBy: 'settle'` legadas em abas abertas no deploy:** continuam lidas pelo restore com a regra desktop-only (decisão 3); nenhum usuário perde conversa nem ganha drawer fantasma. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (sessão nova desktop fechada + largura cheia; botão/FAB abrem; escolha lembrada na sessão; mobile intocado; sem residual; B166/B167 + B199 preservados) +- [ ] Invariantes AGENTS/engineering-standards (sem Local API/overrideAccess; client-only; identificadores em inglês; sem migration; sem commit de `importMap.js`) +- [ ] Testes de domínio (unit de sessão verde sem mudança; e2es afetados atualizados para abertura explícita e verdes; nenhum `skip`/`fixme` novo) + +## Self-score (decision-quality) + +4/5 — (1) quatro decisões não-triviais em forma Opções + Recomendação + Rejeitadas (fix, pré-measured, openBy, e2e); (2) caro-vs-barato explícito na decisão 1; (3) appetite ~0,25–0,5 dia respeitado (remoção + cliques de teste, sem migration); (4) depth check: reusa `useState(false)`, `chatVisible`, `b167-ai-chat-hidden`, restore/persist existentes — zero mecanismo novo; (−1) tracer ainda precisa confirmar em dev que o header button segue descobrível com o chat fechado e que nenhum comentário B167 restante mente sobre "abre sozinho". diff --git a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx index 5eb85100..a4d37850 100644 --- a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx +++ b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx @@ -10,10 +10,8 @@ import { useMemo, useRef, useState, - type MutableRefObject, type ReactNode, } from 'react' -import type { PanelImperativeHandle } from 'react-resizable-panels' import { useIsMobileMeasured } from '@/hooks/use-mobile' import type { CampaignRole } from '@/lib/campaignRoles' @@ -44,11 +42,9 @@ const AISidebarContext = createContext(null) export const CampaignAISidebarProvider = ({ role, - panelRef, children, }: { role: CampaignRole - panelRef: MutableRefObject children: ReactNode }) => { const [open, setOpen] = useState(false) @@ -67,15 +63,14 @@ export const CampaignAISidebarProvider = ({ // pre-restore values) cannot overwrite a stored session. // // OPS22: the decision needs the REAL viewport, so the restore waits for the - // first `measured` commit (where `isMobile` is already correct). A - // settle-originated `open: true` (the desktop panel reconcile, B167) is - // layout truth, not user intent — restoring it on a mobile viewport made + // first `measured` commit (where `isMobile` is already correct). A legacy + // settle-originated `open: true` (the pre-B203 desktop panel reconcile, + // B167) is layout truth, not user intent — restoring it on a mobile viewport made // the drawer open "by itself" whenever a desktop tab visited a mobile page // in the same session (the e2e login at desktop width + mobile agenda // reproduced it deterministically). Only a user-originated open (FAB / // header button / drawer swipe) restores on mobile. const [sessionRestored, setSessionRestored] = useState(false) - const restoredSessionRef = useRef(false) const sessionReadRef = useRef(false) const restoredOpenByRef = useRef('settle') // B199: the snapshot of the last settle-complete messages — the ONLY @@ -98,14 +93,13 @@ export const CampaignAISidebarProvider = ({ restoredOpenByRef.current = openBy if (session.open && (openBy === 'user' || !isMobile)) setOpen(true) } - restoredSessionRef.current = session !== null setSessionRestored(true) }, [setMessages, measured, isMobile, role]) // OPS22: every user surface (FAB, header button, drawer swipe) opens/closes // the chat through the context `setOpen`/`toggle` — those mark the intent. - // The restore and the B167 settle call the raw state setter instead, so a - // layout-driven open never counts as a user action. + // Only the restore calls the raw state setter instead, so a restored open + // never counts as a user action. const userToggledOpenRef = useRef(false) const requestOpen = useCallback( (next: boolean) => { @@ -121,7 +115,7 @@ export const CampaignAISidebarProvider = ({ // written: closing an empty chat must overwrite a stored `open: true`, // otherwise a reload would reopen a drawer the user just closed. The // persisted `openBy` tells the next page whether the open was the user's - // (restore it anywhere) or the desktop settle's (desktop-only, OPS22). + // (restore it anywhere) or the legacy desktop settle's (desktop-only, OPS22). const openByForWrite = (): SollinhaChatSessionOpenOrigin => userToggledOpenRef.current || restoredOpenByRef.current === 'user' ? 'user' : 'settle' useEffect(() => { @@ -149,22 +143,12 @@ export const CampaignAISidebarProvider = ({ // The chat has two surfaces keyed by viewport — the desktop panel and the // mobile drawer — and both derive from the SAME `open` flag. Crossing the // breakpoint therefore needs no migration of its own: the surface that renders - // just follows `open && viewport`. The only work is keeping `open` truthful: - // on the desktop settle the panel starts at its RRP default (25%) while `open` - // begins false — reconcile `open` to the panel's real visibility so a visible - // chat is "open" (makes close work and the drawer open on resize). A fresh + // just follows `open && viewport`. B203: a fresh session starts closed on + // every viewport — `open` begins false and only leaves it via a session + // restore (see above) or a user action (`requestOpen`/`toggle`). A fresh // mobile visit keeps `open` false (no spontaneous chat). When a session was // restored, the restored `open` wins instead — a chat closed before the - // reload must not be forced back open by the settle (B188). - const settledRef = useRef(false) - useEffect(() => { - if (settledRef.current) return - settledRef.current = measured - if (measured && !isMobile && !restoredSessionRef.current && !panelRef.current?.isCollapsed()) { - setOpen(true) - } - }, [measured, isMobile, panelRef]) - + // reload must not be forced back open (B188). const toggle = useCallback(() => { userToggledOpenRef.current = true setOpen(!open) diff --git a/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx b/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx index 1f5a11a5..11c795f9 100644 --- a/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx +++ b/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx @@ -42,7 +42,7 @@ export const CampaignAISidebarShell = ({ // chat is scoped by the toolset (speech acervo only), not by hiding the // FAB, the drawer or the desktop header button. (C154 hid all three.) return ( - + {children} @@ -85,7 +85,8 @@ const AISidebarSurfaces = ({ // Before the first viewport measurement the render must not hide the panel // (it would flash the main content at 100% then settle to 25% on desktop); // the hydration frame is treated as desktop-with-chat-open, matching the - // panel's RRP default, and the settle reconciles `open` afterwards. + // panel's RRP default. B203: a fresh session starts closed — `open` only + // leaves false via session restore or user action. const chatVisible = measured ? !isMobile && open : true // Apply the remembered — or the capped-default — width once the desktop diff --git a/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts b/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts index fb5cfcca..6a3b103d 100644 --- a/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts +++ b/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts @@ -111,6 +111,12 @@ test.describe('B192 — follow-ups sugeridos após cada resposta do Sollinha', ( await campaign.login(page, user.email!, user.password) await page.goto('/campanha') + // B203 — fresh desktop sessions start closed; open explicitly. + await waitForRouterSettled(page) + await page + .getByRole('button', { name: 'Sollinha — Assistente virtual' }) + .filter({ visible: true }) + .click() await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) // B191 gesture opens the conversation; the mock answers with a block. @@ -190,6 +196,12 @@ test.describe('B192 — follow-ups sugeridos após cada resposta do Sollinha', ( await campaign.login(page, user.email!, user.password) await page.goto('/campanha') + // B203 — fresh desktop sessions start closed; open explicitly. + await waitForRouterSettled(page) + await page + .getByRole('button', { name: 'Sollinha — Assistente virtual' }) + .filter({ visible: true }) + .click() await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) await askViaInput(page, 'Os dados de 2026 já existem?', 'Os dados de 2026 ainda não existem.') await expect(page.getByText('Os dados de 2026 ainda não existem.')).toBeVisible({ diff --git a/tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts b/tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts index 2d9ca837..ea93162e 100644 --- a/tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts +++ b/tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts @@ -56,6 +56,12 @@ test.describe('B191 — ações rápidas de abertura no chat Sollinha (chips de await campaign.login(page, user.email!, user.password) await page.goto('/campanha') + // B203 — fresh desktop sessions start closed; open explicitly. + await waitForRouterSettled(page) + await page + .getByRole('button', { name: 'Sollinha — Assistente virtual' }) + .filter({ visible: true }) + .click() await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) for (const text of STAFF_DESKTOP_CHIPS) { await expect(chipButton(page, text)).toBeVisible() @@ -108,6 +114,12 @@ test.describe('B191 — ações rápidas de abertura no chat Sollinha (chips de await campaign.login(page, user.email!, user.password) await page.goto('/campanha') + // B203 — fresh desktop sessions start closed; open explicitly. + await waitForRouterSettled(page) + await page + .getByRole('button', { name: 'Sollinha — Assistente virtual' }) + .filter({ visible: true }) + .click() await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) for (const text of LEADER_CHIPS) { await expect(chipButton(page, text)).toBeVisible() diff --git a/tests/e2e/campaignAiChatResize.e2e.spec.ts b/tests/e2e/campaignAiChatResize.e2e.spec.ts index 6567ad4b..a1f422e0 100644 --- a/tests/e2e/campaignAiChatResize.e2e.spec.ts +++ b/tests/e2e/campaignAiChatResize.e2e.spec.ts @@ -9,8 +9,18 @@ import { const MESSAGE = 'Mensagem que migra entre as superficies' -const openChatAndSend = async (page: Page) => { +const openDesktopChat = async (page: Page) => { + // B203 — fresh desktop sessions start closed; open explicitly via the header button. + await waitForRouterSettled(page) + await page + .getByRole('button', { name: 'Sollinha — Assistente virtual' }) + .filter({ visible: true }) + .click() await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) +} + +const openChatAndSend = async (page: Page) => { + await openDesktopChat(page) // OPS42 — dev-only settle before interacting (see `waitForRouterSettled`). await waitForRouterSettled(page) const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' }) @@ -104,13 +114,9 @@ test.describe('B167 — chat Sollinha migra entre painel e drawer ao redimension await campaign.login(page, user.email!, user.password) await page.goto('/campanha') - // Desktop opens the chat at load (as before); close it so it is "fechado". - // OPS42 — dev-only settle before the click (see `waitForRouterSettled`). + // B203 — fresh desktop sessions start closed; no close click needed. + // OPS42 — dev-only settle before asserting (see `waitForRouterSettled`). await waitForRouterSettled(page) - await page - .getByRole('button', { name: 'Fechar', exact: true }) - .filter({ visible: true }) - .click() // Closing hides the desktop panel's footprint. await expect.poll(() => chatPanelWidthAt(page, 1)).toBeLessThan(2) @@ -130,10 +136,8 @@ test.describe('B167 — chat Sollinha migra entre painel e drawer ao redimension const user = await campaign.fixtures.createCampaignUser('coordinator', { name: campaign.fixtures.value('Chat Resize Coordenador'), }) - // Mobile from the start (B188): a desktop login auto-opens the chat (the - // settle below); OPS22 makes that settle-originated `open: true` never - // restore on a mobile page of the same tab, and here the login itself - // happens at mobile width — so no drawer, only the FAB. + // Mobile from the start (B188): B203 starts every fresh session closed, + // so no `open: true` is ever persisted here — no drawer, only the FAB. await page.setViewportSize({ width: 500, height: 800 }) await campaign.login(page, user.email!, user.password) @@ -179,7 +183,9 @@ test.describe('B167 — chat Sollinha migra entre painel e drawer ao redimension await secondTab.setViewportSize({ width: 1280, height: 800 }) await secondTab.goto('/campanha') - await expect(secondTab.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) + // B203 — the new tab is a fresh session, so it starts closed; open it + // explicitly before asserting the empty greeting state. + await openDesktopChat(secondTab) await expect(secondTab.getByText(MESSAGE)).toHaveCount(0) await secondTab.close() }) diff --git a/tests/e2e/campaignAiTranscribe.e2e.spec.ts b/tests/e2e/campaignAiTranscribe.e2e.spec.ts index 5aeaa272..43b3f283 100644 --- a/tests/e2e/campaignAiTranscribe.e2e.spec.ts +++ b/tests/e2e/campaignAiTranscribe.e2e.spec.ts @@ -53,6 +53,12 @@ const mockTranscribe = (page: Page, text: string) => }) const openChat = async (page: Page) => { + // B203 — fresh desktop sessions start closed; open explicitly. + await waitForRouterSettled(page) + await page + .getByRole('button', { name: 'Sollinha — Assistente virtual' }) + .filter({ visible: true }) + .click() await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) // OPS42 — dev-only settle before interacting (see `waitForRouterSettled`). await waitForRouterSettled(page) diff --git a/tests/e2e/campaignSollinhaContext.e2e.spec.ts b/tests/e2e/campaignSollinhaContext.e2e.spec.ts index ebaa1401..61b075db 100644 --- a/tests/e2e/campaignSollinhaContext.e2e.spec.ts +++ b/tests/e2e/campaignSollinhaContext.e2e.spec.ts @@ -51,8 +51,18 @@ const gatedMockAiChat = (page: Page) => { const MESSAGE = 'Mensagem que sobrevive ao reload' const SECOND_MESSAGE = 'Segunda pergunta' -const openChatAndSend = async (page: Page) => { +const openDesktopChat = async (page: Page) => { + // B203 — fresh desktop sessions start closed; open explicitly via the header button. + await waitForRouterSettled(page) + await page + .getByRole('button', { name: 'Sollinha — Assistente virtual' }) + .filter({ visible: true }) + .click() await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) +} + +const openChatAndSend = async (page: Page) => { + await openDesktopChat(page) // OPS42 — dev-only settle before interacting (see `waitForRouterSettled`). await waitForRouterSettled(page) const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' }) @@ -141,8 +151,8 @@ test.describe('B188 — contexto da conversa persiste na sessão da janela/tab', const user = await campaign.fixtures.createCampaignUser('coordinator', { name: campaign.fixtures.value('Contexto Janela Coordenador'), }) - // Mobile from the start: a desktop login would auto-open the chat (B167 - // settle), persist `open: true` and make the drawer open "by itself" here. + // Mobile from the start: B203 starts every fresh session closed, so the + // drawer only opens on user command here. await page.setViewportSize({ width: 500, height: 800 }) await campaign.login(page, user.email!, user.password) await page.goto('/campanha') @@ -166,8 +176,8 @@ test.describe('B188 — contexto da conversa persiste na sessão da janela/tab', const user = await campaign.fixtures.createCampaignUser('coordinator', { name: campaign.fixtures.value('Contexto Janela Coordenador'), }) - // Mobile from the start (see the sibling test): the desktop settle must - // never have run in this tab, so no `open: true` is ever persisted. + // Mobile from the start: B203 never auto-opens, so no `open: true` is + // ever persisted in this tab. await page.setViewportSize({ width: 500, height: 800 }) await campaign.login(page, user.email!, user.password) await page.goto('/campanha') @@ -182,22 +192,32 @@ test.describe('B188 — contexto da conversa persiste na sessão da janela/tab', }) }) - test('settle do desktop não vaza chat aberto para a visita mobile da mesma aba (OPS22)', async ({ + test('sessão nova desktop começa fechada e não vaza chat para o mobile da mesma aba (OPS22/B203)', async ({ page, campaign, }) => { const user = await campaign.fixtures.createCampaignUser('coordinator', { name: campaign.fixtures.value('Contexto Janela Coordenador'), }) - // Desktop login: the B167 settle opens the chat and the persist effect - // writes `open: true` (settle-originated) for the tab. + // B203: desktop login starts closed — no settle auto-opens the chat, so no + // `open: true` is ever persisted for the tab. await campaign.login(page, user.email!, user.password) - await expect.poll(async () => (await storedSession(page))?.open).toBe(true) + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto('/campanha') + // Guardrail: the open button stays discoverable with the chat closed. + await expect( + page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).filter({ visible: true }), + ).toBeVisible({ timeout: 20_000 }) + await waitForRouterSettled(page) + // The persist effect writes even the empty state (B188), so wait for the + // session to exist before asserting it stayed closed — a null session + // would pass vacuously. + await expect.poll(async () => await storedSession(page)).not.toBeNull() + expect((await storedSession(page))?.open).toBe(false) - // A mobile page in the SAME tab must not restore that settle-originated - // open: the drawer would cover the content and aria-hide it (the OPS22 - // regression that turned the agenda-mobile e2e red). The chat stays - // reachable through its button. + // A mobile page in the SAME tab must not show a drawer on its own: the + // chat stays reachable through its button (the OPS22 regression that + // turned the agenda-mobile e2e red can no longer trigger). await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/campanha/agenda') diff --git a/tests/e2e/campaignSollinhaWidth.e2e.spec.ts b/tests/e2e/campaignSollinhaWidth.e2e.spec.ts index 0c848098..3551aeee 100644 --- a/tests/e2e/campaignSollinhaWidth.e2e.spec.ts +++ b/tests/e2e/campaignSollinhaWidth.e2e.spec.ts @@ -1,6 +1,6 @@ import type { Locator, Page } from '@playwright/test' -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { expect, test, waitForRouterSettled } from './fixtures/campaignE2EFixtures.js' type ResponsiveCampaignFixture = { fixtures: { @@ -44,10 +44,18 @@ test.describe('B166 — largura padrão do chat Sollinha com teto no desktop', ( }) => { test.slow() - // Wide desktop: 25% of the group exceeds 360px, so the cap must bite. The - // sidebar is open by default on a fresh context. + // Wide desktop: 25% of the group exceeds 360px, so the cap must bite. + // B203 — fresh sessions start closed; open explicitly via the header button. await page.setViewportSize({ width: 1920, height: 1080 }) await loginAs(page, campaign) + // B203 — fresh sessions start closed; open explicitly via the header button. + // OPS42 — dev-only settle before the click (see `waitForRouterSettled`). + await waitForRouterSettled(page) + await page + .getByRole('button', { name: 'Sollinha — Assistente virtual' }) + .filter({ visible: true }) + .click() + await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) const panel = chatPanel(page) await expect(panel).toBeVisible()