feat: FlowPilot migration — Phase 1-9 + Phase 9 bug fixes + QA fixture harness #147
Reference in New Issue
Block a user
Delete Branch "feat/flowpilot-migration"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
FlowPilot migration — Phase 1-9 of the ResolutionAssist → FlowPilot rename, plus the conditional script-builder / template-match / inline-no-template / escalate-intercept / proposal-banner / resolution-note-preview surfaces from Phase 9.
This branch was merged with
main(which includes PR #141, PSA ticket management) so the two feature streams ship coherently. Both stacks coexist inAssistantChatPage.tsx.What's included
Phase 1-9 FlowPilot work (~79 commits):
.ai/(PROJECT_CONTEXT, CURRENT_TASK, HANDOFF, DECISIONS, SESSION_LOG)What we know)Bugs found + fixed during QA on this branch:
seed_test_users.pycrash oncancel_at_period_endNOT NULL (49c6c8f)875bd92)9330ce4)9330ce4)QA harness (commit
d68131a):backend/scripts/seed_phase9_qa_fixtures.py— pre-bakes 4 ai_sessions × 4 suggested_fixes covering the four backend states the AI orchestrator must produce. Lets future QA runs exercise all 7 conditional Phase 9 components without depending on the AI emittingSUGGEST_FIX.Merge commit (
1c90437) brings in PR #141 (PSA ticket management). Manually resolved:docs//.ai/.Test plan
/pilot/{seeded fixture id}and exercise each Phase 9 surface (runpython -m scripts.seed_phase9_qa_fixturesfirst)RUN_RLS_TESTS=1 pytest tests/test_rls_isolation.pytsc -bclean (already passed locally)/ticketspage still loads and lists ticketsKnown CI debt (pre-existing on main, not introduced here)
TicketQueue/DeviceNode/GroupNode/useMediaQuery, explicit-any inuseFlowPilotSession, fast-refresh exports inrouter.tsx)test_tenant_context.pyevent-loop runtime error — same pytest-asyncio fixture-scope issue resolved on RLS tests (b14a16a); needs the same fix applied to this fileCI gate has been failing on main for several merges. Addressing as a follow-up after this lands so we don't conflate cleanup with feature work.
Widens AIProvider.generate_json / generate_text / generate_text_stream signatures to accept `system_prompt: str | list[SystemBlock]`: - `str` (the existing call shape): passes through uncached, unchanged behavior. Every existing caller stays on the uncached path — no silent behavior change. - `list[SystemBlock]`: enables Anthropic prompt caching via structured system blocks. Caller-authored `cache_control` is honored verbatim (policy α); if no block carries it, the provider applies `cache_control: {"type": "ephemeral"}` to the first block only. Gemini ignores cache_control and concatenates list entries into one system string — the widened signature is strictly additive on that path. Adds `anthropic.cache` structured-log telemetry: on every Anthropic response (streaming included, via `stream.get_final_message()`), logs `cache_read_input_tokens` and `cache_creation_input_tokens`. Telemetry failure in streaming is swallowed so the user-facing stream never breaks. Verification deferred: cannot run from code-server (no Python, no DB, no dev env). TODO(phase0-verify) left inline in the module docstring. First verification task on the new dev environment is to hit any FlowPilot endpoint twice within 5 minutes and confirm the second call shows cache_read_input_tokens > 0 in the `anthropic.cache` log event. If verification fails, that's a debug task on the new env — not a blocker for continuing Phase 0.2/0.3/0.4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Significant rewrite of FLOWPILOT-MIGRATION.md after post-Codex plan review and the Phase 0 in-flight audit. Archives the pre-rewrite version as FLOWPILOT-MIGRATION-v1.md and keeps the Codex review under CODEX-FlowAssist-Migration-PLAN.md for traceability. Substantive changes that affect implementation: - Section 0.1 adds a spec-drift note listing corrections integrated into this revision (API namespace, task-lane item UUIDs, account_settings creation, missing /tickets/ai-parse endpoint). - Section 2 adds "Task lane item ID" terminology — stable UUID assigned to items inside ai_sessions.pending_task_lane so session_facts.source_ref has something reliable to point to. - Section 4.1 adds ai_sessions.state_version (INTEGER NOT NULL DEFAULT 0) and escalation_package_external_id. state_version drives preview cache invalidation; incremented atomically on writes to facts / suggested fixes / script_generations. - Section 4.6 creates account_settings as a new table with JSONB preferences column, lazy row creation, and a promotion rule for when a setting should graduate to a typed column. - Section 5 namespaces all session-scoped routes under /api/v1/ai-sessions/{id}/... to match the existing codebase pattern. - Section 5.5 documents the preview caching strategy (state_version keyed, 500ms client debounce, Redis planned). - Section 6.6 adds per-service MCP capability flags alongside the model tier flags. - Section 7.1 makes the /assistant -> /pilot redirect include the session-deep-link path and preserve the session ID. - Section 8.2 adds supersession semantics for [SUGGEST_FIX] markers. - Section 9 Phase 1 now explicitly includes account_settings and state_version; Phase 3 uses state_version-keyed caching; Phase 5 mentions MCP inheritance via chat_call_cached wrapper. - Section 11 adds a dedicated test plan (migrations, backend, frontend, manual QA). - Section 14 captures the eight planning decisions made during the Phase 0 conversation so they are traceable. No code changes in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>- docker-compose.dev.yml: drop Traefik/dev.resolutionflow.com labels, expose backend:8000 and frontend:5173 directly; swap relative bind mounts for ${REPO_ROOT}/... so compose works when driven from inside a code-server container with the host Docker socket mounted; default POSTGRES_PORT to 5433 host-side; add explicit uvicorn/npm run dev commands; add ENABLE_MCP_MICROSOFT_LEARN and docker-01/Tailscale CORS origins. - frontend/vite.config.ts: replace dev.resolutionflow.com with allowedHosts=['docker-01', '.ts.net', 'localhost'] for direct-port access over the private network. - DEV-ENV.md: add Section 11 reference topology for the homelab Proxmox + code-server Option B setup, plus troubleshooting entries for the REPO_ROOT-empty-mount trap and the Vite allowedHosts rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds the load-bearing structural feature of the FlowPilot migration: a "What we know" panel that holds confirmed facts for a session, fed by AI [PROMOTE] markers and engineer-added notes. Facts feed the resolution note preview (Phase 3) and survive across turns via stable UUIDs assigned to pending_task_lane items. Backend: - FactSynthesisService: create/update/soft-delete facts with atomic state_version bumps; LLM-backed synthesize_from_question/check on the fact_synthesis (Haiku) action tier per Section 6.6. - /api/v1/ai-sessions/{id}/facts CRUD + /facts/promote (proposed_text or via synthesis). PATCH returns 403 for question/diagnostic_check facts (edit the source item instead, Section 7.3). - unified_chat_service: [PROMOTE] marker parser (JSON-block per Section 8.1 spec drift note), stable-UUID assignment for pending_task_lane questions/actions preserved by exact text/label match across turns. - ASSISTANT_SYSTEM_PROMPT: documents [PROMOTE] format, when to/not to emit, hallucination guardrails, source_ref handling. - 17 tests covering parser, stable IDs, service validation, CRUD, editability rule, both promote modes, 422 null-synthesis path, state_version invariant. Frontend: - src/components/pilot/sections/{WhatWeKnow,WhatWeKnowItem,AddNoteButton} — green-gradient section above Questions, dashed-circle check, inline edit/delete gated by the server's editable flag. - TaskLane gains a whatWeKnowSlot prop (existing assistant/ folder kept per the doc's "rename is opportunistic" guidance). - AssistantChatPage fetches facts on selectChat and refetches after each chat send (so [PROMOTE]-synthesized facts appear immediately); auto- opens the lane when facts exist. Verification: end-to-end smoke against the local docker stack confirms all five endpoints (list/create/patch/delete/promote) plus the 403 editability rule. pytest suite verifies the same with mocked LLM. Live [PROMOTE] flow remains untested until used in the UI — the marker shape is covered by parser tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds the AI-proposed resolution path and the inline preview of the markdown that will be posted to the customer ticket on Resolve. The preview is keyed on (session_id, ai_sessions.state_version) so back-to- back fetches against unchanged state hit an in-process cache instead of paying for a Sonnet call. Backend: - preview_cache: in-process LRU keyed on (kind, session_id, state_version). No TTL — state_version is the source of truth. Soft-cap 5000 entries. - unified_chat_service: [SUGGEST_FIX] parser (last-block-wins, JSON payload, confidence clamped 0-100), supersession persistence (sets superseded_at on prior active row), atomic state_version bump. - ResolutionNoteGeneratorService: pulls session, facts, active fix, and redacted script_generations into a structured input bundle for Sonnet; produces the four-section markdown (Problem / What we confirmed / Root cause / Resolution). Sensitive script parameters redacted via ScriptTemplateEngine.redact_sensitive driven by the template's parameters_schema. - /api/v1/ai-sessions/{id}/suggested-fixes/active — 200 with the active fix or 404. - /api/v1/ai-sessions/{id}/suggested-fixes/{fix_id}/decision — records one_off / draft_template / build_template / dismissed; dismiss supersedes; bumps state_version. 409 on dismissing an already- superseded fix. - /api/v1/ai-sessions/{id}/resolution-note/preview — generates or returns cached markdown; from_cache flag in payload signals cache hit. - scripts.py POST /generate now bumps state_version on the linked ai_session_id when present (third source of preview-cache invalidation per Section 5.5). - ASSISTANT_SYSTEM_PROMPT documents [SUGGEST_FIX] (when to/not to emit, format, supersession semantics). - 12 tests covering the parser (well-formed, last-wins, malformed, confidence clamping), supersession + state_version invariant, all decision branches, preview cache hit-on-no-change + miss-after-write. Frontend: - src/components/pilot/sections/SuggestedFix.tsx — amber-accented card with confidence badge; dismiss action wired to the decision endpoint. - src/components/pilot/ResolutionNotePreview.tsx — popover with refresh, loading state, cached/fresh indicator, ticket-ref display. - src/api/sessionSuggestedFixes.ts — typed client; getActive normalizes 404 to null so callers don't have to special-case. - TaskLane gains suggestedFixSlot + bottomSlot props (rendered after Diagnostic Checks; bottomSlot anchors the Resolve action). - AssistantChatPage: refreshSessionDerived helper batches fact + fix refresh; fact mutations and chat sends both schedule a 500ms-debounced preview refresh per the Section 5.5 spec. Verified end-to-end against the dev stack with a real Sonnet call: - /active 404 → fact create → preview generates four-section markdown grounded only in provided facts → second preview call hits cache (from_cache=true, no LLM call) → fact write 2 → cache miss, regenerates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Wires the preview popover's Confirm & post action to ConnectWise (and, via the provider pattern, any future PSA). Adds the parallel Escalate flow with the handoff-oriented five-section markdown. Sessions without a linked PSA ticket resolve/escalate locally — markdown stored, status flipped, nothing posted externally. Backend: - EscalationPackageGeneratorService: Sonnet, five sections (Problem / What we've confirmed / What we've tried / Current hypothesis / Suggested next steps). Shares the preview_cache with a separate KIND so Resolve and Escalate previews for the same state coexist. - PSAWritebackService: post_resolution_note (RESOLUTION note type, customer-visible), post_escalation_package (INTERNAL_ANALYSIS, handoff for the next engineer only), transition_ticket_status with mandatory re-fetch verification. PSAStatusVerificationError surfaces loudly when CW silently rejects a status change — the ConnectWise anti-pattern CLAUDE.md flags. - Endpoints: * POST /ai-sessions/{id}/escalation-package/preview * POST /ai-sessions/{id}/resolution-note/post * POST /ai-sessions/{id}/escalation-package/post Outcomes: "resolved" / "escalated" with external_id + verified status, "resolved_local" / "escalated_local" when no PSA linked. - Target CW status IDs live in account_settings.preferences (cw_resolved_status_id, cw_escalated_status_id). When unset, the post proceeds without a status transition — response includes a status_transition_skipped_reason rather than silently erroring. - 7 tests: local-only path, PSA happy path with verified transition, status verification failure → 502, skipped transition when unconfigured, 409 on already-resolved re-post, escalate parallel path, internal-analysis note type enforced. Frontend: - ResolutionNotePreview now kind-parameterized ('resolve' | 'escalate') with inline edit + Confirm & post. Preview loads from the matching backend endpoint; posting calls the matching endpoint; outcome toast surfaces the verified CW status or the local-only result. - AssistantChatPage: previewKind state replaces previewOpen; two toggle buttons (Preview Resolve note / Escalate instead) in the lane's bottom slot. handleConfirmPost dispatches by kind. Verified 2026-04-22: - Local-only Resolve + Escalate round-trip against the dev stack. - Live Sonnet escalation-package preview; cache hit on repeat call with no state change (separate cache kind from resolution-note). - PSA post + status-verification paths covered by mocked-provider pytest cases. Live CW round-trip pending a test CW instance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Wires the SuggestedFix card to an inline panel that handles both cases: template-matched fixes open the Script Library generator with parameters pre-filled from session context; un-matched fixes open the three-option dialog (one_off / draft_template / build_template). The decision endpoint records the path choice with side effects: draft_template persists a draft_templates row via a Sonnet-driven TemplateExtractionService; build_template returns a redirect to the Script Builder; one_off just records the choice. Backend: - TemplateExtractionService: drafts a parameter schema from a concrete rendered script. Conservative by default ("prefer fewer parameters"). Round-trip-validates that templated_body only references declared parameters; missing-key mismatch falls back to the original script with no params. LLM/parse failures fall back identically — the engineer can still create a draft and refine in the post-resolve prompt (Phase 6). - /suggested-fixes/{fix_id}/decision side effects: * one_off → returns rendered_script (engineer's edited version or the fix's ai_drafted_script verbatim) * draft_template → same + creates draft_templates row with extracted params, returns draft_template_id * build_template → returns redirect_path=/scripts/builder?from_session= &fix= so the frontend can navigate to the builder pre-loaded - 400 when a non-template fix has no ai_drafted_script (template-matched fixes take the dedicated /scripts/generate path, not this endpoint). - 12 tests: TemplateExtractionService parse + fallback paths, all four decision branches, edited_script override, missing-script 400. Frontend: - src/components/pilot/script/{TemplateMatchPanel, NoTemplateDialog, ParameterizationPreview}.tsx — inline panels rendered in the task lane's bottom slot when the engineer clicks a SuggestedFix card. - TemplateMatchPanel: loads template via /scripts/templates/{id}, pre-fills params from fix.ai_drafted_parameters with cyan "from session" tags, generates via existing /scripts/generate (already bumps state_version on ai_session_id from Phase 3). 404 falls back with a clear message instead of erroring. - NoTemplateDialog: shows the AI-drafted script with proposed parameter values highlighted in amber via ParameterizationPreview; three option cards with the middle (draft_template) flagged Recommended; inline edit on the script body before deciding. - SuggestedFix card now clickable: onActivate toggles the inline panel. - AssistantChatPage: scriptPanelOpen state + handleScriptDecision that navigates on build_template and toasts on the other paths. Active fix changes auto-close the panel so engineers don't act on stale state. - Cmd+K → "Open inline Script Generator" palette entry surfaces only on /pilot/:id routes; fires a window event the chat page subscribes to. No Resolve shortcut added per Section 14 decision (browser ⌘R conflict). Verified 2026-04-22 against the dev stack: - one_off / draft_template / build_template all return the right shape with real Sonnet TemplateExtractionService for the draft path. - Conservative extraction confirmed: cmdkey + Restart-Process script yielded zero proposed parameters as intended. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Two fixes from the Phase 5 shakedown: 1. Stale lane data leaking across chats. handleNewChat, sendPrefill, and handleResumeNew were each missed when Phase 3/5 added activeFix, previewKind, previewData, and scriptPanelOpen — only selectChat reset the full set. Result: starting a new chat while a Suggested Fix card was active showed the previous session's fix card (and any open preview/script panel) until the next backend refresh swept it. Consolidated all four entry points behind a single resetSessionDerivedState() helper so adding new lane state in future phases only requires touching one place. 2. CommandPalette TDZ on cold load. SCRIPTS_INLINE_QUICK_ACTION (line 66) referenced PILOT_INLINE_SCRIPT_PATH declared at line 94 — module-level evaluation hit the use before the declaration. Browser blanked with "Cannot access 'PILOT_INLINE_SCRIPT_PATH' before initialization". Moved the path const above its first use; also extracted PILOT_INLINE_SCRIPT_EVENT into a tiny @/lib/pilotEvents module so AssistantChatPage doesn't import the palette component just to read a string — that mixed-export pattern broke Fast Refresh ("consistent components exports") and added an unnecessary import edge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Closes the loop on the Phase 5 "Run now, templatize after resolve" path. After a session resolves, drafts queued by the three-option dialog surface as a modal that lets the engineer review the AI-proposed parameterization and either save as a reusable team template or skip. A "don't ask again" toggle writes to account_settings.preferences so the next resolve won't pop the modal. Backend: - /api/v1/draft-templates: * GET — list account drafts (pending_only default true; pass false for audit view including accepted/rejected) * GET /{id} — single draft * POST /{id}/accept — promotes to a new script_templates row with source_session_id / source_user_id / source_ticket_ref populated (drives the Script Library "generated from CW #X · resolved by Y" provenance chip). Draft flips to status=accepted, promoted_template_id set, resolved_at stamped. 409 on re-accept / already-rejected. 400 on unknown category_id. * POST /{id}/reject — flips to status=rejected. 409 on re-reject. - /api/v1/accounts/me/preferences (GET/PATCH) — thin wrapper over AccountSettings.get_setting/set_setting. PATCH merges keys into the JSONB column, preserving existing keys the client didn't touch. Used by the "Don't ask again for this team" checkbox (templatize_prompt_enabled=false) and, forward-looking, by cw_resolved_status_id / cw_escalated_status_id from Phase 4. - 13 tests: list filter, accept with/without edited_body, provenance copy-through, reject, 409 on re-accept / re-reject, 400 on unknown category, prefs round-trip with merge semantics. Frontend: - src/components/pilot/script/TemplatizePrompt.tsx — modal showing the drafted script with proposed parameters in the Phase 5 ParameterizationPreview, editable name/category/description, an individual-parameter remove button, and the "don't ask again" opt-out. Accept posts to /draft-templates/{id}/accept + optionally PATCHes preferences. Skip posts /reject. - src/api/draftTemplates.ts — typed client plus accountPreferencesApi. - AssistantChatPage: after a successful Resolve (external OR local), fetches preferences + pending drafts for the session and queues the modal one draft at a time. Escalate does not trigger this flow. - Sidebar: Scripts nav shows the pending-draft count as a badge. Fetched independently of the main sidebar stats so endpoint flakes don't break the rest of the sidebar. Verified live 2026-04-22: seed two drafts → GET sees both pending → accept draft A (template created, provenance CW #99123 populated) → reject draft B → pending count drops → PATCH opt-out → GET confirms persistence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Records engineer-reported outcome (applied_success|applied_failed| applied_partial|dismissed). Enforces transition rules (partial → success/ failed allowed; terminal outcomes return 409) and notes requirements (applied_partial requires notes). Sets verified_at on success/failure, stamps applied_at if not already set (handles the case where the AI [FIX_OUTCOME] marker fires before the engineer clicks Apply). Also fixes pre-existing test-infrastructure bug: network_diagram.py used bare string server_default="'[]'" for JSONB columns, which asyncpg rejects during test schema creation. Changed to text("'[]'::jsonb") to match the pattern used by script_template.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Four review findings addressed: - High: draft_template 'Run now, templatize after' DOES run the script; applied_at table now stamps for both one_off and draft_template. Only build_template (no run) skips the stamp. - Medium: TemplateMatchPanel needs an explicit '✓ I ran this' button. Generate/Copy don't commit to running. The new button is the stamp moment for template-match fixes. - Medium: get-or-create for inline script_builder_sessions — POST /script-builder/sessions is now idempotent for origin='pilot_inline' (returns the existing row for a (user, ai_session_id) pair). Backed by a partial unique index: UNIQUE (user_id, ai_session_id) WHERE origin = 'pilot_inline' so remount doesn't create duplicates and draft continuity is preserved. - Medium: authorization — the create endpoint validates that any provided ai_session_id is owned by the current user (same guard other pilot endpoints use). Prevents cross-user attachment of scratch sessions to arbitrary pilot sessions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Three consistency fixes: - File inventory (backend + frontend) now names all three apply-stamp call sites: handleScriptDecision('one_off' | 'draft_template') plus TemplateMatchPanel's 'I ran this' handler. Previously listed only 'one_off' in two places, contradicting the §5 lifecycle table. - NoTemplateDialog relocation section no longer claims the decision handler is 'unchanged' — it is unchanged EXCEPT for the moved apply stamp, which is the point of §5. - Open deferrals entry on ScriptBuilderChat 'ephemeral mode' removed; replaced with the actual new surface (ScriptBuilderTab controller), which reuses the existing script-builder prompt unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Phase 9 prep. Adds: - origin VARCHAR(20) NOT NULL with CHECK ('standalone' | 'pilot_inline') - invariant: pilot_inline rows must have ai_session_id - partial unique index on (user_id, ai_session_id) WHERE origin='pilot_inline' — backs get-or-create idempotency for the inline Script Builder tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>- ScriptBuilderCreateRequest gains origin ('standalone' | 'pilot_inline') and optional ai_session_id. Handler-side validation (next task) enforces pilot_inline ⇒ ai_session_id required + owned by caller. - SessionSuggestedFixScriptRequest added for the new PATCH /script endpoint (Phase 9 Task 6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Root cause of the 06:32 AM outage: running 'pytest tests/' inside the resolutionflow_backend container silently dropped the public schema on the DEV database. Two layered bugs made this possible; both are fixed. Bug 1 — env-var lookup in conftest.TEST_DATABASE_URL put DATABASE_URL (which normally points at the dev/prod DB) ahead of DATABASE_TEST_URL. When DATABASE_URL is set, pytest used the dev DB as the 'test' DB and the test_db fixture's DROP SCHEMA public CASCADE wiped it. Fixed: - Honor only DATABASE_TEST_URL (or the localhost fallback). - Assert at module load that the DB name contains 'test' — refuses to run otherwise. Makes future misconfiguration impossible. Bug 2 — conftest overrode app.dependency_overrides[get_db] but not get_admin_db. Endpoints using get_admin_db (register, admin routes) bypassed the test session and hit the real admin DB. Before Bug 1 was fixed this was hidden because both engines pointed at the same dev DB. With isolation in place, register started failing 'Email already registered' because of stale users in the dev DB. Fixed: - Also override get_admin_db to yield the same test session. RLS is not enabled in the create_all-managed test schema, so sharing is safe. Also adds DATABASE_TEST_URL=resolutionflow_test to docker-compose.dev.yml so pytest in the container works out of the box. Verified: 49/50 Phase 8 + 9 tests pass against resolutionflow_test; the 1 failure is the pre-existing Phase 8 Issue #4 (test_record_decision_persists_and_bumps_state_version). Refs gitea #145 (will update that issue with this as the primary fix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The ResolutionNotePreview popover renders inside TaskLane's overflow-y-auto region at the bottom of the lane. On a 720px viewport with the default question/check list expanded, the popover lands below the visible scroll position — the engineer clicks "Preview Resolve note", sees the button label flip to "Showing", but no preview appears on screen. Add a useEffect that calls scrollIntoView({block: 'nearest'}) on the popover's outer div whenever `open` flips to true. block: 'nearest' scrolls just enough to make it visible without yanking the lane to the top. Discovered during Phase 9 QA. Reproduced at 1280x720; fix verified visually in the same QA run (screenshots in .gstack/qa-reports/phase9-*/). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>