diff --git a/docs/plans/largura-padrao-chat-sollinha-impl.md b/docs/plans/largura-padrao-chat-sollinha-impl.md new file mode 100644 index 00000000..29e6509f --- /dev/null +++ b/docs/plans/largura-padrao-chat-sollinha-impl.md @@ -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) diff --git a/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx b/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx index 2b5dc74b..c73660ca 100644 --- a/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx +++ b/src/components/campaign/shell/ai/CampaignAISidebarShell.tsx @@ -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(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 ( - + { + 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 */}
@@ -26,16 +71,24 @@ export const CampaignAISidebarShell = ({ children }: { children: ReactNode }) => {/* Resize handle */} - + {/* + 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. + */} +
{/* AI chat sidebar — collapsible, controlled by context */} + 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) diff --git a/tests/e2e/campaignSollinhaWidth.e2e.spec.ts b/tests/e2e/campaignSollinhaWidth.e2e.spec.ts new file mode 100644 index 00000000..0aa6df28 --- /dev/null +++ b/tests/e2e/campaignSollinhaWidth.e2e.spec.ts @@ -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 +} + +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) + }) +}) diff --git a/tests/unit/sollinhaChatPanelWidth.unit.spec.ts b/tests/unit/sollinhaChatPanelWidth.unit.spec.ts new file mode 100644 index 00000000..71ce2233 --- /dev/null +++ b/tests/unit/sollinhaChatPanelWidth.unit.spec.ts @@ -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) + }) +})