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>
+
+
+ )
+}
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>
-
-
- )
- }
-
- // 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/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()
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()
+ })
+})
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`)