Merge pull request #434 from fsolla/B167-migrar-o-chat-sollinha-entre-painel-e-drawer-ao-redimen
B167 — Migrar o chat Sollinha entre painel e drawer ao redimensionar
This commit is contained in:
commit
a1a0e3701d
12 changed files with 524 additions and 106 deletions
87
docs/plans/migrar-chat-painel-drawer-resize-impl.md
Normal file
87
docs/plans/migrar-chat-painel-drawer-resize-impl.md
Normal file
|
|
@ -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<br/>open · isMobile · measured · useChat · reconcile/sync] --> S[CampaignAISidebarShell]
|
||||
S -->|desktop| G[Group<br/>Panel main + Separator + Panel chat sempre montados]
|
||||
S -->|mobile| D[CampaignAIDrawer<br/>portal, fora do Group]
|
||||
G --> C[CampaignAISidebar<br/>desktop surface]
|
||||
D --> C2[CampaignAIDrawer + header]
|
||||
C --> CH[CampaignAIChat<br/>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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={cn('grid min-h-0 grid-rows-[1fr_auto]', className)}>
|
||||
{/* Messages area */}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="min-h-0 overflow-y-auto px-4 py-3 [&::-webkit-scrollbar]:hidden [scrollbar-width:none]"
|
||||
>
|
||||
<div className="min-h-0 overflow-y-auto px-4 py-3 [&::-webkit-scrollbar]:hidden [scrollbar-width:none]">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center">
|
||||
<div className="max-w-xs space-y-3">
|
||||
|
|
|
|||
45
src/components/campaign/shell/ai/CampaignAIDrawer.tsx
Normal file
45
src/components/campaign/shell/ai/CampaignAIDrawer.tsx
Normal file
|
|
@ -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 (
|
||||
<Drawer open={open} onOpenChange={(next) => setOpen(next)} showSwipeHandle>
|
||||
<DrawerContent
|
||||
id="CampaignAISidebar"
|
||||
className="h-[90dvh] border-t border-border bg-background text-foreground"
|
||||
>
|
||||
<DrawerTitle className="sr-only">Sollinha — Assistente virtual</DrawerTitle>
|
||||
<div className="grid h-full grid-rows-[auto_1fr]">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Bot className="size-4" />
|
||||
Sollinha
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CampaignAIChat />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)}
|
||||
>
|
||||
<Bot className="size-4" aria-hidden />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Drawer open={open} onOpenChange={(next) => setOpenMobile(next)} showSwipeHandle>
|
||||
<DrawerContent
|
||||
id="CampaignAISidebar"
|
||||
className="h-[90dvh] border-t border-border bg-background text-foreground"
|
||||
>
|
||||
<DrawerTitle className="sr-only">Sollinha — Assistente virtual</DrawerTitle>
|
||||
<div className="grid h-full grid-rows-[auto_1fr]">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Bot className="size-4" />
|
||||
Sollinha
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setOpenMobile(false)}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CampaignAIChat />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop: rendered inside a react-resizable-panels Panel — sizing is handled by the panel
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[auto_1fr] border-l border-border bg-background">
|
||||
<div className="flex min-h-11 shrink-0 items-center justify-between border-b border-border px-4">
|
||||
|
|
|
|||
|
|
@ -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<UIMessage>['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 <AISidebarContext.Provider value={value}>{children}</AISidebarContext.Provider>
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>(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 (
|
||||
<CampaignAISidebarProvider panelRef={panelRef}>
|
||||
<CampaignAIFab />
|
||||
<AISidebarSurfaces panelRef={panelRef}>{children}</AISidebarSurfaces>
|
||||
</CampaignAISidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PanelImperativeHandle | null>
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const { open, isMobile, measured } = useAISidebar()!
|
||||
const groupRef = useRef<HTMLDivElement | null>(null)
|
||||
const chatPanelElementRef = useRef<HTMLDivElement | null>(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 <body>.
|
||||
const prevIsMobileRef = useRef(isMobile)
|
||||
useEffect(() => {
|
||||
const prev = prevIsMobileRef.current
|
||||
prevIsMobileRef.current = isMobile
|
||||
if (prev && !isMobile && open) {
|
||||
chatPanelElementRef.current?.querySelector<HTMLTextAreaElement>('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 (
|
||||
<CampaignAISidebarProvider panelRef={panelRef}>
|
||||
<CampaignAIFab />
|
||||
<>
|
||||
<Group
|
||||
id="campaign-ai-shell"
|
||||
orientation="horizontal"
|
||||
className="min-h-0 flex-1"
|
||||
elementRef={groupRef}
|
||||
onLayoutChanged={(_layout, meta) => {
|
||||
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 */}
|
||||
<Panel defaultSize="75" minSize={MAIN_MIN_PCT}>
|
||||
|
|
@ -70,32 +142,36 @@ export const CampaignAISidebarShell = ({ children }: { children: ReactNode }) =>
|
|||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* 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.)
|
||||
*/}
|
||||
<Separator
|
||||
disableDoubleClick
|
||||
className="hidden w-3 shrink-0 cursor-col-resize transition-colors focus:outline-none md:flex items-center justify-center group/separator data-[resize-handle-active]:bg-primary/20"
|
||||
className={cn(
|
||||
'w-3 shrink-0 cursor-col-resize items-center justify-center transition-colors focus:outline-none group/separator data-[resize-handle-active]:bg-primary/20',
|
||||
chatVisible ? 'flex' : 'hidden',
|
||||
)}
|
||||
>
|
||||
<div className="h-full w-px bg-border transition-colors group-hover/separator:bg-primary/50 group-data-[resize-handle-active]/separator:bg-primary/50" />
|
||||
</Separator>
|
||||
|
||||
{/* AI chat sidebar — collapsible, controlled by context */}
|
||||
{/* AI chat sidebar — footprint hidden while the chat is closed or on mobile */}
|
||||
<Panel
|
||||
id="ai-chat-panel"
|
||||
panelRef={panelRef}
|
||||
elementRef={chatPanelElementRef}
|
||||
defaultSize={CHAT_DEFAULT_PCT}
|
||||
minSize={CHAT_MIN_PX}
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
className="hidden md:block"
|
||||
>
|
||||
<CampaignAISidebar />
|
||||
{chatVisible ? <CampaignAISidebar /> : null}
|
||||
</Panel>
|
||||
</Group>
|
||||
</CampaignAISidebarProvider>
|
||||
|
||||
{/* Mobile chat surface — mounted outside the Group so the main content owns the full width */}
|
||||
{isMobile ? <CampaignAIDrawer /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean | undefined>(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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
187
tests/e2e/campaignAiChatResize.e2e.spec.ts
Normal file
187
tests/e2e/campaignAiChatResize.e2e.spec.ts
Normal file
|
|
@ -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<HTMLElement>('[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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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`)
|
||||
|
|
|
|||
Loading…
Reference in a new issue