fix(B199): fechar o drawer mid-stream persiste open:false imediato — reload não ressuscita (Closes #672)

This commit is contained in:
Francisco Solla 2026-08-11 19:40:19 -03:00
parent 11605a2ef3
commit 54205fb093
4 changed files with 328 additions and 17 deletions

View file

@ -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`.

View file

@ -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<br/>status ready: escreve msgs + open<br/>+ atualiza settledMessagesRef]
O[open effect<br/>open mudou + status != ready:<br/>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<UIMessage[]>([])` — 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).

View file

@ -78,12 +78,22 @@ export const CampaignAISidebarProvider = ({
const restoredSessionRef = useRef(false)
const sessionReadRef = useRef(false)
const restoredOpenByRef = useRef<SollinhaChatSessionOpenOrigin>('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<UIMessage[]>([])
// 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

View file

@ -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<void>((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()
})
})