diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index 43a0f53c..f78fc0cc 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -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.
diff --git a/docs/plans/crash-busca-bottom-drawer.md b/docs/plans/crash-busca-bottom-drawer.md
index 240b0472..60942981 100644
--- a/docs/plans/crash-busca-bottom-drawer.md
+++ b/docs/plans/crash-busca-bottom-drawer.md
@@ -1,14 +1,20 @@
# Crash — busca no bottom drawer (focus / digitação)
-Status: registrado
+Status: done
Atualizado em: 2026-08-01
-Issue: #129 (file-miss #133)
+Issue: #129 (file-miss plan #133 · delivery #135)
Priority: P0
Model: cursor-grok-4.5-medium
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)_
+- _(nenhuma — root cause fechada no repro unit; fix as-built acima)_
## Abordagem proposta
diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx
index f0dbcd7e..e23233e9 100644
--- a/src/app/(campaign)/campanha/(app)/layout.tsx
+++ b/src/app/(campaign)/campanha/(app)/layout.tsx
@@ -86,9 +86,16 @@ export default async function CampaignAppLayout({ children }: { children: React.
-
- {children}
-
+ {/*
+ 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).
+ */}
+
+ {children}
+
{biometricEnrollment ? : null}
diff --git a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx
index 2ff36fa9..1b0a876b 100644
--- a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx
+++ b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx
@@ -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(
-
- {ui}
- ,
+
+
+ {ui}
+
+ ,
)
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()
+
+ 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()
+
+ 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,
diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts
index 432ccc9a..7789c165 100644
--- a/tests/unit/codebaseConventions.unit.spec.ts
+++ b/tests/unit/codebaseConventions.unit.spec.ts
@@ -667,3 +667,22 @@ describe('public site metadata global access', () => {
expect(offenders, 'import and call resolveSiteMetadata from @/utilities/seo').toEqual([])
})
})
+
+describe('campaign TooltipProvider wraps quick-actions chrome (B102)', () => {
+ // B91/B100 mount the drawer as a sibling of the scrollport inside
+ // CampaignAppScrollChrome. Nesting TooltipProvider only around {children}
+ // left focus→suggest priority hits (CampaignHoverTooltip) without a
+ // provider and crashed the page.
+ it('lifts TooltipProvider around CampaignAppScrollChrome in the app layout', () => {
+ const layoutPath = resolve(repoRoot, 'src/app/(campaign)/campanha/(app)/layout.tsx')
+ expect(existsSync(layoutPath)).toBe(true)
+ const source = readFileSync(layoutPath, 'utf8')
+
+ expect(source).toMatch(
+ /]*>\s*\s*<\/TooltipProvider>/,
+ )
+ expect(source).not.toMatch(
+ /]*>\s*\s*<\/CampaignAppScrollChrome>/,
+ )
+ })
+})