diff --git a/docs/plans/e2e-dev-router-quiescencia.md b/docs/plans/e2e-dev-router-quiescencia.md
new file mode 100644
index 00000000..5a06a791
--- /dev/null
+++ b/docs/plans/e2e-dev-router-quiescencia.md
@@ -0,0 +1,92 @@
+# OPS42: e2e dev — navegação RSC do login commita tarde e remonta o layout (interação silenciosamente perdida)
+
+Status: registrado
+Atualizado em: 2026-08-11
+Issue: #691
+Priority: P3
+Model: composer-2.5
+Appetite: ~0,5 dia eng — um helper de "quiescência do router" no fixture
+
+## Intenção
+
+Em dev mode (e2e local, compile a frio), a navegação RSC do login pode commitar
+**~1,8 s depois** do `goto('/campanha')` do teste: o fixture resolve o
+`waitForURL` no redirect otimista, o teste segue para `goto` + interação
+imediata, e a payload RSC do redirect — ainda em voo — commit por cima,
+**remontando o subtree do layout** (provider do chat novo, textarea novo com
+`defaultValue=''`). A interação (fill + Enter) cai no textarea velho e é
+silenciosamente perdida: input volta a `''`, o envio nunca dispara, o teste
+estoura em `toBeVisible`. CI (prod mode) é imune — por isso main está verde.
+
+## Persona e fluxo
+
+- **Persona:** agente rodando e2e local em dev mode com a máquina sob carga
+ (worktrees paralelos compilando).
+- **Fluxo quebrado:** login → `page.goto('/campanha')` → `'Olá! Eu sou o
+Sollinha'` visível → `fill` + `Enter` → nada acontece; o teste falha com o
+ input vazio e "Enviar" disabled, aparentemente sem causa.
+- **Fluxo desejado:** o teste só interage depois que o router estabilizou
+ (nenhuma framenav/navegação RSC pendente), ou a interação sobrevive à
+ remontagem.
+
+## Objetivo e aceite
+
+- Interagir com a página em dev mode nunca mais perde o fill/Enter para uma
+ remontagem tardia do layout.
+- Sem impacto em prod mode (CI) — o helper é dev-only ou inócuo em prod.
+- Precedentes a seguir: `checkRadixWhenHydrated` (fixture, retry até o estado
+ certo) e o hardening OPS30 (login resiliente a compile frio).
+
+## Dados (intenção)
+
+Dados: N/A — infraestrutura de e2e (fixture), sem servidor/banco.
+
+## Direção no codebase (hipótese)
+
+- **Área provável:** `tests/e2e/fixtures/campaignE2EFixtures.ts` — o `login`
+ resolve no `waitForURL` (redirect otimista) antes do commit RSC; e/ou um
+ helper `waitForRouterSettled(page)` chamado após `goto` nos journeys que
+ interagem logo após o load (espera por uma janela de ~500 ms sem
+ `framenavigated`).
+- **Evidência da sessão (B199, 2026-08-11):** em dev mode sob carga, o teste
+ B188 "reload na mesma aba" falhou 5/5 com esse mecanismo: `FRAMENAV
+/campanha` (sem request document) ~1,0–1,9 s após o `HELLO`, textarea `ta2`
+ criada com `defaultValue=''` no instante do fill, zero writes de `.value` no
+ textarea (React nunca commitou o valor digitado — o estado do useChat
+ resetou na remontagem do provider).
+- **Risco:** esperar demais (networkidle nunca chega em dev com HMR ativo) —
+ a janela de quiescência deve ser baseada em **ausência de framenav**, não em
+ network idle.
+
+## Dependências
+
+- #586 (OPS30 — hardening e2e dev: login resiliente a compile frio) — mesma
+ família de robustez; destrava sozinha quando o pai flipar `done`.
+
+## Fora de escopo
+
+- Consertar o comportamento do Next dev (a remontagem em si é da framework).
+- O flake de `googleCalendarSync` sob carga (#686 já registra).
+- Mudanças em prod mode/CI (imunes por construção).
+
+## Rabbit holes de produto
+
+- **Espera longa demais por teste:** multiplicar o tempo de cada spec em dev.
+ **Corte:** o helper só espera a quiescência (janela sem framenav), não
+ network idle; e só é chamado onde o journey interage imediatamente após o
+ load.
+- **Camuflar falha real:** esperar quiescência pode mascarar um problema de
+ app. **Corte:** o helper tem timeout curto (fallback) e a falha original do
+ app continua aparecendo no assertion seguinte.
+
+## Questões em aberto (produto)
+
+- Nenhuma — é infraestrutura de teste.
+
+## Referências
+
+- Evidência completa da sessão B199 (mensagens de debug `TMPDEBUG2–8` no
+ worktree B199, 2026-08-11): `NAVREQ/FRAMENAV/LOAD` timestamps, `SET
+defaultValue ta2=""`, setter de `value` nunca chamado.
+- `tests/e2e/campaignSollinhaContext.e2e.spec.ts` (B188 "reload na mesma aba"
+ é o teste que flakeia), `tests/e2e/fixtures/campaignE2EFixtures.ts`.
diff --git a/docs/plans/sollinha-close-mid-stream-persist-impl.md b/docs/plans/sollinha-close-mid-stream-persist-impl.md
new file mode 100644
index 00000000..c3334001
--- /dev/null
+++ b/docs/plans/sollinha-close-mid-stream-persist-impl.md
@@ -0,0 +1,84 @@
+# Impl: Sollinha: fechar o drawer durante streaming pode reabri-lo num reload (race de persistência)
+
+Status: aprovado
+Atualizado em: 2026-08-11
+Issue: #672
+Intenção: docs/plans/sollinha-close-mid-stream-persist.md
+Appetite restante: herdado (~0,5 dia eng; um ajuste no persist effect do provider)
+
+## Leitura da intenção
+
+- **Outcome:** fechar o drawer em qualquer fase do turno (inclusive mid-stream) persiste `open: false` de imediato — um reload antes do settle não ressuscita o drawer; a conversa em andamento continua persistindo no settle normal.
+- **O que NÃO negociar:** o mecanismo de restore/`openBy` (OPS22/B188) fica intacto — o fix é só no write de fechamento; mensagens mid-stream nunca são persistidas (reload mid-stream perde só o turno em voo, corte documentado de B188); nada de migration/servidor/Consent.
+- **O que reavaliar:** a hipótese da intenção ("efeito separado só para `open`, sem gate de status, reusando `writeSollinhaChatSession` com as mensagens do último commit") está correta; o refinamento é o `status === 'ready'` skip no efeito novo para não duplicar o write que o efeito de settle já faz (ver D1).
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ subgraph provider [CampaignAISidebarContext]
+ R[restore no mount] --> S1[settle effect
status ready: escreve msgs + open
+ atualiza settledMessagesRef]
+ O[open effect
open mudou + status != ready:
escreve settledMessagesRef + open]
+ end
+ S1 -.->|snapshot msgs settle-completas| REF[settledMessagesRef]
+ O -->|write imediato de open| W[writeSollinhaChatSession]
+ S1 --> W
+```
+
+**Opções consideradas:** A) efeito separado só para `open`, sem gate de status, gravando com o snapshot de mensagens do último settle | B) persistir tudo sem gate de status num efeito único | C) efeito separado só para fechamento (`open === false`)
+**Recomendação:** **A** com um refinamento — o efeito novo pula quando `status === 'ready'`, porque o efeito de settle já escreve tudo (mensagens + `open`) nesse estado; sem o skip, um `open` mudado em repouso dispararia dois writes no mesmo commit (um com snapshot velho, um com live — correto por "último write vence", mas redundante e dependente da ordem dos efeitos). O resultado cobre o aceite: fechar mid-stream escreve `open: false` com o snapshot de mensagens settle-completas (nunca um stream pela metade).
+**Rejeitadas:** **B** porque gravaria mensagens mid-stream no storage (o rabbit hole que a intenção nomeia — reload perderia a cauda do turno persistida); **C** porque abre mid-stream continuaria com `open` velho no storage (reload após abrir-durante-streaming devolveria o drawer fechado apesar da intenção do usuário) e a regra fica assimétrica sem ganho — o write em ambas as direções honra a regra OPS22 existente (open de origem `user` restaura em qualquer viewport; origem `settle` nunca restaura o drawer mobile) e o settle do desktop roda sempre em `ready`, então nenhum write mid-stream nasce da origem `settle`.
+
+### Componentes / mudanças
+
+- **`CampaignAISidebarContext.tsx`** (`src/components/campaign/shell/ai/`):
+ - Novo `settledMessagesRef = useRef([])` — o snapshot das mensagens do último write settle-completo; único conteúdo que um write mid-stream pode gravar.
+ - O persist effect atual (linhas 116-121) fica como está (gate `sessionRestored && status === 'ready'`, escreve `messages + open + openBy`) e ganha `settledMessagesRef.current = messages` no corpo, após o gate.
+ - Novo efeito de `open`: gate `sessionRestored && status !== 'ready'` (skips em repouso), deps `[sessionRestored, status, open]`; escreve `writeSollinhaChatSession(settledMessagesRef.current, open, openBy)`.
+ - `openBy` extraído para um closure local `openByForWrite()` compartilhado pelos dois efeitos (mesma regra B188: `userToggledOpenRef || restoredOpenBy === 'user'` → `'user'`, senão `'settle'`).
+ - Comentário do bloco B188 atualizado: mensagens só no settle; `open` é persistido imediatamente (com snapshot), nunca mensagens mid-stream.
+- **`tests/e2e/campaignSollinhaContext.e2e.spec.ts`**: novo describe `B199` com um teste determinístico (ver Fases).
+- **Migration:** sem migration. **Access / Consent:** N/A. **UI:** Impeccable B — comportamento existente, sem superfície nova.
+
+## Fases verificáveis
+
+1. **Provider** — refatoração do persist effect (snapshot ref + efeito de `open`). `pnpm exec tsc --noEmit` + `pnpm lint`.
+2. **E2E** — novo teste no `campaignSollinhaContext.e2e.spec.ts`; rodar só o arquivo.
+ - Helper `gatedMockAiChat(page)` ao lado de `mockAiChat` (mesmo wire format SSE): a **primeira** requisição responde imediato; as seguintes ficam em espera até o teste chamar `release()` (contador `requests >= 1` + promise deferida). Registrado no corpo do teste — LIFO sobre o `beforeEach` (mesmo precedente do teste de link externo do B198).
+ - Fluxo do teste (mobile 500px): abre o drawer → manda msg 1 → settle → poll `storedSession.open === true` (o `open: true` velho que o bug deixaria órfão) → manda msg 2 (resposta gated; chat fica busy — testemunha `status !== 'ready'` via mic desabilitado) → clica `Fechar` → drawer some → **poll `storedSession.open === false` ainda com a resposta em voo** (testemunha determinística do fix; sem o fix este poll estoura timeout) → `release()` → poll `storedSession.messages.length === 4` (o settle cobre a conversa toda, com `open: false` — nada foi descartado) → reload → drawer continua fechado → reabre pelo FAB → conversa restaurada visível (msg 1).
+3. **Gates** — `pnpm gate:fast`; entrega via `pnpm push` (pipeline de execução).
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Persistir mensagens mid-stream (o rabbit hole da intenção): proibido pelo corte B188 — o snapshot ref é o único caminho.
+- Mexer no restore/`openBy` para "consertar" isto: anti-goal explícito — troca um bug raro por outro.
+- `storage` events / sincronização entre abas, rascunho do input, largura (B166), superfície (B167): regras próprias, intactas.
+- Unit tests do provider (fiação de efeitos): B188 já estabeleceu o precedente de cobrir o provider via e2e; a lib `sollinhaChatSession.ts` não muda.
+
+## Riscos e mitigação
+
+| Risco | Mitigação |
+| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Write mid-stream grava mensagem pela metade | O efeito novo grava só `settledMessagesRef` (último settle); o write do settle continua o único a persistir mensagens live. |
+| Ordem dos efeitos cria write velho-sobre-novo | Skip `status === 'ready'` no efeito novo: em repouso só o efeito de settle escreve; mid-stream só o efeito novo escreve (settle effect early-returna antes de tocar o ref). Sem sobreposição. |
+| OPS22: open de origem `settle` restaura drawer mobile | Impossível mid-stream: o settle roda no mount (status `ready`); todo write mid-stream nasce de `requestOpen`/`toggle` (origem `user`). Regra de restore intacta. |
+| Drawer fecha com animação; `toHaveCount(0)` flaky | Precedente do B198 no mesmo arquivo usa `toHaveCount(0)`; assert posterior do storage torna o teste independente da animação. |
+| O teste depende de timing do stream | Não: a resposta da 2ª troca fica gated (fetch pendente) — o close acontece com o chat comprovadamente busy (mic desabilitado), sem corrida. |
+| StrictMode (dev) duplica efeitos | Writes idempotentes; restore já é ref-guarded (B188); e2e roda com StrictMode real e cobre o conjunto. |
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção ainda coberto (close mid-stream persiste `open: false`; conversa persiste no settle; restore/`openBy` intocados)
+- [ ] Invariantes AGENTS/engineering-standards (sem migration/access/Consent; identificadores em inglês; pt-BR só em strings de UI)
+- [ ] Testes de domínio previstos: e2e determinístico de fechamento mid-stream + settle cobrindo a conversa (sem unit — lib intocada)
+
+## Decisões de engenharia (self-score)
+
+| Decisão | Recomendação | Rejeitadas |
+| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
+| Onde persistir o close mid-stream | Efeito novo de `open` no provider (gate `sessionRestored`, skip `ready`), gravando snapshot de mensagens | Escrever tudo sem gate de status (persiste stream pela metade); fechar no handler do X (lógica no componente, fora do ciclo React do estado) |
+| Qual conteúdo de mensagens no write mid-stream | `settledMessagesRef` — último snapshot settle-completo | `messages` live (stream em voo); omitir mensagens (quebraria o shape/sessão e um reload perderia tudo) |
+| Quando o efeito novo escreve | Qualquer mudança de `open` fora de `ready` (abrir e fechar) | Só fechamento (assimétrico; abrir mid-stream deixaria storage velho) |
+| Teste | E2E com resposta gated (fetch pendente) + poll do storage como testemunha | Delay fixo no stream (flaky); simular estado via storage manual (não cobre o provider) |
+
+**Self-score decision-quality: 5/5** — aceite inviolado (close mid-stream persiste imediato; conversa intacta no settle; restore intocado); rabbit hole nomeado e cortado pelo snapshot ref; reusa os donos existentes (mesmo efeito de persist, mesma lib, mesmo wire format de mock); a divergência da hipótese da intenção é defensiva e documentada (skip `ready` evita write duplicado sem mudar comportamento).
diff --git a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx
index e93da2dd..75a235be 100644
--- a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx
+++ b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx
@@ -78,12 +78,22 @@ export const CampaignAISidebarProvider = ({
const restoredSessionRef = useRef(false)
const sessionReadRef = useRef(false)
const restoredOpenByRef = useRef('settle')
+ // B199: the snapshot of the last settle-complete messages — the ONLY
+ // content an `open` write made mid-stream may persist (writing live
+ // messages would freeze a half-received turn in the session).
+ const settledMessagesRef = useRef([])
+ // B199: the `open` value of the last write by either persist effect — lets
+ // the mid-stream effect skip status transitions that leave `open` untouched.
+ const lastWrittenOpenRef = useRef(open)
useEffect(() => {
if (sessionReadRef.current || !measured) return
sessionReadRef.current = true
const session = readSollinhaChatSession()
if (session) {
setMessages(session.messages)
+ // B199: the mid-stream `open` write must never clobber a restored
+ // conversation with an empty snapshot — seed it from the restore itself.
+ settledMessagesRef.current = session.messages
const openBy = session.openBy ?? 'settle'
restoredOpenByRef.current = openBy
if (session.open && (openBy === 'user' || !isMobile)) setOpen(true)
@@ -107,19 +117,35 @@ export const CampaignAISidebarProvider = ({
// B188: persist the conversation only once the chat has settled — writing
// during streaming would freeze a half-received message in the session
- // (a reload mid-stream loses only that in-flight turn). `open` changes made
- // mid-stream are picked up by the settle that follows. Empty state is still
+ // (a reload mid-stream loses only that in-flight turn). Empty state is still
// 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).
+ const openByForWrite = (): SollinhaChatSessionOpenOrigin =>
+ userToggledOpenRef.current || restoredOpenByRef.current === 'user' ? 'user' : 'settle'
useEffect(() => {
if (!sessionRestored || status !== 'ready') return
- const openBy =
- userToggledOpenRef.current || restoredOpenByRef.current === 'user' ? 'user' : 'settle'
- writeSollinhaChatSession(messages, open, openBy)
+ settledMessagesRef.current = messages
+ lastWrittenOpenRef.current = open
+ writeSollinhaChatSession(messages, open, openByForWrite())
}, [sessionRestored, status, messages, open])
+ // B199: persist `open` without waiting for the settle — closing the drawer
+ // mid-stream must reach the storage immediately, or a reload would restore
+ // the stale `open: true` and resurrect the drawer. Messages stay at the last
+ // settle-complete snapshot until the next settle (in the abort path the
+ // snapshot IS the terminal content — the documented B188 cut). Skipping
+ // `ready` avoids a duplicate write of the settle effect above, and the
+ // `lastWrittenOpenRef` guard skips plain status transitions that leave
+ // `open` untouched. Deliberately no `messages` dep: never persist a live
+ // stream, only the open flag over the snapshot.
+ useEffect(() => {
+ if (!sessionRestored || status === 'ready' || lastWrittenOpenRef.current === open) return
+ lastWrittenOpenRef.current = open
+ writeSollinhaChatSession(settledMessagesRef.current, open, openByForWrite())
+ }, [sessionRestored, status, open])
+
// 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
diff --git a/tests/e2e/campaignSollinhaContext.e2e.spec.ts b/tests/e2e/campaignSollinhaContext.e2e.spec.ts
index 8a4b7777..a324617d 100644
--- a/tests/e2e/campaignSollinhaContext.e2e.spec.ts
+++ b/tests/e2e/campaignSollinhaContext.e2e.spec.ts
@@ -7,30 +7,72 @@ const SESSION_KEY = 'teqo:campaign:sollinha-chat-session'
const DEFAULT_REPLY = 'Resposta mockada com [Municípios](/campanha/municipios).'
const EXTERNAL_REPLY = 'Resposta mockada com [portal da saúde](https://www.saude.ba.gov.br/).'
+/**
+ * The SSE body the mocks reply with. Chunks follow the SDK v7 UI-message-stream
+ * wire format (start / text-start / text-delta / text-end / finish) — a bare
+ * legacy `{"type":"text"}` chunk fails the client schema and the chat would
+ * never settle.
+ */
+const streamBody = (reply: string) =>
+ [
+ 'data: {"type":"start"}\n\n',
+ 'data: {"type":"text-start","id":"t1"}\n\n',
+ `data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(reply)}}\n\n`,
+ 'data: {"type":"text-end","id":"t1"}\n\n',
+ 'data: {"type":"finish","finishReason":"stop"}\n\n',
+ ].join('')
+
/**
* Mock the AI endpoint with a minimal SSE stream so sending a message neither
- * needs a DeepSeek API key nor hits the real rate limiter. The chunks follow
- * the SDK v7 UI-message-stream wire format (start / text-start / text-delta /
- * text-end / finish) — a bare legacy `{"type":"text"}` chunk fails the client
- * schema and the chat would never settle. The reply carries a markdown link so
- * the "navigate without reload" path has a real anchor.
+ * needs a DeepSeek API key nor hits the real rate limiter. The reply carries a
+ * markdown link so the "navigate without reload" path has a real anchor.
*/
const mockAiChat = (page: Page, reply: string = DEFAULT_REPLY) =>
page.route('**/campanha/api/ai-chat', async (route) => {
await route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
- body: [
- 'data: {"type":"start"}\n\n',
- 'data: {"type":"text-start","id":"t1"}\n\n',
- `data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(reply)}}\n\n`,
- 'data: {"type":"text-end","id":"t1"}\n\n',
- 'data: {"type":"finish","finishReason":"stop"}\n\n',
- ].join(''),
+ body: streamBody(reply),
})
})
+/**
+ * Mock whose FIRST request replies immediately and every later request hangs
+ * until the test calls `release()` — a deterministic window in which the chat
+ * is still busy (`status !== 'ready'`) with a reply blocked, with no stream
+ * timing to race against.
+ */
+const gatedMockAiChat = (page: Page) => {
+ let resolveGate: (() => void) | undefined
+ const gate = new Promise((resolve) => {
+ resolveGate = resolve
+ })
+ let requests = 0
+ void page.route('**/campanha/api/ai-chat', async (route) => {
+ const index = requests
+ requests += 1
+ if (index >= 1) await gate
+ try {
+ await route.fulfill({
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ body: streamBody(DEFAULT_REPLY),
+ })
+ } catch {
+ // An early failure may end the test before `release()` — fulfilling on a
+ // closed page rejects, and the test's own error is the signal to read.
+ }
+ })
+ return {
+ release: () => {
+ resolveGate?.()
+ },
+ requests: () => requests,
+ }
+}
+
const MESSAGE = 'Mensagem que sobrevive ao reload'
+const SECOND_MESSAGE = 'Segunda pergunta'
const openChatAndSend = async (page: Page) => {
await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 })
@@ -258,3 +300,70 @@ test.describe('B198 — link de resposta fecha o drawer mobile ao navegar no mes
await expect(drawer).toBeVisible()
})
})
+
+test.describe('B199 — fechar o drawer durante streaming persiste fechado no reload', () => {
+ test.beforeEach(async ({ page }) => {
+ test.slow()
+ await mockAiChat(page)
+ })
+
+ test('fechar mid-stream grava open:false e o reload não reabre o drawer', async ({
+ page,
+ campaign,
+ }) => {
+ // Last-registered route wins (LIFO) — overrides the beforeEach mock. The
+ // first exchange replies immediately; the second stays blocked until
+ // `release()`, so the close provably lands while the chat is busy.
+ const gated = gatedMockAiChat(page)
+
+ const user = await campaign.fixtures.createCampaignUser('coordinator', {
+ name: campaign.fixtures.value('Fechar Mid-Stream'),
+ })
+ await page.setViewportSize({ width: 500, height: 800 })
+ await campaign.login(page, user.email!, user.password)
+ await page.goto('/campanha')
+
+ const drawer = await openMobileDrawer(page)
+
+ await openChatAndSend(page)
+ await waitForChatSettled(page)
+ // The stale `open: true` the bug would leave orphaned in the storage.
+ await expect.poll(async () => (await storedSession(page))?.open).toBe(true)
+
+ // Second exchange — the gated reply keeps the chat busy (status !== 'ready').
+ const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' })
+ await input.fill(SECOND_MESSAGE)
+ await input.press('Enter')
+ await expect(page.getByText(SECOND_MESSAGE)).toBeVisible({ timeout: 20_000 })
+ await expect(
+ page.getByRole('button', { name: 'Falar pergunta (voz)' }).filter({ visible: true }),
+ ).toBeDisabled({ timeout: 20_000 })
+
+ // Close while the reply is still blocked: the persist must not wait for
+ // the settle, or a reload would restore the stale `open: true`.
+ await drawer.getByRole('button', { name: 'Fechar' }).click()
+ await expect(drawer).toHaveCount(0)
+ await expect.poll(async () => (await storedSession(page))?.open).toBe(false)
+
+ // Let the reply land: the settle still persists the whole conversation.
+ gated.release()
+ await expect.poll(async () => (await storedSession(page))?.messages?.length).toBe(4)
+ // Both exchanges went through the gated route — the LIFO override worked.
+ expect(gated.requests()).toBe(2)
+
+ // Reload: no ghost drawer, conversation restored.
+ await page.reload()
+ await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0)
+ // The FAB is SSR'd; a click that lands before hydration is a silent no-op
+ // (B13/B17 class) — retry until the drawer actually opens.
+ const fab = page.getByRole('button', { name: 'Sollinha — Assistente virtual' })
+ await expect(async () => {
+ await fab.click({ timeout: 1_000 })
+ await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toBeVisible(
+ { timeout: 1_000 },
+ )
+ }).toPass({ timeout: 15_000 })
+ await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 })
+ await expect(page.getByText(SECOND_MESSAGE)).toBeVisible()
+ })
+})