B102 — fix drawer search crash on focus/type (TooltipProvider scope)

Lift TooltipProvider in the campaign app layout so it wraps
CampaignAppScrollChrome (scroll + quick-actions drawer). Focus→suggest
with priority-alta hits mounted CampaignHoverTooltip outside the old
provider and crashed the page. Unit pins cover focus→suggest and type→search.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-01 17:56:51 +00:00
parent 313a041d5b
commit 653f04062b
No known key found for this signature in database
4 changed files with 109 additions and 11 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-01):** **B102** (Issue #129) — crash na busca do bottom drawer (focus/digitação): `TooltipProvider` do layout `(app)` envolvia só `{children}` e o drawer (irmão do scrollport) renderizava hits `priority:alta` via `CampaignHoverTooltip` sem provider. Fix: provider envolve `CampaignAppScrollChrome`; pins unit focus→suggest e type→search. Plano: [`crash-busca-bottom-drawer.md`](docs/plans/crash-busca-bottom-drawer.md).
**Recently resolved (2026-08-01):** **Pool pin `fast=false`** — `resolvePoolModel` agora envia `fast=false` em todo spawn cuja família Cloud anuncia o param (`composer-2.5`, `grok-4.5`, …). Antes o supervisor pedia só `{ id: "composer-2.5" }`; a API Create-Agent resolve o default da variante (`fast=true`) e o usage dashboard mostrava `composer-2.5-fast` em todos os workers do pool. Skills `model-selection` / `agent-pool` + plano do orquestrador atualizados.
**Recently resolved (2026-08-01):** **C8 F1–F2** (Issue #37) — freshness audit confirmou entrega em código desde 2026-07-19: locks advisory em batch (`acquireTextAdvisoryLocks`), bulk import com `.returning()` + leituras drizzle, overview derivando `total` de `totalDocs`, migration `pg_trgm`, guard `contactSearchQuery` (mín. 2 chars). Plano [`escala-dry-pos-c6.md`](docs/plans/escala-dry-pos-c6.md) fechado; follow-ups de filtros/forms permanecem no C9.

View file

@ -1,6 +1,6 @@
# Crash — busca no bottom drawer (focus / digitação)
Status: registrado
Status: em implementação
Atualizado em: 2026-08-01
Issue: #129 (file-miss #133)
Priority: P0
@ -9,6 +9,12 @@ Impeccable: B — `CampaignQuickActionsDrawer` + `CampaignGlobalSearchBody`
Appetite: ~0,5–1d eng; root-cause + fix + pin focus/type; sem migration
Responsável: —
## Freshness audit (2026-08-01)
- Arquivos citados existem; assinaturas de `useHomeSearch` / `useHomeSearchResultsState` / drawer intactas.
- Sem migration; deps soft B91/B100 done.
- **Root cause confirmada (repro unit):** `TooltipProvider` no layout `(app)` envolvia só `{children}` dentro de `CampaignAppScrollChrome`. O drawer monta como **irmão** do scrollport → fora do provider. Focus → `mode:suggest` → hits `priority:'alta'` → `MunicipalityPriorityIndicator` → `CampaignHoverTooltip` → throw ``Tooltip` must be used within `TooltipProvider`` → página cai. Início não crasha porque o drawer não monta lá e a busca vive sob o provider dos children. Digitar com hits prioritários crashava pelo mesmo motivo; o pin B100 só mudava query com payload vazio (sem flag).
## Design (Impeccable)
Âncoras: `PRODUCT.md` (Feel the action) · B91/B100 drawer search · tema `campaign`.
@ -66,10 +72,11 @@ Diagnóstico preliminar (código em `main`):
- **Fix mínimo no caminho focus→suggest→render; não redesenhar snap.** Geometria/gesto → **B105**. **Rejeitado:** reescrever busca só para o drawer.
- **File-miss obrigatório** com causa + PR culpado quando conhecido. **Rejeitado:** só Issue de feature sem harvest.
- **i18n:** ids existentes; sem copy nova.
- **Fix (as-built):** elevar `TooltipProvider` no layout `(app)` para envolver `CampaignAppScrollChrome` inteiro (scroll + drawer). Harness unit espelha isso.
## Questões em aberto
- **Causa raiz exata antes do claim?** **Opções:** A) fechar no `work-issue` com repro primeiro | B) bipartir plan/exec. **Recomendação:** **A** — appetite cabe; hypothese já estreita. _(assumido)_
- **Causa raiz exata antes do claim?** **Opções:** A) fechar no `work-issue` com repro primeiro | B) bipartir plan/exec. **Recomendação:** **A** — appetite cabe; hypothese já estreita. _(assumido — fechado no freshness/repro)_
## Abordagem proposta

View file

@ -86,9 +86,16 @@ export default async function CampaignAppLayout({ children }: { children: React.
<CampaignNotificationBellSlot user={user} />
</div>
</header>
<CampaignAppScrollChrome role={user.role}>
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
</CampaignAppScrollChrome>
{/*
Provider must wrap CampaignAppScrollChrome, not only page children:
the mobile quick-actions drawer (B91/B100) mounts as a sibling of
the scrollport and renders search hits with CampaignHoverTooltip
(priority flag). Nested only around {children} left focus→suggest
without a provider and crashed the page (B102).
*/}
<TooltipProvider delayDuration={300}>
<CampaignAppScrollChrome role={user.role}>{children}</CampaignAppScrollChrome>
</TooltipProvider>
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}

View file

@ -10,6 +10,8 @@ import {
CampaignQuickActionsSnapProvider,
useCampaignQuickActionsSnap,
} from '@/components/campaign/shell/CampaignQuickActionsSnapContext'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import {
QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX,
@ -26,9 +28,9 @@ vi.mock('next/navigation', () => ({
usePathname: () => '/campanha/municipios/foo',
}))
const idleSuggestPayload = {
status: 'success' as const,
resultKind: 'suggest' as const,
const idleSuggestPayload: HomeSearchSuccessResponse = {
status: 'success',
resultKind: 'suggest',
municipalities: [],
territories: [],
advisors: [],
@ -38,6 +40,26 @@ const idleSuggestPayload = {
demands: [],
}
/** Priority-alta hit — mounts MunicipalityPriorityIndicator → CampaignHoverTooltip. */
const suggestWithPriorityPayload: HomeSearchSuccessResponse = {
...idleSuggestPayload,
municipalities: [
{
kind: 'municipality',
slug: 'cairu',
name: 'Cairu',
region: 'Baixo Sul',
priority: 'alta',
votePosition2022: null,
},
],
}
const searchWithPriorityPayload: HomeSearchSuccessResponse = {
...suggestWithPriorityPayload,
resultKind: 'search',
}
const matchMediaMock = vi.fn()
beforeEach(() => {
@ -59,11 +81,17 @@ afterEach(() => {
vi.unstubAllGlobals()
})
/**
* Mirrors campaign (app) layout: TooltipProvider wraps chrome that includes the
* drawer (B102). Nested only around page children left focus→suggest crashing.
*/
const renderQuickActionsChrome = (ui: ReactNode) =>
render(
<CampaignQuickActionsSnapProvider>
<CampaignGlobalSearchProvider>{ui}</CampaignGlobalSearchProvider>
</CampaignQuickActionsSnapProvider>,
<TooltipProvider delayDuration={300}>
<CampaignQuickActionsSnapProvider>
<CampaignGlobalSearchProvider>{ui}</CampaignGlobalSearchProvider>
</CampaignQuickActionsSnapProvider>
</TooltipProvider>,
)
const SnapReadout = () => {
@ -134,6 +162,60 @@ describe('CampaignQuickActionsDrawer (B100)', () => {
})
})
it('focus posts suggest and renders priority hits without crashing (B102)', async () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: suggestWithPriorityPayload,
})
renderQuickActionsChrome(<CampaignQuickActionsDrawer actions={[]} />)
fireEvent.focus(screen.getByLabelText('Buscar na campanha'))
await waitFor(() => {
expect(postCampaignJson).toHaveBeenCalledWith(
'/campanha/home-search',
{ mode: 'suggest' },
expect.any(AbortSignal),
)
})
await waitFor(() => {
expect(screen.getByRole('region', { name: 'Sugestões' })).toBeTruthy()
expect(screen.getByText('Cairu')).toBeTruthy()
expect(screen.getByLabelText('Município prioritário')).toBeTruthy()
})
})
it('typing keeps search results with priority hits mounted (B102)', async () => {
vi.mocked(postCampaignJson).mockImplementation(async (_url, body) => {
if (typeof body === 'object' && body !== null && 'mode' in body && body.mode === 'search') {
return { ok: true, payload: searchWithPriorityPayload }
}
return { ok: true, payload: idleSuggestPayload }
})
renderQuickActionsChrome(<CampaignQuickActionsDrawer actions={[]} />)
const input = screen.getByLabelText('Buscar na campanha')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'ca' } })
await waitFor(() => {
expect(postCampaignJson).toHaveBeenCalledWith(
'/campanha/home-search',
{ mode: 'search', query: 'ca' },
expect.any(AbortSignal),
)
})
await waitFor(() => {
expect(screen.getByRole('region', { name: 'Municípios' })).toBeTruthy()
expect(screen.getByText('Cairu')).toBeTruthy()
expect(screen.getByLabelText('Município prioritário')).toBeTruthy()
})
})
it('toggles between dock and collapsed from the handle', () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,