Merge pull request #677 from fsolla/B198-sollinha-mobile-fechar-o-drawer-ao-navegar-por-link

B198 — Sollinha mobile: fechar o drawer ao navegar por link
This commit is contained in:
Francisco Solla 2026-08-11 17:56:52 +00:00 committed by GitHub
commit f2008e2a65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 402 additions and 23 deletions

View file

@ -4,6 +4,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-11):** **B198** (Issue #660) — o drawer mobile do Sollinha fecha ao navegar por link interno: tocar num link `/campanha…` da resposta fecha o drawer **no toque** (antes de a página carregar) e a navegação segue no mesmo tab; desktop inalterado (painel fica aberto); link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre (o close persiste `open: false`). **Engrenagem:** a decisão pura mora no owner `src/lib/ai/markdownLinks.ts` — regex `^\/campanha(?:\/|$)` promovido a `isCampaignInternalLink` (era `APP_INTERNAL_LINK` no renderer) + `shouldCloseDrawerOnLinkClick(href, isMobile, event)` (fecha ⇔ interno && mobile && clique simples — ignora ctrl/meta/shift/alt, middle-click e `defaultPrevented`, pois abrem nova aba e a origem mantém o chat); o `markdownComponents` estático do `CampaignAIChat` virou `useMemo` no corpo (acesso ao `ctx.isMobile`/`setOpen`; identidade estável, recomputa só no crossing de breakpoint). Sessão/restauração intactas (fechar com `requestOpen(false)` marca `openBy:'user'` com `open:false` — inerte, o restore só reabre com `open:true`). Testes: unit (12 — interno/externo, viewport, modifiers, middle, prevented) + e2e no `campaignSollinhaContext` (interno fecha+navega sem `load`+reload não reabre; externo mantém drawer e abre popup interceptado). Nota: dev-mode local flakeou no prewarm/compile cold — e2e rodado no modo CI (`E2E_PROD=1` com `NEXT_DIST_DIR=.next/e2e`, 7/7). **Débito registrado no gate (B199/#672, `depends` #660):** race pré-existente do persist effect (só grava com `status==='ready'`) — fechar o drawer mid-stream e recarregar antes do settle reabre o drawer "sozinho" (classe OPS22); o tap-no-link do B198 amplia a superfície. Intenção [`sollinha-drawer-fecha-link-mobile.md`](plans/sollinha-drawer-fecha-link-mobile.md); impl [`sollinha-drawer-fecha-link-mobile-impl.md`](plans/sollinha-drawer-fecha-link-mobile-impl.md).
**Recently resolved (2026-08-11):** **C114** (Issue #635) — Agenda da Campanha espelhada no Google Calendar com push Teqo→Google (API oficial, service account). **Motor:** reconciliação completa por passada (`runCampaignCalendarSync`) com **id determinístico de evento** (`teqo` + base32hex do id — charset do Google) → upsert idempotente sem estado por evento; janela 90d passado/365d futuro (precedente do feed iCal), espelho cheio do escopo do staff (sem intersecção de advisor — agenda oficial), cancelado/deletado no Teqo **apaga o evento no Google** (sem estado cancelado visível a quem segue); conteúdo comparado por instante absoluto (imune a fuso do calendário) → passadas repetidas não tocam o Google; eventos estrangeiros do calendário nunca são tocados (decode completo — um `teqo3` manual não é nosso). **Triggers (sem cron — Hobby):** hook `afterChange`/`afterDelete` na `activity` (só campos espelhados, com normalização de shapes populados vs depth-0; tarefas não disparam; **nunca lança** — Teqo independe do Google; timeout 15s por request), auto-retry na view da agenda quando `paused` (uma vez por carregamento), botão "Sincronizar agora", e **auto-resync por config** (D7): trocar `calendarId`/reativar na collection `googleCalendarSync` re-conciliar sozinho (guard por comparação de campo quebra o loop de re-entrada); scan de delete com lookback de 730d (cancelado antigo não vira fantasma; atividade viva fora da janela mantém o evento). **Estado:** collection `googleCalendarSync` (uma linha; `calendarId` admin-only, `disabledAt` staff, timestamps system-write; **status derivado** `not-configured | disabled | synced | paused` em lib pura) + **chave da service account só em env** (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`, base64 do JSON do GCP — sem credencial no DB); sem env → `not-configured` com runbook de ops (criar calendário na conta da campanha, público no link, SA editor, calendarId no Painel). **Transporte:** `jose` (novo dep) + fetch → Calendar API v3 REST (token JWT assertion, cache em módulo, 401 re-minta uma vez, 409 de insert idempotente; `end` obrigatório → atividade sem `endAt` vira 1h). **UI (Impeccable B, padrão C94):** pill no header da agenda (sincronizado/pausado/não configurado/desativado) + FAB mobile + diálogo com os 4 estados do canvas (link público de adição `calendar/r?cid=webcal…` + instruções + sync agora + desativar; falha de action visível). **Sem participantes no v1** (decisão de produto do gate: aviso segue as configs de quem segue; service account exigiria domain-wide delegation — segue para o C115/Google→Teqo). DRY com o feed: janela/where/nomes de município/description compartilhados (`calendarFeed.ts` como dono da janela). Migration hand-written (o gerador re-emitia no-ops pré-existentes de `campaign_user.contact_id`). Testes: unit (mapping/derivação/guards com normalização/client com fetch fake/hook wiring via `vi.mock`), int (engine com stub in-memory: cria/converge/atualiza só drift/cancela deleta/delete limpa fantasma/falha→pausado→recupera/troca de calendarId), e2e (pill não-configurado + runbook, sem dependência externa). Débito de cobertura (estados disabled/paused/synced + ação) registrado como #666 (depends #635). Intenção [`c114-google-calendar-push-agenda-oficial.md`](plans/c114-google-calendar-push-agenda-oficial.md); impl [`c114-google-calendar-push-agenda-oficial-impl.md`](plans/c114-google-calendar-push-agenda-oficial-impl.md).
**Recently resolved (2026-08-11):** **D10** (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). **F1 (self-heal):** `purgeOrphanedConsentRenames` (3 nomes explícitos de renames temporários — `temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) roda sob lease **exclusivo**, em **autocommit** (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (`withMutableConsentFixture` antes do snapshot, setup do `withMissingInviteConsentFixture`, `ensureLeasedConsent`) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE `consent_key_idx` auto-sustentado). **F1b:** `purgeMunicipalityResidue` também deleta os **contatos** que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do `removeSupporterData`, reimplementado no helper). **F2 (refutação por medição + design final):** a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK `consent_id` (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era **shared**), down-test da Onda0 deletando as 4 keys **não-atomicamente**, e provision re-textando no meio de asserções de preview. **Fix:** janela do `withMissingInviteConsentFixture` → **exclusiva** (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 **atômico** (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases **exclusivos** aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. **Review /simplify (2 reviewers):** P1-1 — os renames vivos de `campaignInvite`/`campaignFixtures` migraram para chaves privadas (`consent-vacated-<id>`/`owned-consent-vacated-<id>`) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; `withExclusiveTestDatabaseLease` colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. **Contraprova:** 7 arquivos da key × 6 workers → **12/12 verdes** (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/`src/`. Intenção [`escala-dry-pos-d9.md`](plans/escala-dry-pos-d9.md); impl [`escala-dry-pos-d9-impl.md`](plans/escala-dry-pos-d9-impl.md).

View file

@ -0,0 +1,62 @@
# Sollinha: fechar o drawer durante streaming pode reabri-lo num reload (race de persistência)
Status: registrado
Atualizado em: 2026-08-11
Issue: #<N>
Priority: P3
Model: composer-2.5
Impeccable: B — comportamento do drawer existente, sem nova superfície
Appetite: ~0,5 dia eng; um ajuste no persist effect do provider
## Intenção
O persist effect do chat (`CampaignAISidebarContext.tsx`) só grava a sessão quando `status === 'ready'`. Se o usuário fecha o drawer enquanto a resposta ainda está chegando (X manual, swipe — ou, desde B198, um toque num link da resposta que fecha o drawer no mesmo tab) e um reload acontece antes do settle, o `sessionStorage` ainda carrega o estado velho `open: true` — o restore reabre o drawer "sozinho" na próxima página (mesma classe OPS22 que o repo já caça).
Pré-existente (o X tinha a mesma janela), mas o link-navegação do B198 torna o fechamento-durante-streaming um fluxo normal de campo: o usuário toca no link no meio da resposta, o drawer fecha, ele navega; um reload (ou o próprio fluxo de volta) não deve ressuscitar o drawer.
## Persona e fluxo
- **Persona / contexto:** assessor/liderança no celular; toca num link interno enquanto o Sollinha ainda está respondendo.
- **Job principal:** fechar o drawer em qualquer momento (mesmo mid-stream) e ter um reload sem drawer fantasma.
- **Fluxo desejado:** resposta em streaming → toque no link → drawer fecha → reload → drawer continua fechado, conversa restaurada.
- **Anti-goal:** não reabrir a raça de restore (OPS22/B188): fechar mid-stream não pode ser tratado como "estado velho válido".
## Objetivo e aceite
- Fechar o drawer com `status !== 'ready'` persiste `open: false` mesmo antes do settle — um reload imediato não reabre o drawer.
- A conversa em andamento continua persistindo normalmente (nada de descartar mensagens do stream em andamento).
- O mecanismo `openBy`/`userToggledOpenRef` continua governando o restore como hoje (não inverter intenção).
## Dados (intenção)
Dados: N/A — persistência client-side (`sessionStorage`), sem servidor.
## Direção no codebase (hipótese)
- **Área provável:** `src/components/campaign/shell/ai/CampaignAISidebarContext.tsx` — o persist effect (linhas ~116-121) hoje early-returna com `status !== 'ready'`. Hipótese: persistir `open` em mudanças imediatas (efeito separado só para `open`, sem gate de status, reusando `writeSollinhaChatSession` com as mensagens atuais do último commit), deixando o gate de `status` apenas para as mensagens do stream. Validar interação com `sessionRestored` e com o write do settle (que já escreve tudo junto).
- **Precedente a olhar:** `src/lib/sollinhaChatSession.ts` (write/read fail-closed), `docs/plans/sollinha-contexto-sessao-janela.md` (B188) e `sollinha-drawer-fecha-link-mobile-impl.md` (B198).
- **Risco:** escrever a sessão com um `messages` mid-stream congelaria uma resposta pela metade no storage — se o write de `open` e o write de `messages` forem o mesmo `writeSollinhaChatSession`, o gate de mensagens precisa continuar (ou o write de close pode usar o último snapshot `ready` de mensagens).
## Dependências
- #660 (B198) — o fluxo de fechamento por link é o amplificador; destrava sozinha quando o pai flipar `done`.
## Fora de escopo
- Mudar o mecanismo de restore/`openBy` (OPS22/B188) — só o write de fechamento.
- Persistir mensagens mid-stream (mantém a regra atual de só gravar stream completo).
## Rabbit holes de produto
- **Escrever o stream pela metade.** Se o ajuste gravar `messages` mid-stream, um reload perde a cauda do turno. **Corte:** fechamento persiste `open: false` com o snapshot de mensagens do último estado `ready` (ou o array atual se vazio/estável).
- **Reabrir a raça de restore.** Mexer em `openBy` para "consertar" isto troca um bug raro por outro. **Corte:** só o `open` muda; `openBy` continua como hoje.
## Questões em aberto (produto)
- Nenhuma — comportamento: fechar é fechar, em qualquer fase do turno.
## Referências
- GitHub Issue #<N> (pai: #660)
- `src/components/campaign/shell/ai/CampaignAISidebarContext.tsx`, `src/lib/sollinhaChatSession.ts`
- `docs/plans/sollinha-contexto-sessao-janela.md`, `docs/plans/sollinha-drawer-fecha-link-mobile-impl.md`

View file

@ -0,0 +1,95 @@
# Impl: Sollinha mobile: fechar o drawer ao navegar por link
Status: aprovado
Atualizado em: 2026-08-11
Issue: #660
Intenção: docs/plans/sollinha-drawer-fecha-link-mobile.md
Appetite restante: herdado (~0,5 dia eng; um encaixe no renderer de links do chat)
## Leitura da intenção
- **Outcome:** em mobile, tocar num link interno (`/campanha…`) na resposta do Sollinha fecha o drawer no toque (antes de a página carregar) e a navegação segue no mesmo tab; reabrir restaura a conversa intacta; desktop inalterado; link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre o drawer.
- **O que NÃO negociar:** a conversa não pode ser descartada (sessão intacta); o painel desktop não pode fechar; só links das respostas do chat fecham (chips/sugestões/busca ficam fora — são `sendMessage`, não navegação, então nem chegam perto do renderer de links).
- **O que reavaliar:** a hipótese "fechar é chamar `setOpen(false)` no toque, provavelmente só quando `isMobile`" — validar o efeito no `userToggledOpenRef`/`openBy` persistido (ver Riscos).
## Decisões de engenharia
### D1 — Onde fechar o drawer
```
Opções: A | B | C
Recomendação: A — no onClick do renderer de link interno (mobile) — porque é o
único ponto que conhece a tripla href + viewport + gesto do clique: o renderer
já decide interno vs externo (B187/B188), o `ctx.isMobile` vem do provider e o
click event diz se é clique simples (mesmo tab). Fecha no toque, exatamente o
aceite ("antes de a página carregar"). Sessão intocada.
Alternativas rejeitadas: B (efeito em pathname no provider) porque fecha em
QUALQUER navegação — incluindo back/forward e cliques na faixa de 10% da página
visível acima do drawer —, mais amplo que o aceite (o corte da intenção é "só
os links das respostas") e dispara após o commit da navegação, não no toque; C
(expor `closeForNavigation` no contexto que não marca intenção de usuário)
porque `requestOpen(false)` já é seguro (ver Riscos) — crescer o API do
contexto sem mudança de comportamento é over-engineering.
```
### D2 — Onde mora a decisão pura (testável)
```
Opções: A | B
Recomendação: A — em `src/lib/ai/markdownLinks.ts` (dono da decisão de link
interno/externo, B187) — porque o regex `APP_INTERNAL_LINK` já é conhecimento
de link que vive no renderer; consolidar lá como `isCampaignInternalLink` e
adicionar `shouldCloseDrawerOnLinkClick` pura (href + isMobile + metadados do
click), unit-testável sem DOM. O renderer vira fiação fina.
Alternativas rejeitadas: B (deixar a condição inline no componente) porque a
regra de fechamento (mobile + interno + clique simples, ignorando
ctrl/meta/shift/alt/middle-click e defaultPrevented) merece pin de teste de
unidade, e o owner já existe.
```
### D3 — Como o renderer alcança o contexto
`markdownComponents` hoje é constante de módulo (sem acesso ao `useAISidebar`).
Mover para dentro de `CampaignAIChat` num `useMemo` (deps `[ctx?.isMobile,
ctx?.setOpen]`) — o `a` interno ganha `onClick` que fecha quando a função pura
responde true. `setOpen` (requestOpen) é estável via `useCallback`; o memo não
re-renderiza o markdown à toa. Alternativa rejeitada: componente novo
`AssistantMarkdown` — o chat já é o dono do markdown; fatiar em outro
componente é cerimônia sem volatilidade.
## Componentes / mudanças
- **`isCampaignInternalLink`** (`src/lib/ai/markdownLinks.ts`): regex `^\/campanha(?:\/|$)` promovido de `CampaignAIChat.tsx` para o owner; usado pelo renderer e pela função de fechamento. Sem mudança de semântica.
- **`shouldCloseDrawerOnLinkClick(href, isMobile, event)`** (`src/lib/ai/markdownLinks.ts`): `true` ⇔ link interno && `isMobile` && clique simples (`button === 0`, sem ctrl/meta/shift/alt, `!defaultPrevented`). Modifier/middle-click abre em nova aba — a aba de origem mantém o drawer (mesma regra do link externo).
- **`CampaignAIChat.tsx`**: `markdownComponents` vira `useMemo` no corpo do componente lendo `ctx`; o branch interno do `a` adiciona `onClick` que chama `setOpen(false)` quando `shouldCloseDrawerOnLinkClick`. Branch externo e desktop intactos (desktop: `isMobile` false → no-op).
- **`tests/unit/aiMarkdownLinks.unit.spec.ts`**: pin das duas funções puras (interno/externo, viewport, modifiers, botão, defaultPrevented).
- **`tests/e2e/campaignSollinhaContext.e2e.spec.ts`**: dois testes mobile com o mock de SSE já usado no arquivo (resposta com link interno → drawer fecha + URL muda sem `load`; resposta com link externo → drawer permanece e abre nova aba).
- **Migration:** sem migration.
- **Access / Consent:** N/A.
- **UI:** Impeccable B — comportamento do drawer existente, sem superfície nova; shape→critique leve (não há shape novo).
## Fases verificáveis
1. **Núcleo puro** — `markdownLinks.ts` + unit tests; `pnpm test:unit` (spec de markdownLinks).
2. **Fiação UI** — `CampaignAIChat.tsx` (useMemo + onClick); `pnpm typecheck` + `pnpm lint`.
3. **E2E** — dois testes no `campaignSollinhaContext.e2e.spec.ts`; rodar só o arquivo.
4. **Gates** — `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`; push via `pnpm push` no PR.
## Rabbit holes / Não escopo (engenharia)
- Animar/transicionar o fechamento; mudar drawer/vaul; chips/sugestões/busca (não navegam); sessão/restauração; painel desktop; prefetch de link.
- Decisão de "qual drawer state se anima durante navegação": não existe — `setOpen(false)` usa o mecanismo existente.
## Riscos e mitigação
- **`openBy` persistido como `'user'` após fechamento por navegação** (porque `requestOpen` marca `userToggledOpenRef`): inerte — o restore só reabre quando `session.open === true` (CampaignAISidebarContext.tsx:89), e aqui `open` vira `false` antes do persist effect rodar (`status` ready + mudança de `open`). Reload não reabre (aceite). Validação no teste e2e existente "drawer aberto volta aberto após reload" não é afetada (aquele fluxo não navega).
- **Clique modificado (cmd/ctrl+click) em link interno**: abre nova aba; sem fechar — a aba de origem mantém o chat montado. Coberto por unit test da função pura.
- **Clique em link durante frame de hidratação (isMobile não medido)**: impossível — o drawer só renderiza com `isMobile` true, que só vira true após a medição (`useIsMobileMeasured`).
- **`markdownComponents` recriado por render**: o `useMemo` mantém identidade estável; `ReactMarkdown` recebe `components` novo apenas quando viewport/`setOpen` mudam.
- **Fechar + navegar no mesmo handler**: state update (drawer) e router push (next/link) coexistem sem conflito; o `e.defaultPrevented` não é tocado.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (5 itens do aceite → testes unit + e2e)
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem access, identificadores em inglês, pt-BR só em strings de UI)
- [x] Testes de domínio previstos: unit (decisão pura) + e2e (comportamento drawer mobile)

View file

@ -114,6 +114,7 @@ export const E2E_AFFECTED_MANIFEST = [
prefixes: [
'src/components/campaign/shell/ai',
'src/lib/sollinhaChatSession',
'src/lib/ai/markdownLinks',
`${CAMPAIGN_APP}/api/ai-chat`,
],
specs: [

View file

@ -12,7 +12,11 @@ import { InputGroup, InputGroupButton } from '@/components/ui/input-group'
import { Marker, MarkerContent } from '@/components/ui/marker'
import { Message, MessageAvatar, MessageContent } from '@/components/ui/message'
import { Spinner } from '@/components/ui/Spinner'
import { externalLinkTarget } from '@/lib/ai/markdownLinks'
import {
externalLinkTarget,
isCampaignInternalLink,
shouldCloseDrawerOnLinkClick,
} from '@/lib/ai/markdownLinks'
import { cn } from '@/lib/utils'
import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
@ -30,35 +34,52 @@ import { getSollinhaOpeningQuestions } from '@/lib/sollinhaOpeningQuestions'
* only ever emits `/campanha…` paths — navigate through `next/link` (B188), so
* following a Sollinha link keeps the layout (and the conversation) mounted
* instead of reloading the page. Anything else keeps the plain anchor.
* B198 — on mobile the same-tab internal navigation closes the drawer in the
* touch, so the destination is visible on arrival; external and new-tab
* navigations leave the drawer open for the origin tab's conversation.
*/
const APP_INTERNAL_LINK = /^\/campanha(?:\/|$)/
/** B192 — narrower drawer surface: fewer follow-up chips than on desktop. */
const FOLLOW_UP_MOBILE_LIMIT = 2
const markdownComponents: Components = {
a: ({ node: _node, href, children, ...rest }) => {
const external = typeof href === 'string' ? externalLinkTarget(href) : null
if (typeof href === 'string' && APP_INTERNAL_LINK.test(href)) {
return (
<Link href={href} {...rest}>
{children}
</Link>
)
}
return (
<a href={href} {...rest} {...external}>
{children}
</a>
)
},
}
export const CampaignAIChat = ({ className }: { className?: string }) => {
const ctx = useAISidebar()
const [input, setInput] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
// B198 — the markdown link renderer needs the context (viewport + close),
// so the module-level components object moved in here: same shape as before,
// plus the mobile same-tab navigation closing the drawer on touch. The memo
// keeps the renderer identity stable across message renders; it recomputes
// only on a breakpoint crossing (`isMobileViewport`) or context churn.
const isMobileViewport = ctx?.isMobile ?? false
const closeChat = ctx?.setOpen
const markdownComponents = useMemo<Components>(() => {
return {
a: ({ node: _node, href, children, ...rest }) => {
const external = typeof href === 'string' ? externalLinkTarget(href) : null
if (typeof href === 'string' && isCampaignInternalLink(href)) {
return (
<Link
href={href}
{...rest}
onClick={(e) => {
if (shouldCloseDrawerOnLinkClick(href, isMobileViewport, e)) closeChat?.(false)
}}
>
{children}
</Link>
)
}
return (
<a href={href} {...rest} {...external}>
{children}
</a>
)
},
}
}, [isMobileViewport, closeChat])
const mic = useMicTranscript()
const textareaRef = useRef<HTMLTextAreaElement>(null)

View file

@ -3,6 +3,14 @@ export type ExternalLinkTarget = {
rel: 'noopener noreferrer'
}
/**
* App-internal destinations the assistant emits — the B162 link catalog only
* ever produces `/campanha…` paths (B187/B188).
*/
const CAMPAIGN_INTERNAL_LINK = /^\/campanha(?:\/|$)/
export const isCampaignInternalLink = (href: string): boolean => CAMPAIGN_INTERNAL_LINK.test(href)
/**
* Decides whether a markdown href rendered by the Sollinha chat should open in
* a new tab. Only absolute http(s) URLs (and protocol-relative ones) qualify —
@ -16,3 +24,34 @@ export const externalLinkTarget = (href: string): ExternalLinkTarget | null => {
}
return null
}
/** The click metadata that decides same-tab vs new-tab navigation (B198). */
export type LinkClickMeta = {
button: number
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
defaultPrevented: boolean
}
/**
* B198 — a mobile link click on the mobile drawer closes it only for the
* navigation that replaces the page in the SAME tab: an internal link touched
* with a plain left click. Modified clicks (cmd/ctrl/shift/alt), middle clicks
* and already-prevented clicks open the destination in a new tab (or nowhere)
* — the origin tab keeps the drawer so the conversation is where it was. The
* desktop panel never closes (the accept keeps it open while navigating).
*/
export const shouldCloseDrawerOnLinkClick = (
href: string,
isMobile: boolean,
event: LinkClickMeta,
): boolean => {
if (!isMobile) return false
if (!isCampaignInternalLink(href)) return false
if (event.defaultPrevented) return false
if (event.button !== 0) return false
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false
return true
}

View file

@ -4,6 +4,9 @@ import { expect, test } from './fixtures/campaignE2EFixtures.js'
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/).'
/**
* 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
@ -12,7 +15,7 @@ const SESSION_KEY = 'teqo:campaign:sollinha-chat-session'
* schema and the chat would never settle. The reply carries a markdown link so
* the "navigate without reload" path has a real anchor.
*/
const mockAiChat = (page: Page) =>
const mockAiChat = (page: Page, reply: string = DEFAULT_REPLY) =>
page.route('**/campanha/api/ai-chat', async (route) => {
await route.fulfill({
status: 200,
@ -20,7 +23,7 @@ const mockAiChat = (page: Page) =>
body: [
'data: {"type":"start"}\n\n',
'data: {"type":"text-start","id":"t1"}\n\n',
'data: {"type":"text-delta","id":"t1","delta":"Resposta mockada com [Municípios](/campanha/municipios)."}\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(''),
@ -44,6 +47,14 @@ const waitForChatSettled = async (page: Page) => {
).toBeEnabled({ timeout: 20_000 })
}
/** Opens the mobile drawer and waits for it to cover the page. */
const openMobileDrawer = async (page: Page) => {
const drawer = page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })
await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).click()
await expect(drawer).toBeVisible({ timeout: 20_000 })
return drawer
}
/** Raw stored session, or null when nothing (valid) is in the tab's storage. */
const storedSession = (page: Page) =>
page.evaluate((key) => {
@ -175,3 +186,75 @@ test.describe('B188 — contexto da conversa persiste na sessão da janela/tab',
})
})
})
test.describe('B198 — link de resposta fecha o drawer mobile ao navegar no mesmo tab', () => {
test.beforeEach(async ({ page }) => {
test.slow()
await mockAiChat(page)
})
test('link interno: drawer fecha no toque e a navegação segue no mesmo tab', async ({
page,
campaign,
}) => {
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Drawer Link Interno'),
})
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)
// A full browser navigation fires `load`; client-side routing does not.
let loadEvents = 0
page.on('load', () => {
loadEvents += 1
})
await drawer.getByRole('link', { name: 'Municípios' }).click()
await expect(page).toHaveURL(/\/campanha\/municipios/)
await expect(drawer).toHaveCount(0)
expect(loadEvents).toBe(0)
// The navigation-originated close persists `open: false` — a reload never
// reopens the drawer on its own.
await page.reload()
await expect(drawer).toHaveCount(0)
})
test('link externo: abre em nova aba e o drawer permanece aberto', async ({ page, campaign }) => {
// Last-registered route wins (LIFO) — overrides the beforeEach mock.
await mockAiChat(page, EXTERNAL_REPLY)
// The popup would hit the real site; fulfill it so CI stays deterministic.
await page
.context()
.route('**://saude.ba.gov.br/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>ok</title>' }),
)
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Drawer Link Externo'),
})
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)
const popupPromise = page.waitForEvent('popup')
await drawer.getByRole('link', { name: 'portal da saúde' }).click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
await popup.close()
// The origin tab keeps the conversation reachable — the drawer stays.
await expect(drawer).toBeVisible()
})
})

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { externalLinkTarget } from '@/lib/ai/markdownLinks'
import {
externalLinkTarget,
isCampaignInternalLink,
shouldCloseDrawerOnLinkClick,
type LinkClickMeta,
} from '@/lib/ai/markdownLinks'
/**
* B187: only absolute web URLs get the new-tab treatment in the Sollinha chat;
@ -46,3 +51,75 @@ describe('externalLinkTarget', () => {
expect(externalLinkTarget('')).toBeNull()
})
})
describe('isCampaignInternalLink', () => {
it('accepts campaign paths and the campaign root', () => {
expect(isCampaignInternalLink('/campanha')).toBe(true)
expect(isCampaignInternalLink('/campanha/')).toBe(true)
expect(isCampaignInternalLink('/campanha/municipios')).toBe(true)
expect(isCampaignInternalLink('/campanha/municipios/ilheus')).toBe(true)
})
it('rejects look-alike paths, web URLs and non-web protocols', () => {
expect(isCampaignInternalLink('/campanha-mobile')).toBe(false)
expect(isCampaignInternalLink('/campanhaX')).toBe(false)
expect(isCampaignInternalLink('https://pt.jorgesolla.com.br/campanha')).toBe(false)
expect(isCampaignInternalLink('mailto:contato@jorgesolla.com.br')).toBe(false)
expect(isCampaignInternalLink('')).toBe(false)
})
})
describe('shouldCloseDrawerOnLinkClick', () => {
const plainClick: LinkClickMeta = {
button: 0,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
defaultPrevented: false,
}
it('closes the drawer on a plain internal link click on mobile', () => {
expect(shouldCloseDrawerOnLinkClick('/campanha/municipios/ilheus', true, plainClick)).toBe(true)
expect(shouldCloseDrawerOnLinkClick('/campanha', true, plainClick)).toBe(true)
})
it('never closes on desktop', () => {
expect(shouldCloseDrawerOnLinkClick('/campanha/municipios/ilheus', false, plainClick)).toBe(
false,
)
})
it('never closes for external links (they open in a new tab)', () => {
expect(shouldCloseDrawerOnLinkClick('https://saude.ba.gov.br', true, plainClick)).toBe(false)
expect(shouldCloseDrawerOnLinkClick('mailto:contato@jorgesolla.com.br', true, plainClick)).toBe(
false,
)
})
it('keeps the drawer for modified clicks and middle clicks (new-tab navigation)', () => {
for (const key of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'] as const) {
expect(
shouldCloseDrawerOnLinkClick('/campanha/municipios/ilheus', true, {
...plainClick,
[key]: true,
}),
).toBe(false)
}
expect(
shouldCloseDrawerOnLinkClick('/campanha/municipios/ilheus', true, {
...plainClick,
button: 1,
}),
).toBe(false)
})
it('respects a prevented default', () => {
expect(
shouldCloseDrawerOnLinkClick('/campanha/municipios/ilheus', true, {
...plainClick,
defaultPrevented: true,
}),
).toBe(false)
})
})