From 5bc154dd20f72d8c56fb115b883eca1024d03fd8 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 10:12:48 -0300 Subject: [PATCH] =?UTF-8?q?fix(OPS22):=20settle=20do=20chat=20desktop=20n?= =?UTF-8?q?=C3=A3o=20vaza=20drawer=20para=20a=20visita=20mobile=20da=20mes?= =?UTF-8?q?ma=20aba=20=E2=80=94=20CI=20main=20verde=20(deploy=20desbloquea?= =?UTF-8?q?do)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG-AGENTS.md | 7 ++ ...i-e2e-agenda-mobile-regressao-b188-impl.md | 113 ++++++++++++++++++ .../shell/ai/CampaignAISidebarContext.tsx | 55 +++++++-- src/lib/sollinhaChatSession.ts | 27 ++++- tests/e2e/campaignAiChatResize.e2e.spec.ts | 6 +- tests/e2e/campaignSollinhaContext.e2e.spec.ts | 25 ++++ tests/unit/sollinhaChatSession.unit.spec.ts | 26 ++++ 7 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 docs/plans/ci-e2e-agenda-mobile-regressao-b188-impl.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 97705ad6..68d68d47 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-10):** **OPS22** (Issue #562) — CI de `main` vermelho determinístico desde o merge do B188 (05:54): os 3 e2e da agenda mobile (`campaignAgendaMobile` :96/:242/:295) falhavam com todas as retries → deploy bloqueado. **Root cause (reproduzida no prod build local + error-contexts com o drawer aberto):** o login e2e roda em **viewport desktop** → o settle de B167 abre o chat (`open=true`) → o **efeito persist do B188** (status `ready` já nasce) grava `{open:true}` no `sessionStorage` → o spec muda para 390px na **mesma aba** → o **restore do B188** reabre o **drawer mobile sozinho** → o modal Radix **aria-hide + inert** a página e cobre o viewport (combobox fora da a11y tree no :242, swipe cai no drawer no :96, clique do seletor bloqueado no :295; teclado/:173 passam por não usarem hit-testing). **Defeito real de produto** (aba desktop → mobile na mesma aba faz o chat "saltar"), não só timing de teste. **Fix na origem:** `SollinhaChatSession` ganhou **`openBy: 'user' | 'settle'`** (opcional — sessão legada sem o campo = `'settle'`, fail-closed; sem bump de versão) e o provider agora distingue **intenção do usuário** (`setOpen`/`toggle` do context value marcam `userToggledOpenRef`; FAB/header/drawer já passam por eles) de **verdade de layout** (settle B167 usa o setter cru): o **restore espera o `measured`** (viewport real) e honra `open:true` em mobile **só quando `openBy==='user'`**; o persist grava `openBy` conforme a intenção. Todos os contratos B188/resize mantidos (reload restaura conversa; drawer aberto por usuário volta aberto; visita mobile nova fechada; crossing ao vivo B167 intocado). Testes: unit (round-trip/invalidez/legado de `openBy`) + **e2e pin OPS22** no spec B188 (login desktop → poll do persist `open:true` → viewport 390 → `/campanha/agenda` → sem dialog + botão do Sollinha visível); spec da agenda intacto (4/4 no prod build). Sem migration/access/Consent/schema. Intenção [`ci-e2e-agenda-mobile-regressao-b188.md`](plans/ci-e2e-agenda-mobile-regressao-b188.md); impl [`ci-e2e-agenda-mobile-regressao-b188-impl.md`](plans/ci-e2e-agenda-mobile-regressao-b188-impl.md). + **Recently resolved (2026-08-10):** **OPS32** (Issue #594) — `/work-issue` no paradigma de **Issue já claimada** + `model-selection` volta ao cardápio só-Cursor. A skill `work-issue` foi reescrita como **fluxo próprio do humano**: nenhum passo de claim/ambiente/modelo — a sessão nasce no contrato (claim fora da skill: hoje `pnpm agent:claim` manual, OPS33 no `worktree next`); o número da Issue vem do prompt/`$ARGUMENTS` (`--issue `, contrato OPS33) e, ausente, **uma** pergunta ao humano com validação (`gh issue view`: OPEN + `in-progress`; **checagem falhou → para e pede o claim fora da skill** — nunca claima); o plano de intenção é recuperado do body da Issue (`Plano: [docs/plans/…]`, mesmo contrato de `extractPlanPath` do pool; sem link → body é spec). Fluxo: Plan mode → `*-impl.md` → pausa humana → execução → `/simplify` → `capture-review-debts` com gate → PR Ready `--base main` + auto-merge. **Desacoplada de `agent-work-issue`** (pool): saíram "base de execução", a linha da tabela e o wording de claim; a mecânica de execução→fechamento (Passos 4–6) foi extraída para **`.agents/skills/work-issue/execution-pipeline.md`** (material compartilhado com tabela de deltas por ator — branch `-` vs `agent/-`, UI, modo do `capture-review-debts`, Cloud) e as duas skills passaram a referenciá-la, cada uma com seus deltas; os materiais de engenharia (engineering-brief/implementation-template/decision-quality) seguem como referência. **`model-selection` perdeu** a tabela Local/Zed, o frontmatter `model-local:` e o mapeamento canônico `model:`→DeepSeek (regras 3/8 e o "Como aplicar" dual): cardápio único Cursor (Composer 2.5 default; Grok com effort; Kimi K3 Low só exec bipartida), Issues declaram `model:` único, e os gates dizem que work-issue não verifica modelo. Correlatos: brief do `agent:claim` ("metadata consultiva" + footer sem o fluxo stage extinto), pool prompt (linha "verificação assimétrica" → spawn já fixa), `.opencode/commands/work-issue.md` (description sem claim), `docs/AGENT-OPS.md` (linha Skills + bullet "Agente faz sozinho" com claim fora da sessão), `plan-issue` (linha da tabela de relação alinhada), `rebase-on-main` (Passo 6 cita `--base main`). Verificado: o pool não quebra — `agent-pool-models.mjs` consome só `model:` (unit pinam slugs Cursor). AGENTS.md intocado (texto do claim do worktree é OPS33). Sem migration/access/Consent/UI. Intenção [`work-issue-paradigma-issue-claimada.md`](plans/work-issue-paradigma-issue-claimada.md); impl [`work-issue-paradigma-issue-claimada-impl.md`](plans/work-issue-paradigma-issue-claimada-impl.md). **Recently resolved (2026-08-10):** **B193** (Issue #542) — card de município mobile denso + edit-where-you-see + última atualização expandível. O card de `/campanha/municipios` no mobile deixou o grid de labels e virou composição do wireframe: cabeçalho nome+território com **posição de voto 2022 à direita** (percentual em cima; colocação · votos embaixo), **barra de cenários** (pessimista·central·otimista + marcador do ativo — `VoteEstimateScenarioStrip` como trigger da sheet de votos existente), **chips** classe/tendência/nível (tendência e nível com label à esquerda, dentro do trigger — tocar no label abre a sheet; classe com razão via tooltip que abre no toque), **3 grupos de avatares com label** (Assessores/Lideranças/Dobradinhas — um avatar por entidade, wrap, sem cap; leitura de assessores para não-coordenador), rodapé **"Última atualização há X dias" + chevron que expande o limite inferior** revelando o último card de atualização (badges + corpo + autor · data, sem moldura) + CTA "Registrar atualização" (sheet existente); **sem atualização → o rodapé vira CTA direto** (sem chevron/expansão); prioritário = **borda lateral direita 6px** `border-primary` no lugar do flag; Salvador read-only; "Cobertura" (conta da cadeira) sai do card (fica na tabela/detalhe — gate humano); rodapé mede a idade da **atualização** (não o frescor update∨pledge — coluna desktop intacta). **C109 corrigida no caminho (a "sheet quebra ao abrir" era real):** os callbacks dos controles (autosave `onOpenChange`, `handleOpenChange` do nível) são recriados por render — qualquer re-render com a sheet aberta (digitar no sheet!) fazia o host descartar a sessão e o corpo sumia; sessões do host agora são **keyed por id de sessão** (imune a identidade) e o **footer customizado** (submit + `DialogClose`) passou a ser **registrado e renderizado pelo host dentro da árvore do Drawer** (portal perdia o Dialog context — o e2e antigo só abria a sheet de tendência, sem footer custom, por isso a C109 não reproduzia). Controles ganharam prop `trigger`/`triggerClassName` (precedente `RelationChipCell.trigger`); `MunicipalityRelationAvatarStack` ganhou modo wrap. Dado: `MunicipalityListViewModel.lastUpdate` (batch por página via `loadMunicipalityLastUpdates`, scoped por portfólio). E2E novos (390×844): barra abre sheet e reflete valores salvos, chip Nível abre a sheet, borda de prioridade (6px + cor), expansão/CTA vazio. Sem migration/access/Consent. Intenção [`municipios-card-mobile-edit-where-you-see.md`](plans/municipios-card-mobile-edit-where-you-see.md); impl [`municipios-card-mobile-edit-where-you-see-impl.md`](plans/municipios-card-mobile-edit-where-you-see-impl.md). @@ -14,6 +16,11 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-10):** **OPS24** (Issue #569) — `--go` virou o **padrão** do worktree CLI e `kill` volta ao main: `worktree next`/`plan` imprimem `cd ` na última linha **por padrão** (antes exigiam `--go`); `--stay` suprime; `--go` explícito continua aceito como no-op (não quebra hábito/automação). `kill` troca a mensagem "Volte ao main: cd …" (que nunca casava com o sed `s/^cd //p` da função shell) pela **linha máquina `cd
` como última linha** — depois do drop dos DBs, que rodava antes — e **não aceita `--stay`** (ficar em cwd destruído é exatamente o bug corrigido; `--go` no kill é no-op). Vale nas duas superfícies que aplicam o cd: função `worktree()` de `.agents/shell/worktree.sh` (agora sempre captura/applica, `--stay` retorna cedo) e comando `/worktree` do opencode (agente aplica o `cd` da última linha sempre, exceto `--stay`). Docs: header/uso do script, AGENTS.md ("Per-worktree environments"), skills `worktree-next-issue`/`local-database`. Sem migration/dados/access; derivação pura (`worktree-env.mjs`/`worktree.mjs` libs) intocada. Intenção [`worktree-go-default-e-kill-volta-ao-main.md`](plans/worktree-go-default-e-kill-volta-ao-main.md); impl [`worktree-go-default-e-kill-volta-ao-main-impl.md`](plans/worktree-go-default-e-kill-volta-ao-main-impl.md). + + +**Recently resolved (2026-08-10):** **OPS26** (Issue #571) — `worktree next`/`plan` **no terminal** terminam com o TUI do opencode aberto no worktree: a função `worktree()` de `.agents/shell/worktree.sh` marca `TEQO_WORKTREE_TERMINAL=1` e o script imprime a diretiva `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt /work-issue]` **antes** do `cd` (que segue sendo a última linha — contrato OPS24 intacto); a função aplica o cd e executa a linha (sem `exec`: sair do TUI volta ao shell no worktree; sem auto-`kill`). `next` envia `/work-issue` (o comando OPS25 roda o ciclo completo); `plan` abre **sem prompt** — a CLI do opencode não tem pre-fill sem submit (`--prompt` sempre auto-envia; fallback acordado: `/plan-` + autocomplete; gap registrado para o repo do opencode). Presets são constantes em `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, `opencodeLaunchDirective` — pura, unit-testada; trocar de modelo é editar a constante). **Sem o marcador (comando `/worktree` do opencode, automação) a diretiva não é impressa** — nunca abre TUI aninhado; `--stay` suprime cd e launch; `kill` intocado. Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue`. Sem migration/dados/access/UI. Intenção [`worktree-go-abre-opencode-com-presets.md`](plans/worktree-go-abre-opencode-com-presets.md); impl [`worktree-go-abre-opencode-com-presets-impl.md`](plans/worktree-go-abre-opencode-com-presets-impl.md). + + **Recently resolved (2026-08-10):** **OPS25** (Issue #570) — atalhos diretos no TUI do opencode: `.opencode/commands/work-issue.md` e `plan-issue.md` (mecanismo nativo de commands, mesmo padrão de `issue.md`/`worktree.md`). O frontmatter leva `description` pt-BR (autocomplete do TUI) + `model: deepseek/deepseek-v4-flash` (pin do modelo de preferência, alinhado ao launch da OPS26 com `--model`); o corpo instrui carregar a skill de **mesmo nome** pela ferramenta de skills e segui-la ponta a ponta, apontando a fonte canônica `.agents/skills//SKILL.md` — **sem transcrever** (anti-goal da intenção) — com `$ARGUMENTS` repassado no fim. **Guard de acoplamento** `tests/unit/opencodeCommands.unit.spec.ts`: comando↔skill presos por nome exato (renome de skill → build falha em vez de comando quebrado silencioso). Sem migration/access/Consent/UI. Intenção [`opencode-comandos-work-issue-e-plan-issue.md`](plans/opencode-comandos-work-issue-e-plan-issue.md); impl [`opencode-comandos-work-issue-e-plan-issue-impl.md`](plans/opencode-comandos-work-issue-e-plan-issue-impl.md). **Recently resolved (2026-08-10):** **B192** (Issue #533) — o Sollinha encerra respostas factuais com **follow-ups sugeridos como chips** (2–3, no mesmo slot dos chips de abertura do B191, acima do input; tocar envia como mensagem do usuário; substituídos a cada resposta; somem durante o streaming; mobile limita a 2). **Uma única chamada ao modelo:** o prompt (`systemPrompt.ts`) instrui encerrar com o bloco estável `**Sugestões de continuação:**` + lista markdown (só perguntas respondíveis pelas próprias tools, leader sem sugestão eleitoral — B180; bloco = instrução de interface, nunca citar em prosa) e o cliente extrai. **Contrato em fonte única:** `src/lib/sollinhaFollowUpSuggestions.ts` — `SOLLINHA_FOLLOW_UP_MARKER` (interpolado no prompt) + `splitSollinhaFollowUpBlock` (última ocorrência vence; body sempre mascarado do marcador em diante; itens `-/*/1./1)` aceitos, bold e duplicados limpos, teto 3; **fail-closed: <2 itens ou linha não-lista → sem chips**, resposta normal). UI: strip no render de todo text part assistant **antes** do markdown/links (bloco nunca pisca como prosa; mensagens antigas restauradas via B188 continuam mascaradas e os chips renascem por derivação); slot B191 estendido (catálogo vazio → follow-ups); tipo compartilhado renomeado `SollinhaOpeningQuestion → SollinhaChatChip`. **Strip defensivo no servidor:** `route.ts` remove o bloco das mensagens assistant ao reconverter para o modelo (próximo turno sem eco nem custo de contexto). Unit (16 casos do split) + e2e novo `campaignAiChatFollowUps.e2e.spec.ts` (wire format v7 como o mock do B187; bolha sem o bloco, chips, substituição, fail-closed, cap mobile). Sem migration/access/Consent/schema/endpoint novo. Intenção [`sollinha-follow-ups-respostas.md`](plans/sollinha-follow-ups-respostas.md); impl [`sollinha-follow-ups-respostas-impl.md`](plans/sollinha-follow-ups-respostas-impl.md). diff --git a/docs/plans/ci-e2e-agenda-mobile-regressao-b188-impl.md b/docs/plans/ci-e2e-agenda-mobile-regressao-b188-impl.md new file mode 100644 index 00000000..1f85f16d --- /dev/null +++ b/docs/plans/ci-e2e-agenda-mobile-regressao-b188-impl.md @@ -0,0 +1,113 @@ +# Impl: CI main — e2e da agenda mobile vermelho desde o merge do B188 (deploy bloqueado) + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #562 +Intenção: docs/plans/ci-e2e-agenda-mobile-regressao-b188.md +Appetite: ~1 dia (diagnóstico já fechado; fix pequeno) + +## Leitura da intenção + +- **Outcome:** CI de `main` verde estável (e2e agenda mobile 4/4 em runs consecutivos) → deploy automático desbloqueado; se o mecanismo raiz for defeito real de produto, o comportamento do app é corrigido (não o teste). +- **O que NÃO negociar:** não enfraquecer as asserções do spec (C101 é o contrato de UX mobile da agenda); sem mudança de schema/Consent/access; não refatorar o spec nem a infra de e2e; não tocar leader lockdown. +- **O que reavaliar:** a hipótese da intenção apontava o chat como suspeito natural (B167 precedent) — confirmada, mas o mecanismo exato é diferente do "roubo de largura do primeiro frame": é o **vazamento do estado `open` persistido pelo B188 entre viewports da mesma aba**. + +## Root cause (verificado localmente, prod build) + +**Cadeia completa, confirmada por reprodução local (`E2E_PROD=1`, build `.next/e2e`) + error-contexts:** + +1. O fixture `campaign.login` loga em **viewport desktop (1280×720)** e aterrissa em `/campanha`. O efeito `settle` de B167 (`CampaignAISidebarContext`) reconhece `measured && !isMobile` e faz `setOpen(true)` — o chat desktop "abre" sozinho no load (comportamento pré-existente). +2. **B188** adicionou o efeito **persist** (`sessionRestored && status === 'ready'`) — e `status` do `useChat` já nasce `'ready'` (nada foi enviado). Resultado: a página de login/dashboard grava `{version:1, messages:[], open:true}` no `sessionStorage` da aba. +3. O spec então faz `setViewportSize(390×844)` e `goto('/campanha/agenda')` — **mesma aba, mesmo `sessionStorage`**. No mount da agenda, o efeito **restore** do B188 lê a sessão e faz `setOpen(true)` → **o drawer mobile do Sollinha abre sozinho**. +4. O drawer (Radix Dialog modal) **aria-hide + inert** todo o resto da página (`ariaHideOutside`) e cobre o viewport. Consequências por teste: + - `:96` — o swipe CDP cai no drawer (hit-testing) → título nunca avança para o dia seguinte. + - `:242` — o combobox sai da a11y tree (aria-hidden) → `getByRole('combobox', { name: 'Filtrar agenda' })` → "element(s) not found" (o label via CSS selector ainda responde height ≤ 1 — por isso linha 265 passa). + - `:295` — clique no seletor de visualização bloqueado pelo drawer/inert → timeout de 90s; **teclado funciona** porque keydown vai ao elemento focado, não por hit-testing. + - `:173` — passa: nada de hit-testing/a11y (scroll programático + geometria). +5. **Por que determinístico desde 07464b88:** antes do B188 nada persistia `open` — o mount da agenda nascia com `open=false`. Depois, todo login desktop → visita mobile da mesma aba reabre o drawer. Único merge na janela verde(05:47)→vermelho(05:54) é o B188 (confirmado no histórico de runs do GH Actions). + +**É defeito real de produto, não só teste:** um usuário com uma aba desktop (chat aberto pelo settle) que muda para largura mobile na MESMA aba → recarregar (ou o próprio reload ao cruzar) reabre o drawer sozinho — o "chat saltando" que o próprio B188 pinou como anti-comportamento no teste "visita mobile nova permanece fechada" (o caso "mobile desde o início" foi coberto; o caso "desktop→mobile na mesma aba" não). O drawer aberto também aria-esconde o conteúdo — quebra de acessibilidade. + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph session [sollinhaChatSession.ts] + S[shape: version 1 + openBy: 'user'|'settle'?] + end + subgraph provider [CampaignAISidebarContext] + U[userToggledOpenRef — setOpen/toggle públicos marcam intenção] + E1[restore: espera measured; abre só se openBy=user OU !isMobile] + E2[persist: grava openBy conforme intenção do usuário] + end + U --> E2 --> S + S --> E1 +``` + +**Core:** distinguir `open:true` **de origem do usuário** (FAB/header/drawer → `setOpen`/`toggle`) de `open:true` **de origem do settle** (reconciliação do painel desktop, B167). O settle é verdade de layout, não intenção — persistir como `open:true` é o que vaza para o drawer mobile. O restore passa a honrar `open:true` em mobile **somente** quando a origem foi o usuário. + +### Mudanças + +1. **`src/lib/sollinhaChatSession.ts`** — `SollinhaChatSession` ganha `openBy?: 'user' | 'settle'` (opcional: sessões legadas sem o campo são tratadas como `'settle'` — fail-closed, o leak fecha também para sessões já gravadas pela versão atual). Validador aceita `undefined | 'user' | 'settle'` (rejeita qualquer outro). `writeSollinhaChatSession(messages, open, openBy = 'settle')`. **Sem bump de versão**: shape cresce compatível; sessão legada `{open:true}` continua válida e é interpretada como settle. +2. **`src/components/campaign/shell/ai/CampaignAISidebarContext.tsx`**: + - `userToggledOpenRef` — o `setOpen` exposto no context value e o `toggle` passam a marcar a ref antes de setar (todas as superfícies de usuário — FAB, header, drawer — já passam por esses dois). Efeitos internos (restore, settle) usam o setter cru. + - **restore**: passa a rodar **após `measured`** (a decisão precisa do viewport real; hoje roda no mount com `isMobile` não medido) e honra `session.open` só quando `session.openBy === 'user' || !isMobile`. Guard `sessionReadRef` mantém 1× sob re-renders/StrictMode. + - **persist**: grava `openBy: userToggledOpenRef.current ? 'user' : 'settle'`. + - `settle` (B167) e demais efeitos: intactos. +3. **Testes:** + - Unit (`tests/unit/sollinhaChatSession.unit.spec.ts`): round-trip de `openBy`; sessão com `openBy` inválido → `null` (fail-closed); sessão legada sem `openBy` continua lendo. + - E2E pin de regressão (`tests/e2e/campaignSollinhaContext.e2e.spec.ts`): novo teste "settle do desktop não vaza drawer para a visita mobile da mesma aba (OPS22)" — login desktop (poll `storedSession().open === true` garante o persist do settle) → viewport 390 → `goto('/campanha/agenda')` → `dialog` com count 0 + combobox "Filtrar agenda" visível + botão do Sollinha ainda visível (chat acessível). Não toca o spec da agenda. + - Atualizar o comentário do teste "visita mobile nova..." em `campaignAiChatResize.e2e.spec.ts` (linhas ~147-150) que documenta o vazamento como "legítimo". +4. **Docs:** registrar entrega em `docs/CHANGELOG-AGENTS.md`; plano `*-impl.md` commitado. + +**Por que os testes do B188 continuam verdes:** (1) desktop + conversa → reload desktop: settle-originated é honrado em desktop (equivale ao que o settle faria) ✓; (2) link navega sem reload ✓; (3) drawer mobile aberto pelo botão → openBy 'user' → restore abre ✓; (4) visita mobile nova (sem sessão) ✓. Os 4 da resize spec: crossings ao vivo (B167) não passam por restore ✓; "fechado cruza borda" usa `open:false` persistido ✓. + +## Opções consideradas / rejeitadas + +- **A) Track de intenção + `openBy` (escolhida)** — fecha o leak na origem (o persist), mantém o contrato B188 (aberto por usuário persiste), não descarta sessões, sem bump de versão. +- **B) Reverter o B188** — desbloqueia, mas devolve a funcionalidade entregue (persistência da conversa); rabbit hole que a intenção cortou. +- **C) Não persistir `open` quando `openBy=settle`** (gravar `open:false` no lugar) — quebra o teste B188 #1: reload desktop com conversa restaurada dependeria do settle reabrir, mas `restoredSessionRef` bloqueia o settle quando há sessão → painel fechado com conversa invisível. Rejeitada. +- **D) Bump de versão da sessão (v2 exigindo `openBy`)** — descarta sessões ativas de usuários na virada; sem necessidade (o campo opcional já fecha o leak nas legadas). +- **E) Enfraquecer/remover as 3 asserções do spec da agenda** — viola o anti-goal da intenção; o app regrediu de verdade. + +## Fases verificáveis + +1. **Lib pura** — `openBy` opcional + validador + default; unit tests novos; `pnpm test:unit` (só o arquivo). +2. **Provider** — ref de intenção + restore pós-measurement com gate de origem; persist com `openBy`. +3. **E2E (prod build)** — spec da agenda mobile 4/4; `campaignSollinhaContext` 4/4 (com o novo pin = 5); `campaignAiChatResize` 4/4. +4. **Gates + entrega** — `pnpm gate:fast` (lint/typecheck/unit) → `pnpm push` → PR `--base main` com `Closes #562` → auto-merge → `gh pr checks --watch --required` (deploy desbloqueado quando `checks` + `migration-lock` verdes). + +## Rabbit holes / não escopo + +- Comportamento **ao vivo** do crossing desktop→mobile (drawer abre por B167 quando `open=true`): **fora de escopo** — é design de B167 ("drawer open on resize"), pinado pela resize spec; a regressão B188 é o caminho persist/restore (reload). +- Outros flakes de carga (#553): destino #553. +- Artefato de `test-results` no CI (questão aberta da intenção): barato (upload action no passo de falha) — incluir só se não atrasar; senão registrar como débito (expensive_lock) na capture-review-debts. +- Restaurar `status` do chat / rascunho do input: já cortados pelo B188. + +## Riscos e mitigação + +| Risco | Mitigação | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Restore atrasado 1 frame (gate `measured`) muda o flash do hello-state no reload | Adiciona só o tempo do matchMedia (~1 frame); o B188 já aceitava flash pós-montagem | +| Sessão legada `{open:true}` tratada como settle → usuário desktop→mobile perde o "aberto" que tinha | É exatamente o defeito sendo corrigido (vazamento); no desktop o open continua honrado | +| `setOpen` cru usado fora dos efeitos internos sem marcar intenção | Auditoria: FAB/header/drawer usam `ctx.setOpen`/`ctx.toggle` (os wrappers); grep de `setOpen(` no shell confirma no gate | +| StrictMode duplo mount re-lê a sessão | `sessionReadRef` gate (idempotente) | +| Build .next/e2e divergente | Mesmo comando do CI (`NEXT_DIST_DIR=.next/e2e pnpm build` + `E2E_PROD=1`) | + +## Aceite de engenharia + +- [x] Aceite da intenção: CI verde estável (spec 4/4), deploy desbloqueado, app corrigido (drawer não salta), spec da agenda intacto +- [x] Invariantes AGENTS: sem migration/Consent/access; sem tocar acesso; identificadores em inglês; pt-BR só em strings visíveis +- [x] Testes: unit da lib (openBy), e2e pin da regressão, e2e existentes (agenda + B188 + resize) verdes + +## Decisões de engenharia (self-score) + +| Decisão | Recomendação | Rejeitadas | +| ------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | +| Onde fechar o leak | Persist/restore com origem da intenção (`openBy`) | Revert B188; não persistir open de settle (quebra B188 #1); bump v2 (descarta sessões) | +| Campo `openBy` | Opcional no shape (legado = settle), sem bump de versão | Obrigatório + version bump | +| Restore timing | Depois de `measured` (precisa do viewport real) | No mount com isMobile não medido | +| Surfaces de usuário | Wrappers `setOpen`/`toggle` no provider (todos já passam por eles) | Marcar intenção em cada componente | +| Teste de regressão | Pin novo no spec B188 (não mexe no spec da agenda) | Enfraquecer asserções da agenda | + +**Self-score decision-quality: 5/5** — mecanismo raiz documentado e reproduzido (CI + local prod build, error-contexts com o drawer aberto), fix mínino na origem, todas as decisões com rejeitadas e testes por camada; nenhum teste enfraquecido. diff --git a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx index d4bd2092..c77a57bd 100644 --- a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx +++ b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx @@ -17,7 +17,11 @@ import type { PanelImperativeHandle } from 'react-resizable-panels' import { useIsMobileMeasured } from '@/hooks/use-mobile' import type { CampaignRole } from '@/lib/campaignRoles' -import { readSollinhaChatSession, writeSollinhaChatSession } from '@/lib/sollinhaChatSession' +import { + readSollinhaChatSession, + type SollinhaChatSessionOpenOrigin, + writeSollinhaChatSession, +} from '@/lib/sollinhaChatSession' type AISidebarContextValue = { /** The chat is showing — single source of truth across panel (desktop) and drawer (mobile). */ @@ -61,27 +65,59 @@ export const CampaignAISidebarProvider = ({ // would diverge from the server-rendered chat on the hydration frame. // `sessionRestored` gates the persist effect so the mount commit (empty // 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 + // 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') useEffect(() => { + if (sessionReadRef.current || !measured) return + sessionReadRef.current = true const session = readSollinhaChatSession() if (session) { setMessages(session.messages) - setOpen(session.open) + const openBy = session.openBy ?? 'settle' + restoredOpenByRef.current = openBy + if (session.open && (openBy === 'user' || !isMobile)) setOpen(true) } restoredSessionRef.current = session !== null setSessionRestored(true) - }, [setMessages]) + }, [setMessages, measured, isMobile]) + + // 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. + const userToggledOpenRef = useRef(false) + const requestOpen = useCallback( + (next: boolean) => { + userToggledOpenRef.current = true + setOpen(next) + }, + [setOpen], + ) // 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 // written: closing an empty chat must overwrite a stored `open: true`, - // otherwise a reload would reopen a drawer the user just closed. + // 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). useEffect(() => { if (!sessionRestored || status !== 'ready') return - writeSollinhaChatSession(messages, open) + const openBy = + userToggledOpenRef.current || restoredOpenByRef.current === 'user' ? 'user' : 'settle' + writeSollinhaChatSession(messages, open, openBy) }, [sessionRestored, status, messages, open]) // The chat has two surfaces keyed by viewport — the desktop panel and the @@ -103,7 +139,10 @@ export const CampaignAISidebarProvider = ({ } }, [measured, isMobile, panelRef]) - const toggle = useCallback(() => setOpen(!open), [open]) + const toggle = useCallback(() => { + userToggledOpenRef.current = true + setOpen(!open) + }, [open, setOpen]) const value = useMemo( () => ({ @@ -114,10 +153,10 @@ export const CampaignAISidebarProvider = ({ messages, status, sendMessage: chat.sendMessage, - setOpen, + setOpen: requestOpen, toggle, }), - [open, isMobile, measured, role, messages, status, chat.sendMessage, setOpen, toggle], + [open, isMobile, measured, role, messages, status, chat.sendMessage, requestOpen, toggle], ) return {children} diff --git a/src/lib/sollinhaChatSession.ts b/src/lib/sollinhaChatSession.ts index 2e99fbb9..667bf1e9 100644 --- a/src/lib/sollinhaChatSession.ts +++ b/src/lib/sollinhaChatSession.ts @@ -16,10 +16,21 @@ export const SOLLINHA_CHAT_MAX_MESSAGES = 50 /** Byte guardrail (serialized JSON) — oldest messages are dropped at write time. */ export const SOLLINHA_CHAT_MAX_BYTES = 256 * 1024 +/** + * How the session's `open` came to be true (OPS22): `'user'` when the person + * toggled the chat (FAB / header button / drawer swipe), `'settle'` when the + * desktop panel reconcile (B167) opened it. The settle is layout truth, not + * intent — a settle-originated `open: true` must never restore the mobile + * drawer "by itself". Sessions written before this field (B188) carry no + * value and are treated as `'settle'` (fail-closed). + */ +export type SollinhaChatSessionOpenOrigin = 'user' | 'settle' + export type SollinhaChatSession = { version: typeof SOLLINHA_CHAT_SESSION_VERSION messages: UIMessage[] open: boolean + openBy?: SollinhaChatSessionOpenOrigin } const MESSAGE_ROLES = new Set(['system', 'user', 'assistant']) @@ -45,7 +56,8 @@ const isSollinhaChatSession = (value: unknown): value is SollinhaChatSession => value.version === SOLLINHA_CHAT_SESSION_VERSION && Array.isArray(value.messages) && value.messages.every(isUIMessage) && - typeof value.open === 'boolean' + typeof value.open === 'boolean' && + (value.openBy === undefined || value.openBy === 'user' || value.openBy === 'settle') ) } @@ -84,13 +96,22 @@ export const pruneSollinhaChatMessages = (messages: UIMessage[]): UIMessage[] => return pruned } -/** Writes the pruned session. Fail-open: storage failures never break the chat. */ -export const writeSollinhaChatSession = (messages: UIMessage[], open: boolean): void => { +/** + * Writes the pruned session. Fail-open: storage failures never break the chat. + * `openBy` defaults to `'settle'` — a write without intent information must + * not restore the mobile drawer (OPS22). + */ +export const writeSollinhaChatSession = ( + messages: UIMessage[], + open: boolean, + openBy: SollinhaChatSessionOpenOrigin = 'settle', +): void => { if (typeof window === 'undefined') return const session: SollinhaChatSession = { version: SOLLINHA_CHAT_SESSION_VERSION, messages: pruneSollinhaChatMessages(messages), open, + openBy, } try { window.sessionStorage.setItem(SOLLINHA_CHAT_SESSION_STORAGE_KEY, JSON.stringify(session)) diff --git a/tests/e2e/campaignAiChatResize.e2e.spec.ts b/tests/e2e/campaignAiChatResize.e2e.spec.ts index 143a86d5..46ad2820 100644 --- a/tests/e2e/campaignAiChatResize.e2e.spec.ts +++ b/tests/e2e/campaignAiChatResize.e2e.spec.ts @@ -145,9 +145,9 @@ test.describe('B167 — chat Sollinha migra entre painel e drawer ao redimension name: campaign.fixtures.value('Chat Resize Coordenador'), }) // Mobile from the start (B188): a desktop login auto-opens the chat (the - // settle below) and persists `open: true` for the tab — a later reload at - // mobile would then legitimately restore the open drawer, which is not - // what a *fresh* mobile visit looks like. + // 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. await page.setViewportSize({ width: 500, height: 800 }) await campaign.login(page, user.email!, user.password) diff --git a/tests/e2e/campaignSollinhaContext.e2e.spec.ts b/tests/e2e/campaignSollinhaContext.e2e.spec.ts index b4614ddd..5ed77e12 100644 --- a/tests/e2e/campaignSollinhaContext.e2e.spec.ts +++ b/tests/e2e/campaignSollinhaContext.e2e.spec.ts @@ -149,4 +149,29 @@ test.describe('B188 — contexto da conversa persiste na sessão da janela/tab', timeout: 20_000, }) }) + + test('settle do desktop não vaza chat aberto para a visita mobile da mesma aba (OPS22)', 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. + await campaign.login(page, user.email!, user.password) + await expect.poll(async () => (await storedSession(page))?.open).toBe(true) + + // 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. + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/campanha/agenda') + + await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Sollinha — Assistente virtual' })).toBeVisible({ + timeout: 20_000, + }) + }) }) diff --git a/tests/unit/sollinhaChatSession.unit.spec.ts b/tests/unit/sollinhaChatSession.unit.spec.ts index 5957b5b1..2414bcbf 100644 --- a/tests/unit/sollinhaChatSession.unit.spec.ts +++ b/tests/unit/sollinhaChatSession.unit.spec.ts @@ -56,6 +56,32 @@ describe('sollinhaChatSession storage', () => { expect(session?.open).toBe(true) }) + it('round-trips the open origin and defaults to settle without one', () => { + writeSollinhaChatSession([], true, 'user') + expect(readSollinhaChatSession()?.openBy).toBe('user') + writeSollinhaChatSession([], true, 'settle') + expect(readSollinhaChatSession()?.openBy).toBe('settle') + // No origin passed — a write without intent must never restore a drawer. + writeSollinhaChatSession([], true) + expect(readSollinhaChatSession()?.openBy).toBe('settle') + }) + + it('rejects an invalid open origin (fail-closed)', () => { + window.sessionStorage.setItem( + SOLLINHA_CHAT_SESSION_STORAGE_KEY, + JSON.stringify({ version: 1, messages: [], open: true, openBy: 'system' }), + ) + expect(readSollinhaChatSession()).toBeNull() + }) + + it('still reads legacy sessions without an open origin', () => { + window.sessionStorage.setItem( + SOLLINHA_CHAT_SESSION_STORAGE_KEY, + JSON.stringify({ version: 1, messages: [], open: true }), + ) + expect(readSollinhaChatSession()?.openBy).toBeUndefined() + }) + it('prunes to the message-count guardrail keeping the newest', () => { const messages = Array.from({ length: SOLLINHA_CHAT_MAX_MESSAGES + 5 }, (_, index) => makeMessage(String(index), index % 2 === 0 ? 'user' : 'assistant'),