From b9d8be146de96341e73d6d421b43986ae9487dee Mon Sep 17 00:00:00 2001 From: Ethan Morchy Date: Sat, 8 Aug 2026 17:08:13 -0700 Subject: [PATCH 1/2] Add zoom to agent fly to location --- frontend/src/app/page.tsx | 5 +++-- frontend/src/components/MaplibreViewer.tsx | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 95ed68a..be4f968 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -436,6 +436,7 @@ export default function Dashboard() { const [flyToLocation, setFlyToLocation] = useState<{ lat: number; lng: number; + zoom?: number; ts: number; } | null>(null); @@ -504,8 +505,8 @@ export default function Dashboard() { // Agent fly_to handler (sar_focus_aoi etc.) — wired here now that // setFlyToLocation is in scope. show_image is routed through // useAgentActions at the top of Dashboard. - useAgentActions(handleMapRightClick, ({ lat, lng }) => { - setFlyToLocation({ lat, lng, ts: Date.now() }); + useAgentActions(handleMapRightClick, ({ lat, lng, zoom }) => { + setFlyToLocation({ lat, lng, zoom, ts: Date.now() }); }, secondaryBootReady); // Eavesdrop Mode State diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index 0db53fc..6e0e77e 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -752,9 +752,12 @@ const MaplibreViewer = ({ useEffect(() => { if (flyToLocation && mapRef.current) { + // Agent moves (sar_focus_aoi) carry their own zoom: a world-view revert + // asks for ~2, a tight AOI for ~9. Ignoring it pinned every move at 8, + // which turned "show the whole world" into a patch of empty ocean. mapRef.current.flyTo({ center: [flyToLocation.lng, flyToLocation.lat], - zoom: 8, + zoom: flyToLocation.zoom ?? 8, duration: 1500, }); } From add75b4973cc1471c5063b84687c01520f975baa Mon Sep 17 00:00:00 2001 From: Ethan Morchy Date: Sat, 8 Aug 2026 17:08:24 -0700 Subject: [PATCH 2/2] tests --- .../hooks/useAgentActionsFlyToZoom.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 frontend/src/__tests__/hooks/useAgentActionsFlyToZoom.test.ts diff --git a/frontend/src/__tests__/hooks/useAgentActionsFlyToZoom.test.ts b/frontend/src/__tests__/hooks/useAgentActionsFlyToZoom.test.ts new file mode 100644 index 0000000..8442777 --- /dev/null +++ b/frontend/src/__tests__/hooks/useAgentActionsFlyToZoom.test.ts @@ -0,0 +1,73 @@ +/** + * Agent fly_to zoom regression. + * + * `sar_focus_aoi` carries a zoom: a world-view revert asks for ~2, a tight + * AOI for ~9. The zoom used to be dropped between the poll and the map, so + * every agent move landed at a hardcoded zoom 8 — "show the whole world" + * rendered as a patch of empty ocean. These tests pin the whole path: + * the hook forwards zoom, the dashboard stores it, the viewer honors it. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { useAgentActions } from '@/hooks/useAgentActions'; + +const SRC_DIR = path.resolve(__dirname, '../../'); + +function readSource(rel: string): string { + return fs.readFileSync(path.join(SRC_DIR, rel), 'utf-8'); +} + +function mockActions(actions: unknown[]) { + return vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ actions }), + }); +} + +describe('useAgentActions — fly_to zoom', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('forwards the zoom the agent asked for', async () => { + vi.stubGlobal( + 'fetch', + mockActions([{ action: 'fly_to', lat: 20, lng: 0, zoom: 2, aoi_id: 'world' }]), + ); + const onFlyTo = vi.fn(); + renderHook(() => useAgentActions(vi.fn(), onFlyTo)); + + await waitFor(() => + expect(onFlyTo).toHaveBeenCalledWith({ lat: 20, lng: 0, zoom: 2 }), + ); + }); + + it('leaves zoom undefined when the action omits it', async () => { + vi.stubGlobal('fetch', mockActions([{ action: 'fly_to', lat: 51.5, lng: -0.1 }])); + const onFlyTo = vi.fn(); + renderHook(() => useAgentActions(vi.fn(), onFlyTo)); + + await waitFor(() => + expect(onFlyTo).toHaveBeenCalledWith({ lat: 51.5, lng: -0.1, zoom: undefined }), + ); + }); +}); + +describe('fly_to zoom survives the wiring', () => { + it('the dashboard stores the agent zoom in flyToLocation', () => { + const page = readSource('app/page.tsx'); + expect(page).toMatch( + /useAgentActions\(\s*handleMapRightClick,\s*\(\{ lat, lng, zoom \}\) => \{\s*setFlyToLocation\(\{ lat, lng, zoom, ts: Date\.now\(\) \}\);/, + ); + }); + + it('the viewer flies to the requested zoom, defaulting to 8', () => { + const viewer = readSource('components/MaplibreViewer.tsx'); + expect(viewer).toContain('zoom: flyToLocation.zoom ?? 8,'); + expect(viewer).not.toMatch(/center: \[flyToLocation\.lng, flyToLocation\.lat\],\s*zoom: 8,/); + }); +});