From aa8fe582c056d862a109253ad51597ce96e52e9a Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 13:34:49 -0300 Subject: [PATCH] feat(B198): drawer mobile do Sollinha fecha ao navegar por link interno MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O renderer de links do chat decide interno vs externo (B187/B188); a decisão pura de fechamento mora no owner src/lib/ai/markdownLinks.ts (shouldCloseDrawerOnLinkClick: interno && mobile && clique simples) e o markdownComponents estatico virou useMemo no corpo do CampaignAIChat para alcancar o contexto. Desktop inalterado; links externos/modos de nova aba mantem o drawer da aba de origem; sessao e restore intactos (close persiste open:false). - unit: 12 testes da decisao pura - e2e (modo CI, E2E_PROD=1): interno fecha + navega sem load + reload nao reabre; externo abre popup e mantem o drawer - debito B199/#672 (depends #660): race de persistencia ao fechar mid-stream --- docs/CHANGELOG-AGENTS.md | 1 + .../sollinha-close-mid-stream-persist.md | 62 ++++++++++++ .../sollinha-drawer-fecha-link-mobile-impl.md | 95 +++++++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 1 + .../campaign/shell/ai/CampaignAIChat.tsx | 61 ++++++++---- src/lib/ai/markdownLinks.ts | 39 ++++++++ tests/e2e/campaignSollinhaContext.e2e.spec.ts | 87 ++++++++++++++++- tests/unit/aiMarkdownLinks.unit.spec.ts | 79 ++++++++++++++- 8 files changed, 402 insertions(+), 23 deletions(-) create mode 100644 docs/plans/sollinha-close-mid-stream-persist.md create mode 100644 docs/plans/sollinha-drawer-fecha-link-mobile-impl.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 3966f556..c34ed4ba 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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-`/`owned-consent-vacated-`) — 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). diff --git a/docs/plans/sollinha-close-mid-stream-persist.md b/docs/plans/sollinha-close-mid-stream-persist.md new file mode 100644 index 00000000..27ed2488 --- /dev/null +++ b/docs/plans/sollinha-close-mid-stream-persist.md @@ -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: # +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 # (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` diff --git a/docs/plans/sollinha-drawer-fecha-link-mobile-impl.md b/docs/plans/sollinha-drawer-fecha-link-mobile-impl.md new file mode 100644 index 00000000..2a72d973 --- /dev/null +++ b/docs/plans/sollinha-drawer-fecha-link-mobile-impl.md @@ -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) diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 19947ddf..4dda1e14 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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: [ diff --git a/src/components/campaign/shell/ai/CampaignAIChat.tsx b/src/components/campaign/shell/ai/CampaignAIChat.tsx index 51e33380..8b4688fd 100644 --- a/src/components/campaign/shell/ai/CampaignAIChat.tsx +++ b/src/components/campaign/shell/ai/CampaignAIChat.tsx @@ -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 ( - - {children} - - ) - } - return ( - - {children} - - ) - }, -} - export const CampaignAIChat = ({ className }: { className?: string }) => { const ctx = useAISidebar() const [input, setInput] = useState('') const bottomRef = useRef(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(() => { + return { + a: ({ node: _node, href, children, ...rest }) => { + const external = typeof href === 'string' ? externalLinkTarget(href) : null + if (typeof href === 'string' && isCampaignInternalLink(href)) { + return ( + { + if (shouldCloseDrawerOnLinkClick(href, isMobileViewport, e)) closeChat?.(false) + }} + > + {children} + + ) + } + return ( + + {children} + + ) + }, + } + }, [isMobileViewport, closeChat]) + const mic = useMicTranscript() const textareaRef = useRef(null) diff --git a/src/lib/ai/markdownLinks.ts b/src/lib/ai/markdownLinks.ts index 8c193c5c..9393f4f5 100644 --- a/src/lib/ai/markdownLinks.ts +++ b/src/lib/ai/markdownLinks.ts @@ -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 +} diff --git a/tests/e2e/campaignSollinhaContext.e2e.spec.ts b/tests/e2e/campaignSollinhaContext.e2e.spec.ts index 5ed77e12..8a4b7777 100644 --- a/tests/e2e/campaignSollinhaContext.e2e.spec.ts +++ b/tests/e2e/campaignSollinhaContext.e2e.spec.ts @@ -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: 'ok' }), + ) + + 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() + }) +}) diff --git a/tests/unit/aiMarkdownLinks.unit.spec.ts b/tests/unit/aiMarkdownLinks.unit.spec.ts index 2fc2957a..084c18c6 100644 --- a/tests/unit/aiMarkdownLinks.unit.spec.ts +++ b/tests/unit/aiMarkdownLinks.unit.spec.ts @@ -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) + }) +})