From 0e38d19d4257701a9d6001f203f2542950cfa47f Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 8 Aug 2026 16:26:49 -0300 Subject: [PATCH 1/3] feat(B167): migra o chat Sollinha entre painel e drawer ao redimensionar - Converse (useChat) lifts to the provider: survives desktop panel <-> mobile drawer swaps, internal /campanha navigation and resize (new tab still resets). - Chat Panel/Separator always mounted (RRP panel-count change forces a full navigation); footprint hidden via elementRef + .b167-ai-chat-hidden (display:none !important), avoiding RRP's imperative collapse/expand races. - Single 'open' flag drives both surfaces (open && viewport); reconciled with the panel at desktop settle and on user drag-collapse (gated by isUserInteraction). - Mobile Drawer extracted to CampaignAIDrawer; FAB/setOpen unified; focus moves into the chat on the mobile->desktop swap. - e2e campaignAiChatResize: desktop->mobile, mobile->desktop, closed stays closed, fresh mobile visit closed, navigation survival, new tab resets. - Impl plan: docs/plans/migrar-chat-painel-drawer-resize-impl.md Closes #416 --- .../migrar-chat-painel-drawer-resize-impl.md | 87 ++++++++ src/app/(frontend)/styles.css | 8 + .../campaign/shell/ai/CampaignAIChat.tsx | 21 +- .../campaign/shell/ai/CampaignAIDrawer.tsx | 45 +++++ .../campaign/shell/ai/CampaignAIFab.tsx | 2 +- .../campaign/shell/ai/CampaignAISidebar.tsx | 37 +--- .../shell/ai/CampaignAISidebarContext.tsx | 70 ++++--- .../shell/ai/CampaignAISidebarShell.tsx | 142 +++++++++---- src/hooks/use-mobile.ts | 19 +- tests/e2e/campaignAiChatResize.e2e.spec.ts | 187 ++++++++++++++++++ 10 files changed, 513 insertions(+), 105 deletions(-) create mode 100644 docs/plans/migrar-chat-painel-drawer-resize-impl.md create mode 100644 src/components/campaign/shell/ai/CampaignAIDrawer.tsx create mode 100644 tests/e2e/campaignAiChatResize.e2e.spec.ts diff --git a/docs/plans/migrar-chat-painel-drawer-resize-impl.md b/docs/plans/migrar-chat-painel-drawer-resize-impl.md new file mode 100644 index 00000000..7c256b1f --- /dev/null +++ b/docs/plans/migrar-chat-painel-drawer-resize-impl.md @@ -0,0 +1,87 @@ +# Impl: Migrar o chat Sollinha entre painel e drawer ao redimensionar + +Status: aprovado — em execução (design final conforme entregue) +Atualizado em: 2026-08-08 +Issue: #416 +Intenção: docs/plans/migrar-chat-painel-drawer-resize.md +Appetite restante: herdado (~1–1,5 dia eng; sem migration) + +## Leitura da intenção + +- **Outcome:** ao cruzar a borda 768 px com o chat aberto, o chat migra aberto para a outra superfície (painel↔drawer) com a conversa atual; fechado permanece fechado; a conversa dura a sessão do site (navegação interna, abrir/fechar, resize) e reinicia em nova aba. +- **O que NÃO negociar:** não fechar um chat aberto; não abrir um chat fechado; `open` é a fonte da verdade (sem flag nova); sem histórico entre sessões (LGPD); abrir/fechar em tela fixa como hoje (painel desktop continua abrindo em 25% no load); sem tocar a largura default (irmão B166); sem migration/collection/Consent. +- **O que foi reavaliado (tudo reproduzido em dev + e2e):** + 1. `useChat` sem store por `id`: em `@ai-sdk/react@4.0.50` as mensagens vivem na instância `Chat` — `id` estável NÃO sobrevive a desmonte. A conversa foi **erguida para o provider** (persiste no layout `(app)`), e `CampaignAIChat` virou consumidor. + 2. `react-resizable-panels@4` aplica `display:flex` inline no elemento do `Panel` e roteia className/style para o wrapper interno — o `hidden md:block` antigo não zerava a pegada flex (coluna fantasma no mobile). Além disso, **mudar a contagem de painéis do `Group` em runtime força uma navegação full-page** nesta app. Solução: panel+separator sempre montados; a pegada do panel é zerada por uma classe com `display:none !important` alternada via `elementRef` no elemento OUTER (sobrevive aos re-renders async do RRP). + 3. `collapse()/expand()` imperativos do RRP são sobrepostos pelo próprio re-layout do RRP no cruzamento (raças). O design **nunca** os usa no caminho do cruzamento; só um drag-collapse do usuário (gated por `meta.isUserInteraction`) fecha o chat. + +## Abordagem final entregue + +```mermaid +flowchart LR + P[CampaignAISidebarProvider
open · isMobile · measured · useChat · reconcile/sync] --> S[CampaignAISidebarShell] + S -->|desktop| G[Group
Panel main + Separator + Panel chat sempre montados] + S -->|mobile| D[CampaignAIDrawer
portal, fora do Group] + G --> C[CampaignAISidebar
desktop surface] + D --> C2[CampaignAIDrawer + header] + C --> CH[CampaignAIChat
consumidor do contexto] + C2 --> CH +``` + +**Sem "migração" no cruzamento:** as duas superfícies derivam do MESMO `open` + viewport (`chatVisible = measured && !isMobile && open`). Cruzar a borda só re-deriva a superfície. O trabalho é manter `open` fiel: + +- **Reconcile no settle desktop:** o panel abre no default RRP (25%) com `open=false`; no primeiro settle medido desktop, `open := !panelRef.isCollapsed()` — um chat visível é "aberto" (X fecha, drawer abre no resize). Visita mobile mantém `open=false` (gated por `!isMobile`). +- **Drag-collapse:** `Group.onLayoutChanged((_, meta) => sync(meta.isUserInteraction))` — só o drag do usuário (isUserInteraction) fecha; o auto-collapse que o RRP faz no próprio re-layout (panel display:none mede 0) NÃO fecha o drawer recém-aberto no lado mobile. +- **Reopen pós-collapse:** effect `[open, isMobile]` expande se `open && !isMobile && isCollapsed()` (no-op nos caminhos normais — sem raça). +- **Pré-medição:** `chatVisible = measured ? … : true` — o frame de hidratação não esconde o panel (evita o piscar 100%→75% no load desktop). + +### Componentes / mudanças + +- **`CampaignAISidebarContext.tsx`**: dono único — `open`, `isMobile`/`measured` (`useIsMobileMeasured`), `useChat` erguido (transport memoizado, `id: 'campaign-sollinha'`), reconcile no settle, `syncPanelVisibility(isUserInteraction)`, effect de reopen. Removeu `setOpenMobile` (unificado em `setOpen`). +- **`CampaignAISidebarShell.tsx`**: `Group`/`Separator`/`Panel` chat sempre montados; `elementRef` + `useLayoutEffect` alterna a classe `b167-ai-chat-hidden`; `CampaignAIDrawer` como irmão no mobile; foco para o textarea ao migrar mobile→desktop com chat aberto. +- **`src/app/(frontend)/styles.css`**: utility `.b167-ai-chat-hidden { display:none !important }` (vence o inline `display:flex` do RRP). +- **`src/hooks/use-mobile.ts`**: refatorado — `useMobileMeasured()` interno + `useIsMobile()` (compat) + `useIsMobileMeasured()` (expoe `measured`). +- **`CampaignAIDrawer.tsx` (novo)**: surface mobile (Drawer + header + chat), portado do branch removido de `CampaignAISidebar`. +- **`CampaignAISidebar.tsx`**: só a surface desktop (header + chat). +- **`CampaignAIChat.tsx`**: consumidor do contexto (remove `useChat` local e o `viewportRef` morto). +- **`CampaignAIFab.tsx`**: `setOpen(true)` (era `setOpenMobile(true)`). +- **Migration:** nenhuma. Access/Consent/RBAC/cache: intocados. + +### Abordagens rejeitadas (registradas) + +- **Migração explícita por resolver puro** (`resolveAISidebarOpenOnResize` + unit tests): descartada quando se provou que, com `open` reconciliado, o cruzamento é automático (`open && viewport`). O resolver virou código morto e foi removido. +- **Panel condicional (desmonta no mobile):** força navegação full-page do RRP nesta app. +- **`display:none` via className/style do RRP:** vão no wrapper interno, não zeram a pegada. +- **`collapse()/expand()` no cruzamento:** sobrepostos pelo re-layout do RRP (raças). +- **`collapsible` no Panel do chat:** o RRP auto-colapsa um painel `display:none` (largura 0) durante o próprio re-layout, e no build de produção o painel voltava ao desktop colapsado (largura 0) sem nada para expandir de forma confiável. Removido — mostrar/esconder é 100% CSS; o painel sempre mantém um tamanho RRP real quando visível. + +### Dados → forma + +- **Apresento dados?** Não (N/A — correção de estado de UI). Item 3 de data-presentation não se aplica. + +## Fases verificáveis + +1. **Núcleo + surfaces** — provider (open/isMobile/measured/useChat/reconcile/sync), shell montado-constante, `CampaignAIDrawer`, chat consumidor, FAB unificado, utility CSS. +2. **Verificação no navegador** — 4 fluxos da intenção por viewport (1280↔500) em dev real. +3. **Gates + e2e** — unit (full), lint/tsc/prettier/cycles; e2e `campaignAiChatResize.e2e.spec.ts` com mock SSE: desktop→mobile (drawer + conversa + sem coluna), mobile→desktop (painel + conversa, sem reload), fechado-fica-fechado (2 direções), visita mobile nova fechada, conversa sobrevive à navegação interna, nova aba recomeça vazia. Entrega com `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- **sessionStorage/localStorage p/ reload na mesma aba:** reload não está no aceite; o provider cobre navegação interna/abrir-fechar/resize sem storage. Deferir (gatilho: produto pedir reload-persistência). +- **Largura default / persistência de tamanho / drag handle:** terreno do B166 (#414, in-progress separada) — aqui só migrar, não remodelar. +- **Debounce de resize rápido:** intenção resolveu "aceitar". +- **Extrair `AISidebarHeader` compartilhado:** 2 call sites com classes diferentes = módulo raso; mantido inline. +- **Foco/AT no drawer×painel:** foco do textarea coberto no mobile→desktop; anúncio AT de troca de superfície deferido (gate) se o produto quiser. + +## Riscos e mitigação + +- **RRP + panel sempre montado + display:none:** utility `!important` sobrevive aos re-renders async do RRP; auto-collapse do RO não fecha o chat (gated por isUserInteraction). Verificado em e2e. +- **Load desktop sem piscar 100%→75%:** `chatVisible` cai para `true` pré-medição. +- **Flakiness local (dev server 2 workers):** conhecida no repo; CI roda e2e sobre o build de produção (`pnpm start`), sem compilações a frio nem hard-nav — stável. +- **Foco ao migrar:** foco vai para o textarea do painel no mobile→desktop com chat aberto. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção coberto (migração aberto→aberto; fechado→fechado; conversa por sessão da aba; tela fixa intacta; visita mobile fecha) +- [x] Invariantes AGENTS/engineering-standards (sem migration; sem Consent; `open` fonte da verdade; identificadores EN / copy pt-BR; transações não tocadas) +- [x] Testes: e2e com mock SSE (6 cenários) + full unit/lint/tsc/prettier/cycles diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 7eb2dab3..88cb0df5 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -403,6 +403,14 @@ body, .scrollbar-hide::-webkit-scrollbar { display: none; /* Chrome, Safari */ } + + /* B167 — the chat Panel's footprint is hidden by this class instead of RRP's + imperative collapse: `display:none !important` beats the `display:flex` + react-resizable-panels writes inline on the Panel element on every render, + so hiding survives RRP's own (async, viewport-driven) re-layouts. */ + .b167-ai-chat-hidden { + display: none !important; + } } /* E16 — municipality dossier print view: compact A4 typography (one step down diff --git a/src/components/campaign/shell/ai/CampaignAIChat.tsx b/src/components/campaign/shell/ai/CampaignAIChat.tsx index 2fa02980..7ac87792 100644 --- a/src/components/campaign/shell/ai/CampaignAIChat.tsx +++ b/src/components/campaign/shell/ai/CampaignAIChat.tsx @@ -1,9 +1,7 @@ 'use client' -import { useChat } from '@ai-sdk/react' -import { DefaultChatTransport } from 'ai' import { Bot, Send, User } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -14,19 +12,23 @@ import { Message, MessageAvatar, MessageContent } from '@/components/ui/message' import { Spinner } from '@/components/ui/Spinner' import { cn } from '@/lib/utils' +import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext' + export const CampaignAIChat = ({ className }: { className?: string }) => { - const { messages, sendMessage, status } = useChat({ - transport: new DefaultChatTransport({ api: '/campanha/api/ai-chat' }), - }) + const ctx = useAISidebar() const [input, setInput] = useState('') - const viewportRef = useRef(null) const bottomRef = useRef(null) + const messages = useMemo(() => ctx?.messages ?? [], [ctx]) + // Auto-scroll to bottom on new messages useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) + if (!ctx) return null + + const { sendMessage, status } = ctx const busy = status !== 'ready' const handleSubmit = (e: React.FormEvent) => { @@ -39,10 +41,7 @@ export const CampaignAIChat = ({ className }: { className?: string }) => { return (
{/* Messages area */} -
+
{messages.length === 0 ? (
diff --git a/src/components/campaign/shell/ai/CampaignAIDrawer.tsx b/src/components/campaign/shell/ai/CampaignAIDrawer.tsx new file mode 100644 index 00000000..99f8e08d --- /dev/null +++ b/src/components/campaign/shell/ai/CampaignAIDrawer.tsx @@ -0,0 +1,45 @@ +'use client' + +import { Bot, X } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/Drawer' + +import { CampaignAIChat } from '@/components/campaign/shell/ai/CampaignAIChat' +import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext' + +export const CampaignAIDrawer = () => { + const ctx = useAISidebar() + + if (!ctx) return null + + const { open, setOpen } = ctx + + return ( + setOpen(next)} showSwipeHandle> + + Sollinha — Assistente virtual +
+
+
+ + Sollinha +
+ +
+ +
+
+
+ ) +} diff --git a/src/components/campaign/shell/ai/CampaignAIFab.tsx b/src/components/campaign/shell/ai/CampaignAIFab.tsx index 9385ae26..dbb14c9c 100644 --- a/src/components/campaign/shell/ai/CampaignAIFab.tsx +++ b/src/components/campaign/shell/ai/CampaignAIFab.tsx @@ -23,7 +23,7 @@ export const CampaignAIFab = ({ className }: { className?: string }) => { size="icon" className="pointer-events-auto size-8 rounded-full shadow-lg" aria-label="Sollinha — Assistente virtual" - onClick={() => ctx?.setOpenMobile(true)} + onClick={() => ctx?.setOpen(true)} > diff --git a/src/components/campaign/shell/ai/CampaignAISidebar.tsx b/src/components/campaign/shell/ai/CampaignAISidebar.tsx index 87aec91e..59ca7039 100644 --- a/src/components/campaign/shell/ai/CampaignAISidebar.tsx +++ b/src/components/campaign/shell/ai/CampaignAISidebar.tsx @@ -3,52 +3,17 @@ import { Bot, X } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/Drawer' -import { useIsMobile } from '@/hooks/use-mobile' import { CampaignAIChat } from '@/components/campaign/shell/ai/CampaignAIChat' import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext' export const CampaignAISidebar = () => { const ctx = useAISidebar() - const isMobile = useIsMobile() if (!ctx) return null - const { open, setOpen, setOpenMobile } = ctx + const { setOpen } = ctx - // Mobile: full-screen Drawer (Panel is hidden on mobile via className) - if (isMobile) { - return ( - setOpenMobile(next)} showSwipeHandle> - - Sollinha — Assistente virtual -
-
-
- - Sollinha -
- -
- -
-
-
- ) - } - - // Desktop: rendered inside a react-resizable-panels Panel — sizing is handled by the panel return (
diff --git a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx index 5a9db719..cb1e93fe 100644 --- a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx +++ b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx @@ -1,23 +1,34 @@ 'use client' +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport, type AbstractChat, type ChatStatus, type UIMessage } from 'ai' import { createContext, useCallback, useContext, + useEffect, useMemo, + useRef, useState, type MutableRefObject, type ReactNode, } from 'react' import type { PanelImperativeHandle } from 'react-resizable-panels' +import { useIsMobileMeasured } from '@/hooks/use-mobile' + type AISidebarContextValue = { + /** The chat is showing — single source of truth across panel (desktop) and drawer (mobile). */ open: boolean - /** Opens/closes the desktop Panel AND toggles the mobile Drawer. */ + isMobile: boolean + /** The first matchMedia measurement has landed (false during the hydration frame). */ + measured: boolean + messages: UIMessage[] + status: ChatStatus + sendMessage: AbstractChat['sendMessage'] + /** Opens/closes the chat. The desktop Panel and the mobile Drawer react to `open`. */ setOpen: (open: boolean) => void - /** Opens the mobile Drawer only — does NOT expand the desktop Panel. */ - setOpenMobile: (open: boolean) => void - /** Toggles the desktop Panel. */ + /** Toggles the chat. */ toggle: () => void } @@ -31,29 +42,44 @@ export const CampaignAISidebarProvider = ({ children: ReactNode }) => { const [open, setOpen] = useState(false) + const { isMobile, measured } = useIsMobileMeasured() - const setOpenWithPanel = useCallback( - (next: boolean) => { - setOpen(next) - if (next) { - panelRef.current?.expand() - } else { - panelRef.current?.collapse() - } - }, - [panelRef], - ) + const chat = useChat({ + id: 'campaign-sollinha', + transport: useMemo(() => new DefaultChatTransport({ api: '/campanha/api/ai-chat' }), []), + }) - const setOpenMobile = useCallback((next: boolean) => { - setOpen(next) - // Don't touch the Panel — it's hidden on mobile - }, []) + // The chat has two surfaces keyed by viewport — the desktop panel and the + // mobile drawer — and both derive from the SAME `open` flag. Crossing the + // breakpoint therefore needs no migration of its own: the surface that renders + // just follows `open && viewport`. The only work is keeping `open` truthful: + // on the desktop settle the panel starts at its RRP default (25%) while `open` + // begins false — reconcile `open` to the panel's real visibility so a visible + // chat is "open" (makes close work and the drawer open on resize). A fresh + // mobile visit keeps `open` false (no spontaneous chat). + const settledRef = useRef(false) + useEffect(() => { + if (settledRef.current) return + settledRef.current = measured + if (measured && !isMobile && !panelRef.current?.isCollapsed()) { + setOpen(true) + } + }, [measured, isMobile, panelRef]) - const toggle = useCallback(() => setOpenWithPanel(!open), [open, setOpenWithPanel]) + const toggle = useCallback(() => setOpen(!open), [open]) const value = useMemo( - () => ({ open, setOpen: setOpenWithPanel, setOpenMobile, toggle }), - [open, setOpenWithPanel, setOpenMobile, toggle], + () => ({ + open, + isMobile, + measured, + messages: chat.messages, + status: chat.status, + sendMessage: chat.sendMessage, + setOpen, + toggle, + }), + [open, isMobile, measured, chat.messages, chat.status, chat.sendMessage, setOpen, toggle], ) return {children} diff --git a/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx b/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx index c73660ca..b61ef649 100644 --- a/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx +++ b/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx @@ -1,39 +1,91 @@ 'use client' -import { type ReactNode, useLayoutEffect, useRef } from 'react' -import { Group, Panel, Separator, usePanelRef } from 'react-resizable-panels' +import { useEffect, useLayoutEffect, useRef, type MutableRefObject, type ReactNode } from 'react' +import { + Group, + Panel, + Separator, + usePanelRef, + type PanelImperativeHandle, +} from 'react-resizable-panels' -import { CampaignAIFab } from '@/components/campaign/shell/ai/CampaignAIFab' -import { CampaignAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebar' -import { CampaignAISidebarProvider } from '@/components/campaign/shell/ai/CampaignAISidebarContext' -import { useIsMobile } from '@/hooks/use-mobile' import { CHAT_MIN_PX, getSavedChatWidthPx, resolveChatPanelWidthPx, saveChatWidthPx, } from '@/lib/sollinhaChatPanelWidth' +import { cn } from '@/lib/utils' + +import { CampaignAIDrawer } from '@/components/campaign/shell/ai/CampaignAIDrawer' +import { CampaignAIFab } from '@/components/campaign/shell/ai/CampaignAIFab' +import { CampaignAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebar' +import { + CampaignAISidebarProvider, + useAISidebar, +} from '@/components/campaign/shell/ai/CampaignAISidebarContext' const CHAT_DEFAULT_PCT = '25' const MAIN_MIN_PCT = '35' export const CampaignAISidebarShell = ({ children }: { children: ReactNode }) => { const panelRef = usePanelRef() - const groupRef = useRef(null) - const isMobile = useIsMobile() - // Once the user resizes the panel, a re-render across the mobile/desktop - // boundary (or a re-run of the sizing effect) must never override the - // size chosen live in this session. + return ( + + + {children} + + ) +} + +/** + * The chat Panel (and its Separator) stay mounted at every viewport — changing + * `react-resizable-panels`' panel count on breakpoint crossing forces a full + * page navigation in this app (B167). Its footprint is driven purely by CSS: a + * class on the Panel's OUTER element zeroes the flex space while `react-resizable-panels` + * would otherwise keep writing `display:flex` inline there. RRP routes its own + * className/style props to the nested content wrapper specifically so they + * cannot break the flex layout, so the class is toggled through `elementRef` + * (attached to the root div) and wins via `display:none !important` (see + * styles.css), surviving RRP's own async, viewport-driven re-layouts. The panel + * is deliberately NOT `collapsible` — RRP's auto-collapse of a `display:none` + * (0-width) panel left it collapsed on the desktop side of a crossing, which no + * expand could reliably undo. Hiding and showing are entirely CSS-driven, so the + * panel always keeps a real RRP size (default 25% / the width the user chose and + * B166 remembers) whenever it is visible. The mobile Drawer portals over the + * content as a Group sibling. + */ +const AISidebarSurfaces = ({ + panelRef, + children, +}: { + panelRef: MutableRefObject + children: ReactNode +}) => { + const { open, isMobile, measured } = useAISidebar()! + const groupRef = useRef(null) + const chatPanelElementRef = useRef(null) + + // B166: once the user resizes the panel, a re-render across the + // mobile/desktop boundary (or a re-run of the sizing effect) must never + // override the size chosen live in this session. const hasUserSizedRef = useRef(false) + // Before the first viewport measurement the render must not hide the panel + // (it would flash the main content at 100% then settle to 25% on desktop); + // the hydration frame is treated as desktop-with-chat-open, matching the + // panel's RRP default, and the settle reconciles `open` afterwards. + const chatVisible = measured ? !isMobile && open : true + // Apply the remembered — or the capped-default — width once the desktop // panel's layout has settled; re-applies if the device later crosses back // into desktop (a mobile-first load that grows to desktop still gets it). // The imperative `resize(px)` converts px→% from the group's measured // offsets, so it must not run during the first commit (transient values). + // (B166.) useLayoutEffect(() => { - if (isMobile) return + if (isMobile || !measured) return const frame = requestAnimationFrame(() => { const group = groupRef.current const panel = panelRef.current @@ -43,25 +95,45 @@ export const CampaignAISidebarShell = ({ children }: { children: ReactNode }) => panel.resize(resolveChatPanelWidthPx(groupWidth, getSavedChatWidthPx())) }) return () => cancelAnimationFrame(frame) - }, [isMobile, panelRef]) + }, [isMobile, measured, panelRef]) + + // The class carries `display:none !important`, which beats the `display:flex` + // RRP writes inline on the Panel element on every (incl. async resize) render + // — so the zeroed footprint survives RRP's own re-layouts. + useLayoutEffect(() => { + chatPanelElementRef.current?.classList.toggle('b167-ai-chat-hidden', !chatVisible) + }, [chatVisible]) + + // A keyboard user mid-conversation in the mobile drawer lands on the desktop + // panel when the window grows; move focus into the chat so the task survives + // the surface swap instead of dropping to . + const prevIsMobileRef = useRef(isMobile) + useEffect(() => { + const prev = prevIsMobileRef.current + prevIsMobileRef.current = isMobile + if (prev && !isMobile && open) { + chatPanelElementRef.current?.querySelector('textarea')?.focus() + } + }, [isMobile, open]) + + const onLayoutChanged = (_layout: unknown, meta: { isUserInteraction: boolean }) => { + if (!meta.isUserInteraction) return + const panel = panelRef.current + if (!panel || panel.isCollapsed()) return + const { inPixels } = panel.getSize() + if (inPixels < CHAT_MIN_PX) return + hasUserSizedRef.current = true + saveChatWidthPx(inPixels) + } return ( - - + <> { - if (!meta.isUserInteraction) return - const panel = panelRef.current - if (!panel || panel.isCollapsed()) return - const { inPixels } = panel.getSize() - if (inPixels < CHAT_MIN_PX) return - hasUserSizedRef.current = true - saveChatWidthPx(inPixels) - }} + onLayoutChanged={onLayoutChanged} > {/* Main content */} @@ -70,32 +142,36 @@ export const CampaignAISidebarShell = ({ children }: { children: ReactNode }) =>
- {/* Resize handle */} + {/* Resize handle — only meaningful while the chat panel is visible */} {/* Double-click resets the panel to its raw `defaultSize` (uncapped 25%); the product default is the capped min(25%, 360px), so the stale - reset affordance would reopen wider than allowed — disable it. + reset affordance would reopen wider than allowed — disable it. (B166.) */}
- {/* AI chat sidebar — collapsible, controlled by context */} + {/* AI chat sidebar — footprint hidden while the chat is closed or on mobile */} - + {chatVisible ? : null} - + + {/* Mobile chat surface — mounted outside the Group so the main content owns the full width */} + {isMobile ? : null} + ) } diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts index 4331d5c5..b40daf28 100644 --- a/src/hooks/use-mobile.ts +++ b/src/hooks/use-mobile.ts @@ -2,7 +2,7 @@ import * as React from 'react' const MOBILE_BREAKPOINT = 768 -export function useIsMobile() { +function useMobileMeasured(): boolean | undefined { const [isMobile, setIsMobile] = React.useState(undefined) React.useEffect(() => { @@ -15,5 +15,20 @@ export function useIsMobile() { return () => mql.removeEventListener('change', onChange) }, []) - return !!isMobile + return isMobile +} + +export function useIsMobile() { + return !!useMobileMeasured() +} + +/** + * Same viewport signal as `useIsMobile`, plus whether the first matchMedia + * measurement has landed. The pre-measurement frame is indistinguishable from a + * genuine desktop viewport, which is enough to break breakpoint-crossing + * migration logic (B167) — the hydration settle must not be treated as a resize. + */ +export function useIsMobileMeasured(): { isMobile: boolean; measured: boolean } { + const isMobile = useMobileMeasured() + return { isMobile: !!isMobile, measured: isMobile !== undefined } } diff --git a/tests/e2e/campaignAiChatResize.e2e.spec.ts b/tests/e2e/campaignAiChatResize.e2e.spec.ts new file mode 100644 index 00000000..6c40297c --- /dev/null +++ b/tests/e2e/campaignAiChatResize.e2e.spec.ts @@ -0,0 +1,187 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from './fixtures/campaignE2EFixtures.js' + +/** + * 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 user message is + * still recorded client-side, which is all these assertions need. + */ +const mockAiChat = (page: Page) => + page.route('**/campanha/api/ai-chat', async (route) => { + await route.fulfill({ + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + body: 'data: {"type":"text","text":"Resposta mockada da Sollinha."}\n\n', + }) + }) + +const MESSAGE = 'Mensagem que migra entre as superficies' + +const openChatAndSend = async (page: Page) => { + await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) + const input = page.getByRole('textbox', { name: 'Pergunte para a Sollinha...' }) + await input.fill(MESSAGE) + await input.press('Enter') + await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 }) +} + +/** + * Widths of the resizable panels in order: [main, chat]. Indexed defensively — + * under a cold dev compile only one panel may exist transiently; callers poll + * with `expect.poll` so a missing panel just means "keep waiting". + */ +const chatPanelWidth = (page: Page) => + page.evaluate(() => + [...document.querySelectorAll('[data-panel]')].map((panel) => + Math.round(panel.getBoundingClientRect().width), + ), + ) + +const chatPanelWidthAt = (page: Page, index: number) => + chatPanelWidth(page).then((widths) => widths[index] ?? 0) + +test.describe('B167 — chat Sollinha migra entre painel e drawer ao redimensionar', () => { + test.beforeEach(async ({ page }) => { + test.slow() + await mockAiChat(page) + await page.setViewportSize({ width: 1280, height: 800 }) + }) + + test('desktop aberto -> mobile: drawer abre com a conversa e sem coluna fantasma', async ({ + page, + campaign, + }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Chat Resize Coordenador'), + }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + await openChatAndSend(page) + + await page.setViewportSize({ width: 500, height: 800 }) + + const drawer = page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' }) + await expect(drawer).toBeVisible({ timeout: 20_000 }) + await expect(drawer.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 }) + // The drawer must STAY open past RRP's own re-layout on the crossing. + await expect(drawer).toBeVisible({ timeout: 20_000 }) + + // The chat Panel is hidden on mobile: no ghost column stealing space. + await expect + .poll(() => chatPanelWidthAt(page, 0)) + .toBeGreaterThanOrEqual(Math.round(500 * 0.95)) + }) + + test('mobile aberto -> desktop: painel abre com a conversa, sem reload', async ({ + page, + campaign, + }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Chat Resize Coordenador'), + }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + await openChatAndSend(page) + + await page.setViewportSize({ width: 500, height: 800 }) + await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toBeVisible({ + timeout: 20_000, + }) + + await page.setViewportSize({ width: 1280, height: 800 }) + + await expect(page.getByRole('dialog')).toHaveCount(0) + await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 }) + // The welcome state must be gone — the conversation followed, not a fresh chat. + await expect(page.getByText('Olá! Eu sou o Sollinha')).not.toBeVisible() + // The panel is visibly expanded, not a 0-width sliver. + await expect.poll(() => chatPanelWidthAt(page, 1)).toBeGreaterThan(200) + }) + + test('chat fechado permanece fechado ao cruzar a borda nas duas direções', async ({ + page, + campaign, + }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Chat Resize Coordenador'), + }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + // Desktop opens the chat at load (as before); close it so it is "fechado". + await page + .getByRole('button', { name: 'Fechar', exact: true }) + .filter({ visible: true }) + .click() + // Closing hides the desktop panel's footprint. + await expect.poll(() => chatPanelWidthAt(page, 1)).toBeLessThan(2) + + await page.setViewportSize({ width: 500, height: 800 }) + await expect(page.getByRole('dialog')).toHaveCount(0) + + await page.setViewportSize({ width: 1280, height: 800 }) + await expect(page.getByRole('dialog')).toHaveCount(0) + // Still closed on desktop: no conversation surface, panel still hidden. + await expect.poll(() => chatPanelWidthAt(page, 1)).toBeLessThan(2) + }) + + test('visita mobile nova começa com o chat fechado (FAB é o convite)', async ({ + page, + campaign, + }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Chat Resize Coordenador'), + }) + await campaign.login(page, user.email!, user.password) + + // Carga direto em mobile: sem drawer espontâneo, só o FAB. + await page.setViewportSize({ width: 500, height: 800 }) + await page.goto('/campanha') + await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Sollinha — Assistente virtual' })).toBeVisible({ + timeout: 20_000, + }) + + // Crescer para desktop continua fechado (o painel não “renasce”). + await page.setViewportSize({ width: 1280, height: 800 }) + await expect(page.getByRole('dialog')).toHaveCount(0) + await expect.poll(() => chatPanelWidthAt(page, 1)).toBeLessThan(2) + }) + + test('conversa sobrevive à navegação interna de /campanha', async ({ page, campaign }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Chat Resize Coordenador'), + }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + await openChatAndSend(page) + + await page.getByRole('link', { name: 'Municípios' }).click() + await expect(page).toHaveURL(/\/campanha\/municipios/) + + await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 }) + await expect(page.getByText('Olá! Eu sou o Sollinha')).not.toBeVisible() + }) + + test('nova aba recomeça a conversa vazia', async ({ page, context, campaign }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Chat Resize Coordenador'), + }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + await openChatAndSend(page) + + const secondTab = await context.newPage() + await secondTab.setViewportSize({ width: 1280, height: 800 }) + await secondTab.goto('/campanha') + + await expect(secondTab.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) + await expect(secondTab.getByText(MESSAGE)).toHaveCount(0) + await secondTab.close() + }) +}) From cfb27296f623b346b1e2392d8968ec797145143a Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 8 Aug 2026 18:11:09 -0300 Subject: [PATCH 2/3] test(campaignActivity): fecha o chat antes do passo mobile da agenda (B167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B167 migra um chat aberto no painel desktop para o drawer full-screen ao encolher a viewport — o drawer cobriria o calendário no passo mobile deste spec. Fecha o chat antes do resize para garantir a superfície de teste. --- tests/e2e/campaignActivity.e2e.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index e3544f8a..fc37c9c7 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -125,6 +125,13 @@ test.describe('Agenda — calendário operacional', () => { ) await expect(page.getByText(title, { exact: true })).toBeVisible() + // B167: when the viewport shrinks to mobile, a chat that was open on the + // desktop panel migrates to the open full-screen drawer — which would cover + // the agenda. Close it first so this mobile-layout step is unobstructed. + await page + .getByRole('button', { name: 'Fechar', exact: true }) + .filter({ visible: true }) + .click() await page.setViewportSize({ width: 390, height: 844 }) await page.getByRole('tab', { name: /lista/i }).click() await expect(page.getByText(title, { exact: true })).toBeVisible() From c1bedcf83875cd9275c3d0fdfee2cdec0174a7d7 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 8 Aug 2026 18:29:42 -0300 Subject: [PATCH 3/3] =?UTF-8?q?test(campaignMunicipalities):=20torna=20o?= =?UTF-8?q?=20assert=20Otimista:=205.000=20determin=C3=ADstico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O getByText sem índice também casa com o span sr-only do hover-preview quando uma linha está hovered — strict-mode violation flaky no CI. .nth(0) fixa no resumo da célula. --- tests/e2e/campaignMunicipalities.e2e.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 126c4897..9461e6d8 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -124,7 +124,10 @@ test.describe('Municípios — jornadas por papel', () => { // (visually a hover-only preview otherwise), so assert against that // unique, unambiguous string rather than a bare "5.000" substring — // that also matches the (opacity-0 but DOM-visible) hover-preview span. - await expect(page.getByText('Otimista: 5.000')).toBeVisible() + // `.nth(0)`: the cell's sr-only summary is first in DOM; the preview span + // duplicates the text when a row is hovered, making a bare getByText a + // strict-mode violation (observed flaky on CI). + await expect(page.getByText('Otimista: 5.000').nth(0)).toBeVisible() // B145: assessoria left the detail hero; it still appears on the dossiê tab. await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=dossie`)