feat(B166): largura padrão do chat Sollinha com teto de 360 px e resize livre persistido

This commit is contained in:
Francisco Solla 2026-08-08 15:31:54 -03:00
parent ec0b109b5a
commit 2ca1afa6a7
5 changed files with 360 additions and 5 deletions

View file

@ -0,0 +1,73 @@
# Impl: B166 — Largura padrão do chat Sollinha com teto no desktop
Status: aprovado
Atualizado em: 2026-08-08
Issue: #414
Intenção: docs/plans/largura-padrao-chat-sollinha.md
Appetite restante: ~0,25–0,5 dia eng (mantido; sem migration)
## Leitura da intenção
- **Outcome:** no desktop de `/campanha`, o chat Sollinha abre — sozinho (carregamento) ou pelo botão do header — com `min(25% da janela, 360 px)` quando não há tamanho salvo; depois de aberto o resize é livre (sem teto superior, piso ~280 px mantido); o último tamanho escolhido pelo usuário é lembrado nas próximas aberturas; mobile (drawer full-screen) e demais superfícies intactos.
- **O que NÃO negociar:** teto de 360 px vale **só** para a abertura padrão (sem tamanho salvo); resize do usuário **sem máximo**; persistir **só o tamanho** (não as conversas); piso ~280 px; mobile drawer intacto.
- **O que reavaliar:** a “Direção no codebase” apontava `CampaignAISidebarShell.tsx` (Panel: `defaultSize="25"`, `minSize="280px"`, `maxSize="50"`, `collapsible`) — **confirmado como dono**. O mecanismo de persistência era aberto (“o executor escolhe”) — escolho persistência **própria em px** no `localStorage`, não `useDefaultLayout` do `react-resizable-panels` (razões abaixo).
## Abordagem recomendada
```mermaid
flowchart LR
A[CampaignAISidebarShell] -->|Group elementRef + onLayoutChanged isUserInteraction| B[save px → localStorage]
A -->|useIsMobile + resize(): PanelImperativeHandle| C[open size = saved ?? min 25%,360]
C --> D[remove maxSize=50 → resize livre]
B --> E[próxima abertura restaura px]
```
**Opções consideradas:** A | B | C | D
**Recomendação:** **A** — persistência própria em **px** + aplicação pontual no `useLayoutEffect`, porque casa com o aceite em unidades reais (px), persiste **apenas o tamanho** (hoje a abertura sozinha em `/campanha` é parte do comportamento — não se muda), e expressa o default `min(25%, 360 px)` sem depender de proporções do layout.
**Rejeitadas:**
- **B — `useDefaultLayout` do `react-resizable-panels`** (persistência de layout por %, `onlySaveAfterUserInteractions`): persiste o layout **inteiro, incluindo collapse/expand** → um usuário que fechou o chat o encontraria fechado no próximo load, o que muda o “abre sozinho ao carregar `/campanha`” que a intenção descreve como comportamento atual a preservar; além disso, o default `min(25%, 360)` exigiria medição do grupo de qualquer forma, e salvar % re-escala com a janela em vez de lembrar o tamanho em px escolhido.
- **C — teto via CSS (`max-width`/cap no Panel)**: travaria **também o resize do usuário** (produto diz “sem teto depois de aberto”) e briga com a matemática flex do library.
- **D — persistir % em vez de px**: drift com largura da janela; o aceite é explícito em px (“480–640 px hoje”, “teto de 360 px”, “o tamanho escolhido é lembrado”).
### Componentes / mudanças
- **`src/lib/sollinhaChatPanelWidth.ts`** (novo — puro, client-safe; referencia `campaignLastActedMunicipality.ts`): storage key `teoq:campaign:sollinha-chat-width-px`, leitura/escrita seguras (guard `typeof window`, try/catch private-mode), e a função pura `resolveChatPanelWidthPx(groupWidthPx, savedPx)` → `savedPx` válido (clampeado a `[CHAT_MIN_PX, groupWidthPx]`) **ou** `min(25% × groupWidth, 360 px)` (também clampeado). Move `CHAT_MIN_PX` (280) da shell para cá (fonte única; `minSize` do Panel e clamp do resolver passam a compartilhar).
- **`src/components/campaign/shell/ai/CampaignAISidebarShell.tsx`** (editar — dono do Group/Panel):
- Chat `Panel`: **remove `maxSize="50"`** (resize livre até o limite natural), mantém `minSize={CHAT_MIN_PX}`, `collapsible`, `collapsedSize={0}`, `defaultSize={CHAT_DEFAULT_PCT}` como valor inicial do library.
- `Group`: adiciona `elementRef` (medir largura do grupo) e `onLayoutChanged` — quando `meta.isUserInteraction` e o painel **não** estiver `isCollapsed()` e `inPixels >= CHAT_MIN_PX`, grava `Math.round(inPixels)` no storage.
- `useLayoutEffect` (client), gate por `useIsMobile()` + guardas: aplica `resolveChatPanelWidthPx(...)` via `panelRef.current.resize(px)` **uma vez** quando o painel está visível no desktop e **não** está colapsado e o usuário ainda **não** mexeu na sessão (ref `hasUserSized`). Re-executa só quando o eixo mobile→desktop cruza (load mobile-first cresce para desktop), para o default capado valer também aí.
- Separator/resize handle: intocado (já `md:flex`, existe).
- **Migration:** sem migration (zero schema).
- **Access / Consent:** nenhum (client-only; sem chave nova).
- **UI:** Impeccable **B** — encaixe no comportamento de abertura (sem redesign visual; o handle de resize já existe); shape→craft→critique→polish resumem-se a verificar o comportamento real em dev.
### Dados → forma (se aplicável)
- N/A — nenhum número novo apresentado; é ajuste de largura de painel.
## Fases verificáveis
1. **Tracer / server+schema:** nada de schema. Implementar `src/lib/sollinhaChatPanelWidth.ts` + unit spec (`tests/unit/sollinhaChatPanelWidth.unit.spec.ts`, espelhando `campaignLastActedMunicipality.unit.spec.ts`: leitura inválida/not-json, gravação, clear, e casos do `resolveChatPanelWidthPx` — grupo 1920 → default 360; grupo 1280 → 320; salvo 520 → 520 mesmo acima de 360; salvo longo clampeado ao grupo; salvo inválido → default).
2. **UI/wire:** editar `CampaignAISidebarShell.tsx` (remover `maxSize`, medir + aplicar, salvar em `onLayoutChanged`). Verificar em `pnpm dev` (porta 3266): abertura fresca em 1920 ≤360 px; arrastar para 520 px → reload restaura 520; fechar/reabrir mantém o da sessão; piso 280; mobile drawer intacto; painel colapsado (fechado pelo X) não reabre sozinho.
3. **Gates:** `pnpm gate:fast` na iteração; `pnpm push` na entrega. E2e `campaignMunicipalityResponsiveColumns` (condicional do `Fechar` no chat) deve continuar verde — comportamento de “abrir/colapsar muda colunas” inalterado (a largura default em 1600 px segue < 360 px). Avaliar se vale um e2e dedicado de persistência (barato em Playwright) — decidir na execução conforme appetite.
## Rabbit holes / Não escopo (engenharia)
- Persistir **conversas** do chat — corte explícito da intenção (sessão nova a cada abertura).
- Mexer no piso (~280 px) ou na semântica mobile drawer — fora de escopo.
- Usar `useDefaultLayout`/persistência de % do library — rejeitado (mudaria aberto/fechado e re-escalaria).
- Não criar hook intermediário pass-through; a lógica cabe no shell + lib puro (depth check: reusa `panelRef`/`useIsMobile` existentes).
## Riscos e mitigação
- **Semântica do `resize()`/`onLayoutChanged` do library (4.12.2):** `meta.isUserInteraction` discrimina drag de usuário vs imperativo (confirmado no bundle); `resize` valida/clampeia contra min/max. Mitigação: salvar só quando `!isCollapsed()` (arrastar até colapsar não grava 0); `isCollapsed()+hasUserSized` nas guardas para **nunca** forçar abrir nem sobrescrever escolha da sessão.
- **Cruzamento mobile↔desktop no meio da sessão:** efeito chaveado em `useIsMobile()`; `resize()` sobre painel `hidden` (mobile) é inerte, e as guardas evitam reabrir painel colapsado.
- **Garbage no storage:** parse/validação na leitura (espelha padrão `campaignLastActedMunicipality`/`recentVisits`), retorna default.
- **e2e existente:** largura default em viewport 1600 continua < 360 px (grupo ≈ 1320 px → 25% ≈ 330 px) → sem mudança de estágio de coluna.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (default capado + resize livre + persistência de tamanho; mobile intacto)
- [ ] Invariantes AGENTS/engineering-standards (sem Local API/overrideAccess; client-only; identificadores em inglês)
- [ ] Testes de domínio previstos (unit do resolver/storage; e2e existente segue verde)

View file

@ -1,23 +1,68 @@
'use client'
import { type ReactNode } from 'react'
import { type ReactNode, useLayoutEffect, useRef } from 'react'
import { Group, Panel, Separator, usePanelRef } 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'
const CHAT_MIN_PX = '280px'
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.
const hasUserSizedRef = useRef(false)
// 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).
useLayoutEffect(() => {
if (isMobile) return
const frame = requestAnimationFrame(() => {
const group = groupRef.current
const panel = panelRef.current
if (!group || !panel || hasUserSizedRef.current || panel.isCollapsed()) return
const groupWidth = group.getBoundingClientRect().width
if (groupWidth <= 0) return
panel.resize(resolveChatPanelWidthPx(groupWidth, getSavedChatWidthPx()))
})
return () => cancelAnimationFrame(frame)
}, [isMobile, panelRef])
return (
<CampaignAISidebarProvider panelRef={panelRef}>
<CampaignAIFab />
<Group orientation="horizontal" className="min-h-0 flex-1">
<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)
}}
>
{/* Main content */}
<Panel defaultSize="75" minSize={MAIN_MIN_PCT}>
<div className="[contain:layout_paint] h-full">
@ -26,16 +71,24 @@ export const CampaignAISidebarShell = ({ children }: { children: ReactNode }) =>
</Panel>
{/* Resize handle */}
<Separator 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">
{/*
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.
*/}
<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"
>
<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 */}
<Panel
id="ai-chat-panel"
panelRef={panelRef}
defaultSize={CHAT_DEFAULT_PCT}
minSize={CHAT_MIN_PX}
maxSize="50"
collapsible
collapsedSize={0}
className="hidden md:block"

View file

@ -0,0 +1,58 @@
/** Device-local Sollinha chat panel width (px), remembered across opens. */
export const SOLLINHA_CHAT_WIDTH_STORAGE_KEY = 'teqo:campaign:sollinha-chat-width-px'
/** Floor of the chat panel — reuse it wherever the panel width is constrained. */
export const CHAT_MIN_PX = 280
/** Cap of the default open width (`min(25% of the group, this)`). */
export const CHAT_DEFAULT_MAX_PX = 360
const isStoredWidth = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value) && value > 0
export const getSavedChatWidthPx = (): number | null => {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY)
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
return isStoredWidth(parsed) ? parsed : null
} catch {
return null
}
}
export const saveChatWidthPx = (widthPx: number): void => {
if (typeof window === 'undefined') return
const rounded = Math.round(widthPx)
if (rounded <= 0) return
try {
localStorage.setItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY, JSON.stringify(rounded))
} catch {
// Ignore quota / private-mode failures.
}
}
export const clearSavedChatWidthPx = (): void => {
if (typeof window === 'undefined') return
try {
localStorage.removeItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY)
} catch {
// Ignore private-mode failures.
}
}
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), Math.max(min, max))
/** Default open width: 25% of the group, capped at `CHAT_DEFAULT_MAX_PX`. */
export const defaultChatWidthPx = (groupWidthPx: number): number =>
clamp(Math.round(Math.min(0.25 * groupWidthPx, CHAT_DEFAULT_MAX_PX)), CHAT_MIN_PX, groupWidthPx)
/**
* Width for the next open: the remembered choice wins (even above the cap),
* otherwise the capped 25% default. Always bounded to `[CHAT_MIN_PX, group]`.
*/
export const resolveChatPanelWidthPx = (groupWidthPx: number, savedPx: number | null): number =>
savedPx === null ? defaultChatWidthPx(groupWidthPx) : clamp(savedPx, CHAT_MIN_PX, groupWidthPx)

View file

@ -0,0 +1,90 @@
import type { Locator, Page } from '@playwright/test'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
type ResponsiveCampaignFixture = {
fixtures: {
createCampaignUser: (
role: 'advisor' | 'coordinator',
input: { name: string },
) => Promise<{ email?: string | null; password: string }>
value: (prefix: string) => string
}
login: (page: Page, identifier: string, password: string) => Promise<void>
}
const loginAs = async (page: Page, campaign: ResponsiveCampaignFixture) => {
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Sollinha Largura'),
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/municipios')
await page.waitForLoadState('networkidle')
}
const chatPanel = (page: Page) => page.locator('#ai-chat-panel')
const chatSeparator = (page: Page) => page.locator('#campaign-ai-shell [role="separator"]')
const panelWidth = (panel: Locator) => panel.evaluate((element) => element.clientWidth)
const dragSeparatorBy = async (page: Page, deltaX: number) => {
const separator = chatSeparator(page)
const box = (await separator.boundingBox())!
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 6 })
await page.mouse.up()
}
test.describe('B166 — largura padrão do chat Sollinha com teto no desktop', () => {
test('opens capped at 360px, grows freely and remembers the resized width', async ({
campaign,
page,
}) => {
test.slow()
// Wide desktop: 25% of the group exceeds 360px, so the cap must bite. The
// sidebar is open by default on a fresh context.
await page.setViewportSize({ width: 1920, height: 1080 })
await loginAs(page, campaign)
const panel = chatPanel(page)
await expect(panel).toBeVisible()
// The panel first paints at the raw 25% default and is capped one frame
// later by the sizing effect — poll until the cap lands (also proves the
// default open never exceeds 360px, acceptance 1).
await expect.poll(() => panelWidth(panel)).toBeLessThanOrEqual(360)
const defaultWidth = await panelWidth(panel)
expect(defaultWidth).toBeGreaterThanOrEqual(280)
// Drag the separator to the left: chat grows beyond the 360px cap — the
// cap applies only to the default open, never to the user resize.
await dragSeparatorBy(page, -220)
await expect.poll(() => panelWidth(panel)).toBeGreaterThan(360)
const resizedWidth = await panelWidth(panel)
// The chosen size was saved locally: a page reload reopens at it, not at
// the capped default.
await page.reload()
await expect(panel).toBeVisible()
await expect.poll(() => panelWidth(panel)).toBeGreaterThan(360)
const restoredWidth = await panelWidth(panel)
expect(Math.abs(restoredWidth - resizedWidth)).toBeLessThanOrEqual(4)
// Reopening via the header button (acceptance 1 — "pelo botão") restores
// the same remembered width instead of a fresh cap.
await page
.getByRole('button', { name: 'Fechar', exact: true })
.filter({ visible: true })
.click()
await expect.poll(() => panelWidth(panel)).toBeLessThan(200)
await page
.getByRole('button', { name: 'Sollinha — Assistente virtual' })
.filter({ visible: true })
.click()
await expect.poll(() => panelWidth(panel)).toBeGreaterThan(360)
const buttonOpenWidth = await panelWidth(panel)
expect(Math.abs(buttonOpenWidth - restoredWidth)).toBeLessThanOrEqual(4)
})
})

View file

@ -0,0 +1,81 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
CHAT_DEFAULT_MAX_PX,
CHAT_MIN_PX,
clearSavedChatWidthPx,
defaultChatWidthPx,
getSavedChatWidthPx,
resolveChatPanelWidthPx,
saveChatWidthPx,
SOLLINHA_CHAT_WIDTH_STORAGE_KEY,
} from '@/lib/sollinhaChatPanelWidth'
describe('sollinhaChatPanelWidth storage', () => {
afterEach(() => {
localStorage.clear()
})
it('returns null when storage is missing or invalid', () => {
expect(getSavedChatWidthPx()).toBeNull()
localStorage.setItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY, 'not-json')
expect(getSavedChatWidthPx()).toBeNull()
localStorage.setItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY, JSON.stringify({ px: 520 }))
expect(getSavedChatWidthPx()).toBeNull()
localStorage.setItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY, JSON.stringify(-50))
expect(getSavedChatWidthPx()).toBeNull()
localStorage.setItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY, JSON.stringify(NaN))
expect(getSavedChatWidthPx()).toBeNull()
})
it('records and reads the saved width (rounded)', () => {
saveChatWidthPx(520.4)
expect(getSavedChatWidthPx()).toBe(520)
saveChatWidthPx(300)
expect(getSavedChatWidthPx()).toBe(300)
})
it('clears the saved width', () => {
saveChatWidthPx(360)
clearSavedChatWidthPx()
expect(getSavedChatWidthPx()).toBeNull()
expect(localStorage.getItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY)).toBeNull()
})
})
describe('defaultChatWidthPx', () => {
it('caps the 25% default at CHAT_DEFAULT_MAX_PX', () => {
expect(defaultChatWidthPx(1920)).toBe(CHAT_DEFAULT_MAX_PX)
expect(defaultChatWidthPx(1920)).toBe(360)
expect(defaultChatWidthPx(1440)).toBe(360)
})
it('stays at 25% when that is below the cap', () => {
expect(defaultChatWidthPx(1280)).toBe(320)
expect(defaultChatWidthPx(1200)).toBe(300)
})
it('never goes below CHAT_MIN_PX', () => {
expect(defaultChatWidthPx(1000)).toBe(CHAT_MIN_PX)
expect(defaultChatWidthPx(700)).toBe(CHAT_MIN_PX)
})
})
describe('resolveChatPanelWidthPx', () => {
it('uses the capped 25% default when nothing is saved', () => {
expect(resolveChatPanelWidthPx(1920, null)).toBe(360)
expect(resolveChatPanelWidthPx(1280, null)).toBe(320)
})
it('lets a saved choice win even above the cap', () => {
expect(resolveChatPanelWidthPx(1920, 520)).toBe(520)
})
it('clamps a saved choice to the group width', () => {
expect(resolveChatPanelWidthPx(500, 900)).toBe(500)
})
it('clamps a saved choice below the floor up to CHAT_MIN_PX', () => {
expect(resolveChatPanelWidthPx(1920, 150)).toBe(CHAT_MIN_PX)
})
})