fix(OPS22): settle do chat desktop não vaza drawer para a visita mobile da mesma aba — CI main verde (deploy desbloqueado)
This commit is contained in:
parent
7b044ff6ef
commit
5bc154dd20
7 changed files with 245 additions and 14 deletions
|
|
@ -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 <N>`, 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 `<Code>-<slug>` vs `agent/<id>-<slug>`, 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 <dir>` 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 <main>` 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 <dir> --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/<nome>/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).
|
||||
|
|
|
|||
113
docs/plans/ci-e2e-agenda-mobile-regressao-b188-impl.md
Normal file
113
docs/plans/ci-e2e-agenda-mobile-regressao-b188-impl.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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<SollinhaChatSessionOpenOrigin>('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 <AISidebarContext.Provider value={value}>{children}</AISidebarContext.Provider>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue