Skip to content

Implement intent-driven object detection and iterative zoom#596

Open
ngoiyaeric wants to merge 3 commits into
mainfrom
feature/iterative-zoom-detection-2601202592126715211
Open

Implement intent-driven object detection and iterative zoom#596
ngoiyaeric wants to merge 3 commits into
mainfrom
feature/iterative-zoom-detection-2601202592126715211

Conversation

@ngoiyaeric
Copy link
Copy Markdown
Collaborator

@ngoiyaeric ngoiyaeric commented May 7, 2026

This PR implements a multi-pass iterative analysis loop for the resolution search feature. It fixes the user query passthrough, adds server-side image cropping using the 'sharp' library, and introduces coordinate mapping to transform detected objects from zoomed crops back to real-world coordinates. The UI is updated to provide incremental feedback during the analysis passes.


PR created automatically by Jules for task 2601202592126715211 started by @ngoiyaeric

Summary by CodeRabbit

  • New Features

    • Added multi-pass image analysis with zoom-in capability to examine specific regions within images
    • Integrated geographic bounds support for more precise location-based search filtering
  • Improvements

    • Enhanced analysis feedback with real-time status updates during processing
    • Improved search input integration with map viewport bounds for spatial context

- Fix user query passthrough in resolution search
- Add server-side image cropping utility using sharp
- Implement multi-pass iterative analysis loop in resolutionSearch agent
- Enhance GeoJSON schema with category and confidence fields
- Implement coordinate mapping from zoomed crops to absolute Lat/Lng
- Provide incremental UI feedback for summary and Mapbox layers during passes

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
qcx Ready Ready Preview, Comment May 7, 2026 1:10pm

@google-labs-jules
Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Review Change Stack

Warning

Rate limit exceeded

@ngoiyaeric has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 34 minutes and 27 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4f1a25a5-b8d9-4ac7-81ec-f355a5cb731b

📥 Commits

Reviewing files that changed from the base of the PR and between 58df5f7 and b806fa4.

📒 Files selected for processing (4)
  • components/map/map-data-context.tsx
  • components/map/mapbox-map.tsx
  • fix_types.py
  • lib/agents/resolution-search.tsx

Walkthrough

This PR introduces iterative zoom-based image analysis for geographic feature detection. Map bounds are now collected from the client, passed through form submission to the server, and forwarded to the resolution-search agent, which executes up to three analysis passes while remapping crop-relative coordinates back to absolute geographic space and optionally requesting new image crops.

Changes

Zoom-driven analysis pipeline

Layer / File(s) Summary
Data contracts and response schema
lib/agents/resolution-search.tsx
Location type extended to accept optional geographic bounds (SW/NE); GeoJSON feature properties gain confidence field; resolution-search response adds zoomRequests array to drive iterative analysis.
Multi-pass analysis and coordinate remapping
lib/agents/resolution-search.tsx
Agent function signature updated to accept bounds; core loop extracts user image, runs up to 3 analysis passes, remaps Point coordinates from crop-relative to absolute space using bounds or normalized space, and requests further crops until completion.
Image cropping and resizing utility
lib/utils/image-server-utils.ts
New utility function decodes base64 images, converts normalized crop coordinates to pixel bounds, clamps to safe in-bounds values, and extracts/resizes using sharp to JPEG format.
HeaderSearchButton parameterization
components/header-search-button.tsx
Component now accepts optional input and setInput props; uses input text in user message, appends input and bounds fields to FormData, and clears input after successful submission.
Form submission and bounds collection
components/chat.tsx, components/followup-panel.tsx
FollowupPanel and Chat components pass map bounds and input state through controlled HeaderSearchButton renders; FollowupPanel appends cameraState.bounds to FormData before server submission.
Server action bounds parsing and streaming
app/actions.tsx
Server action parses bounds JSON from FormData, passes to resolutionSearch agent within location object; streaming loop updates summary with "Analyzing:" prefix and in-progress markers, incrementally appends GeoJsonLayer updates as partial geoJson arrives.
Package dependency
package.json
Sharp image processing library added to dependencies for crop extraction functionality.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • QueueLab/QCX#499: Both PRs modify the resolution-search flow and header-search-button to pass geographic location data (lat/lng and bounds) into resolutionSearch.
  • QueueLab/QCX#409: Both PRs touch the same UI flow (components/header-search-button.tsx and components/chat.tsx) and change how map camera/bounds/capture data are passed.
  • QueueLab/QCX#316: Both PRs implement the same "resolution search" feature and touch the same code paths (resolutionSearch agent, app/actions.tsx server action, GeoJson rendering and map/context integrations).

Suggested labels

Review effort 3/5, Possible security concern

Poem

🐰 A zoom lens twists and turns through the map,
Each pass redraws the geographic snap,
Crops wander inward, coordinates remapped,
Sharp edges crisp from the image-crop app,
Three passes deep—let the feature hunt begin! 🗺️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Implement intent-driven object detection and iterative zoom' directly aligns with the PR's main objectives of implementing multi-pass iterative analysis with zoom-based detection and coordinate mapping.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/iterative-zoom-detection-2601202592126715211

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Implement intent-driven iterative zoom detection with coordinate mapping

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Implement iterative zoom-based object detection with multi-pass analysis
• Add server-side image cropping utility using sharp library
• Implement coordinate mapping from zoomed crops to absolute Lat/Lng
• Pass user query and map bounds through resolution search pipeline
• Provide incremental UI feedback during analysis passes
Diagram
flowchart LR
  A["User Input & Map Bounds"] --> B["Resolution Search Agent"]
  B --> C["Initial Analysis Pass"]
  C --> D{"Zoom Needed?"}
  D -->|Yes| E["Extract Region with Sharp"]
  E --> F["Zoom Pass Analysis"]
  F --> G["Map Coordinates Back"]
  D -->|No| H["Finalize Results"]
  G --> H
  H --> I["GeoJSON Features"]
  I --> J["UI Update with Features"]
Loading

Grey Divider

File Changes

1. lib/utils/image-server-utils.ts ✨ Enhancement +51/-0

Server-side image cropping utility with sharp

• New utility module for server-side image cropping using sharp library
• Extracts regions from base64 images using normalized coordinates (0-1)
• Converts normalized coordinates to pixel coordinates with bounds checking
• Resizes cropped regions to target size and returns as base64 data URL

lib/utils/image-server-utils.ts


2. lib/agents/resolution-search.tsx ✨ Enhancement +185/-29

Multi-pass iterative zoom detection with coordinate mapping

• Implement multi-pass iterative analysis loop with MAX_PASSES limit
• Add zoomRequests schema field for requesting higher-resolution crops
• Add isComplete flag to control iteration termination
• Implement coordinate mapping from zoomed crops back to absolute Lat/Lng using map bounds
• Add category and confidence fields to GeoJSON feature properties
• Update system prompt with intent-driven detection and zoom pass context
• Support location bounds parameter for coordinate transformation

lib/agents/resolution-search.tsx


3. app/actions.tsx ✨ Enhancement +29/-11

Pass user query and bounds through resolution search

• Extract and parse map bounds from form data
• Pass bounds to location object for coordinate transformation
• Use user input from form data instead of hardcoded default message
• Update summary stream with user input and zoom pass indicators
• Append empty GeoJSON layer initially and update incrementally during passes
• Move GeoJSON layer updates to streaming phase for real-time feedback

app/actions.tsx


View more (4)
4. components/header-search-button.tsx ✨ Enhancement +11/-2

Add user query and bounds to search form data

• Accept input and setInput props for user query management
• Extract and pass map bounds to form data for both Mapbox and Google Maps
• Include user input in form data submission
• Clear input field after successful analysis
• Use user input in UserMessage component instead of hardcoded text

components/header-search-button.tsx


5. components/chat.tsx ✨ Enhancement +2/-2

Pass input state to HeaderSearchButton component

• Pass input and setInput props to HeaderSearchButton component
• Enable user query input propagation in both mobile and desktop layouts

components/chat.tsx


6. components/followup-panel.tsx ✨ Enhancement +4/-0

Include map bounds in followup panel form data

• Extract map bounds from camera state
• Append bounds to form data for coordinate transformation support

components/followup-panel.tsx


7. package.json Dependencies +1/-0

Add sharp image processing dependency

• Add sharp library dependency version 0.34.5 for server-side image processing

package.json


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 7, 2026

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. CameraState bounds type error ✓ Resolved 🐞 Bug ≡ Correctness
Description
FollowupPanel reads mapData.cameraState?.bounds, but CameraState has no bounds field and the
repo has strict: true, so this should fail TypeScript compilation (or force unsafe typing).
Code

components/followup-panel.tsx[R33-36]

+    const bounds = mapData.cameraState?.bounds;
+    if (bounds) {
+      formData.append('bounds', JSON.stringify(bounds))
+    }
Evidence
The new code accesses cameraState.bounds, but the CameraState interface does not define bounds
and TypeScript strict mode is enabled, making this an invalid property access.

components/followup-panel.tsx[31-36]
components/map/map-data-context.tsx[5-13]
tsconfig.json[8-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`components/followup-panel.tsx` appends `mapData.cameraState?.bounds`, but `CameraState` does not contain `bounds` and the repo is `strict` TypeScript. This is either a build-breaker or forces unsafe typing.
### Issue Context
Bounds are used downstream for mapping normalized detections back to lat/lng in `resolutionSearch()`, so the fix should define a single bounds shape and ensure it’s actually populated for the providers that need it.
### Fix Focus Areas
- components/followup-panel.tsx[31-36]
- components/map/map-data-context.tsx[5-32]
- components/header-search-button.tsx[154-165]
### Suggested fix
- Extend `CameraState` (or `MapData`) to include a `bounds` field with the same `{ sw: {lat,lng}, ne: {lat,lng} }` shape used elsewhere.
- Ensure whatever code populates `cameraState` also populates `bounds` (or remove this bounds passthrough from `FollowupPanel` if it’s not supported).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. GeoJSON update blanks response 🐞 Bug ≡ Correctness
Description
During streaming, processResolutionSearch() calls uiStream.update(), which replaces the
already-rendered response section with a component that returns null, making the chat response
disappear.
Code

app/actions.tsx[R129-135]

+          if (partialObject.geoJson) {
+            uiStream.update(
+              <GeoJsonLayer
+                id={groupeId}
+                data={partialObject.geoJson as FeatureCollection}
+              />
+            );
Evidence
The response UI is initially set via uiStream.update(), but later updates replace the stream value
with GeoJsonLayer. GeoJsonLayer renders no DOM (return null), so the visible response is
wiped.

app/actions.tsx[223-233]
app/actions.tsx[109-137]
components/map/geojson-layer.tsx[13-100]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`uiStream.update(<GeoJsonLayer .../>)` replaces the previously rendered `<Section>` (carousel + BotMessage) with a component that renders `null`, so users lose the visible response while streaming.
### Issue Context
`GeoJsonLayer` is meant to be a side-effect-only component (map overlay), not the message body.
### Fix Focus Areas
- app/actions.tsx[109-137]
- app/actions.tsx[223-233]
### Suggested fix
- Render `GeoJsonLayer` *inside* the same `<Section>` and call `uiStream.update()` with the entire `<Section>` whenever geoJson changes, e.g. a helper `renderResolutionUI({geoJson})`.
- Alternatively, keep a single UI tree and drive GeoJSON updates via a `StreamableValue<FeatureCollection>` prop (if you have a pattern for that), rather than replacing the entire stream with `GeoJsonLayer`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Concurrent iteration double-runs 🐞 Bug ☼ Reliability
Description
resolutionSearch() starts two runIteration() generators (one for partialObjectStream, one
inside finalObjectPromise) that share mutable state (currentPass, isCompleteFlag, crop
bounds), causing race conditions and duplicate/incorrect analysis passes.
Code

lib/agents/resolution-search.tsx[R330-342]

+  const finalObjectPromise = (async () => {
+    const generator = runIteration();
+    let lastValue: any = {};
+    for await (const value of generator) {
+      lastValue = value;
+    }
+    return lastValue;
+  })();
+
+  return {
+    partialObjectStream: runIteration(),
+    object: finalObjectPromise
+  } as any;
Evidence
Both returned streams are created by calling runIteration() separately, but state mutated inside
the generator is defined in the outer scope, so the two executions interfere with each other and can
also trigger duplicate model calls.

lib/agents/resolution-search.tsx[160-173]
lib/agents/resolution-search.tsx[174-328]
lib/agents/resolution-search.tsx[330-342]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The function returns two consumers of the same logical iterative process by calling `runIteration()` twice, while sharing mutable closure state. This can cause duplicated passes, skipped passes, inconsistent crop bounds, and mismatched `partialObjectStream` vs `object`.
### Issue Context
Callers (e.g. `app/actions.tsx`) expect `partialObjectStream` and `object` to refer to the *same* run.
### Fix Focus Areas
- lib/agents/resolution-search.tsx[160-173]
- lib/agents/resolution-search.tsx[174-343]
### Suggested fix
Implement a *single underlying* iteration and derive `object` from that same iteration without starting a second generator. One approach:
- Create an internal generator `iterate()`.
- Return `partialObjectStream` as a wrapper generator that:
- updates `lastValue` each yield
- yields through to the caller
- resolves a `finalObjectPromise` when iteration completes
- Return `{ partialObjectStream: wrapper(), object: finalObjectPromise }`.
This ensures only one set of model calls and no shared-state races.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. First-pass coordinates mis-mapped 🐞 Bug ≡ Correctness
Description
When location.bounds exists, first-pass Point coordinates are treated as normalized [x,y] and
mapped into lat/lng, but the system prompt only mandates normalized coordinates for zoom passes—so
the model may output real lon/lat and get incorrectly remapped.
Code

lib/agents/resolution-search.tsx[R280-287]

+              } else if (!isZoomPass && processedFeature.geometry.type === 'Point' && location?.bounds) {
+                  const [relX, relY] = processedFeature.geometry.coordinates;
+                  const latRange = location.bounds.ne.lat - location.bounds.sw.lat;
+                  const lngRange = location.bounds.ne.lng - location.bounds.sw.lng;
+                  const lat = location.bounds.ne.lat - (relY * latRange);
+                  const lng = location.bounds.sw.lng + (relX * lngRange);
+                  processedFeature.geometry.coordinates = [lng, lat];
+              }
Evidence
The prompt explicitly enforces 0–1 coordinates only under the zoom-pass context, but the mapping
code assumes 0–1 on non-zoom passes whenever bounds are present, creating an ambiguous contract that
can place detections in the wrong location.

lib/agents/resolution-search.tsx[202-206]
lib/agents/resolution-search.tsx[208-221]
lib/agents/resolution-search.tsx[280-287]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Non-zoom pass point mapping assumes model output is normalized (0–1), but the prompt doesn’t state that requirement for the first pass. If the model outputs GeoJSON with standard lon/lat, your mapping logic will corrupt coordinates.
### Issue Context
You already have a strong instruction for zoom passes, and you already have view bounds available; the contract just needs to be consistent.
### Fix Focus Areas
- lib/agents/resolution-search.tsx[178-226]
- lib/agents/resolution-search.tsx[263-287]
### Suggested fix
Pick one of:
1) **Normalized everywhere**: Update the system prompt to require all Point coordinates to be normalized `[x,y]` (0–1) relative to the current view for *all* passes, then always map using bounds when available.
2) **Detect and branch**: Only apply the bounds-mapping if coordinates look normalized (e.g., both in `[0,1]`). If they look like lon/lat (e.g., abs(x) > 1 or abs(y) > 1), keep as-is.
Also consider clamping normalized coords to `[0,1]` before mapping to avoid out-of-bounds values.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Unsafe image part lookup 🐞 Bug ☼ Reliability
Description
resolutionSearch() casts lastUserMessage.content to any[] and calls .find(), which will
throw if the last user message is string content (valid for CoreMessage), causing a server crash
before the explicit image check.
Code

lib/agents/resolution-search.tsx[R151-153]

+  const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
+  const originalImagePart = (lastUserMessage?.content as any[]).find(p => p.type === 'image');
+  const originalImageData = originalImagePart?.image;
Evidence
The lookup assumes array content without checking Array.isArray, so the .find() call can fail
even though the function intends to throw a controlled error when no image is present.

lib/agents/resolution-search.tsx[151-158]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`(lastUserMessage?.content as any[]).find(...)` can throw when `content` is a string, which is a valid `CoreMessage` shape. This bypasses your intended `if (!originalImageData) throw ...` path.
### Issue Context
This is especially relevant as follow-up queries commonly send plain text user messages.
### Fix Focus Areas
- lib/agents/resolution-search.tsx[151-158]
### Suggested fix
- Add `const content = lastUserMessage?.content; if (!Array.isArray(content)) throw new Error('...requires an image...');`
- Then safely `const originalImagePart = content.find(p => p.type === 'image')`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread components/followup-panel.tsx
Comment thread app/actions.tsx
Comment on lines +129 to +135
if (partialObject.geoJson) {
uiStream.update(
<GeoJsonLayer
id={groupeId}
data={partialObject.geoJson as FeatureCollection}
/>
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Geojson update blanks response 🐞 Bug ≡ Correctness

During streaming, processResolutionSearch() calls uiStream.update(<GeoJsonLayer .../>), which
replaces the already-rendered response section with a component that returns null, making the chat
response disappear.
Agent Prompt
### Issue description
`uiStream.update(<GeoJsonLayer .../>)` replaces the previously rendered `<Section>` (carousel + BotMessage) with a component that renders `null`, so users lose the visible response while streaming.

### Issue Context
`GeoJsonLayer` is meant to be a side-effect-only component (map overlay), not the message body.

### Fix Focus Areas
- app/actions.tsx[109-137]
- app/actions.tsx[223-233]

### Suggested fix
- Render `GeoJsonLayer` *inside* the same `<Section>` and call `uiStream.update()` with the entire `<Section>` whenever geoJson changes, e.g. a helper `renderResolutionUI({geoJson})`.
- Alternatively, keep a single UI tree and drive GeoJSON updates via a `StreamableValue<FeatureCollection>` prop (if you have a pattern for that), rather than replacing the entire stream with `GeoJsonLayer`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +330 to +342
const finalObjectPromise = (async () => {
const generator = runIteration();
let lastValue: any = {};
for await (const value of generator) {
lastValue = value;
}
return lastValue;
})();

return {
partialObjectStream: runIteration(),
object: finalObjectPromise
} as any;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Concurrent iteration double-runs 🐞 Bug ☼ Reliability

resolutionSearch() starts two runIteration() generators (one for partialObjectStream, one
inside finalObjectPromise) that share mutable state (currentPass, isCompleteFlag, crop
bounds), causing race conditions and duplicate/incorrect analysis passes.
Agent Prompt
### Issue description
The function returns two consumers of the same logical iterative process by calling `runIteration()` twice, while sharing mutable closure state. This can cause duplicated passes, skipped passes, inconsistent crop bounds, and mismatched `partialObjectStream` vs `object`.

### Issue Context
Callers (e.g. `app/actions.tsx`) expect `partialObjectStream` and `object` to refer to the *same* run.

### Fix Focus Areas
- lib/agents/resolution-search.tsx[160-173]
- lib/agents/resolution-search.tsx[174-343]

### Suggested fix
Implement a *single underlying* iteration and derive `object` from that same iteration without starting a second generator. One approach:
- Create an internal generator `iterate()`.
- Return `partialObjectStream` as a wrapper generator that:
  - updates `lastValue` each yield
  - yields through to the caller
  - resolves a `finalObjectPromise` when iteration completes
- Return `{ partialObjectStream: wrapper(), object: finalObjectPromise }`.
This ensures only one set of model calls and no shared-state races.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +280 to +287
} else if (!isZoomPass && processedFeature.geometry.type === 'Point' && location?.bounds) {
const [relX, relY] = processedFeature.geometry.coordinates;
const latRange = location.bounds.ne.lat - location.bounds.sw.lat;
const lngRange = location.bounds.ne.lng - location.bounds.sw.lng;
const lat = location.bounds.ne.lat - (relY * latRange);
const lng = location.bounds.sw.lng + (relX * lngRange);
processedFeature.geometry.coordinates = [lng, lat];
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. First-pass coordinates mis-mapped 🐞 Bug ≡ Correctness

When location.bounds exists, first-pass Point coordinates are treated as normalized [x,y] and
mapped into lat/lng, but the system prompt only mandates normalized coordinates for zoom passes—so
the model may output real lon/lat and get incorrectly remapped.
Agent Prompt
### Issue description
Non-zoom pass point mapping assumes model output is normalized (0–1), but the prompt doesn’t state that requirement for the first pass. If the model outputs GeoJSON with standard lon/lat, your mapping logic will corrupt coordinates.

### Issue Context
You already have a strong instruction for zoom passes, and you already have view bounds available; the contract just needs to be consistent.

### Fix Focus Areas
- lib/agents/resolution-search.tsx[178-226]
- lib/agents/resolution-search.tsx[263-287]

### Suggested fix
Pick one of:
1) **Normalized everywhere**: Update the system prompt to require all Point coordinates to be normalized `[x,y]` (0–1) relative to the current view for *all* passes, then always map using bounds when available.
2) **Detect and branch**: Only apply the bounds-mapping if coordinates look normalized (e.g., both in `[0,1]`). If they look like lon/lat (e.g., abs(x) > 1 or abs(y) > 1), keep as-is.
Also consider clamping normalized coords to `[0,1]` before mapping to avoid out-of-bounds values.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/actions.tsx`:
- Around line 125-127: The branch checking fullSummary for the misspelled
literal 'ZOOM PASSContext' will never match (missing space) and relying on
AI-generated text is brittle; instead add and use an explicit zoom-pass boolean
flag (e.g., isZoomPass or zoomInProgress) that is set when extractRegion is
invoked by the agent and cleared when the zoom pass completes, then change the
code path that currently inspects fullSummary (the block calling
summaryStream.update and referencing fullSummary) to consult that flag and call
summaryStream.update(`${fullSummary}\n\n[Zoom Pass in progress...]`) only when
the flag is true.

In `@components/followup-panel.tsx`:
- Around line 33-36: The code is trying to read a non-existent
cameraState.bounds and append it to formData, which is dead; remove the whole
bounds branch (the mapData.cameraState?.bounds check and the
formData.append('bounds', ...) call) from components/followup-panel.tsx, or if
bounds is intentionally needed instead update the CameraState type to include
bounds and ensure mapData.cameraState actually supplies it before calling
formData.append — reference mapData, cameraState, bounds, and the
formData.append('bounds', ...) call when making the change.

In `@components/header-search-button.tsx`:
- Around line 158-165: The code only captures bounds when mapProvider ===
'mapbox'; update the branch that builds formData so it also captures bounds for
mapProvider === 'google' by calling map.getBounds() (or equivalent) and
appending the same JSON structure via formData.append('bounds',
JSON.stringify({...})). Ensure you use the Google Maps LatLngBounds methods
(getSouthWest/getNorthEast or toJSON) to extract lat/lng into the sw/ne shape,
and keep the existing variable names (mapProvider, map, bounds, formData.append)
so resolution-search.tsx receives location.bounds for the zoom-pass coordinate
remapping.

In `@lib/agents/resolution-search.tsx`:
- Around line 330-343: The problem is that runIteration() is called twice
creating two independent generator runs; fix by instantiating a single generator
instance (const generator = runIteration()) and use that same instance for both
partialObjectStream and the final object promise: set partialObjectStream to an
async generator that yields from that single generator instance and make
finalObjectPromise iterate the same generator to capture the last yielded value
(ensuring the stream is exhausted before resolving). Reference runIteration,
partialObjectStream, and finalObjectPromise/object in your changes.
- Around line 280-287: The coordinate remapping inside the branch that checks
!isZoomPass is wrong because it assumes AI returned normalized 0–1 image coords;
update the logic so pass-1 trusts real-world coordinates instead of remapping:
in the block referencing isZoomPass, processedFeature.geometry and
location.bounds (the branch starting with "else if (!isZoomPass &&
processedFeature.geometry.type === 'Point' && location?.bounds)"), remove the
transformation that treats coordinates as relative (the relX/relY ->
latRange/lngRange -> computed lat,lng mapping) and instead assign
processedFeature.geometry.coordinates directly from the AI-provided [lng, lat];
alternatively, if you prefer normalized inputs, add the explicit normalized
(0-1) requirement to the pass-1 system prompt so the existing remap code is
valid—choose one consistent approach and keep the isZoomPass handling consistent
with that choice.
- Around line 151-158: The code calls .find() on (lastUserMessage?.content as
any[]) which can be undefined at runtime, causing a TypeError before your
explicit image check; fix by guarding access to content before calling .find():
ensure lastUserMessage?.content is an array (e.g., test
Array.isArray(lastUserMessage?.content) or use optional chaining like
lastUserMessage?.content?.find) and only call .find() when content is present,
updating the variables originalImagePart, originalImageData and
originalImageMimeType accordingly so the existing throw new Error("Resolution
search requires an image.") runs as intended.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7b29b030-ae9e-42be-850f-075feeb77a3d

📥 Commits

Reviewing files that changed from the base of the PR and between d0efce4 and 58df5f7.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • app/actions.tsx
  • components/chat.tsx
  • components/followup-panel.tsx
  • components/header-search-button.tsx
  • lib/agents/resolution-search.tsx
  • lib/utils/image-server-utils.ts
  • package.json
📜 Review details
🔇 Additional comments (3)
lib/utils/image-server-utils.ts (1)

9-51: LGTM — extractRegion is correctly implemented.

The clamping logic is sound, the sharp pipeline is correctly sequenced (metadata read before extract/resize), and the base64 prefix strip handles all standard image MIME types.

package.json (1)

93-93: LGTM — sharp@^0.34.5 is the current stable release with no known vulnerabilities.

components/chat.tsx (1)

141-141: LGTM — HeaderSearchButton correctly receives the controlled input/setInput props in both the mobile and desktop branches.

Comment thread app/actions.tsx
Comment on lines +125 to +127
if (fullSummary.includes('ZOOM PASSContext') || fullSummary.includes('zoomed-in CROP')) {
summaryStream.update(`${fullSummary}\n\n[Zoom Pass in progress...]`);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

'ZOOM PASSContext' is missing a space — this branch can never be entered.

The string 'ZOOM PASSContext' has no space between PASS and Context. The system prompt uses **ZOOM PASS Context:** (with a space), but even so, the AI's summary output is unlikely to reproduce that system-prompt phrase verbatim. The [Zoom Pass in progress...] indicator therefore never appears in the streamed summary.

🐛 Proposed fix
-            if (fullSummary.includes('ZOOM PASSContext') || fullSummary.includes('zoomed-in CROP')) {
+            if (fullSummary.includes('ZOOM PASS') || fullSummary.includes('zoomed-in crop')) {
               summaryStream.update(`${fullSummary}\n\n[Zoom Pass in progress...]`);
             }

A more reliable alternative is to track zoom-pass state in a flag set when extractRegion is called by the agent, rather than checking for keywords in the AI-generated text.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (fullSummary.includes('ZOOM PASSContext') || fullSummary.includes('zoomed-in CROP')) {
summaryStream.update(`${fullSummary}\n\n[Zoom Pass in progress...]`);
}
if (fullSummary.includes('ZOOM PASS') || fullSummary.includes('zoomed-in crop')) {
summaryStream.update(`${fullSummary}\n\n[Zoom Pass in progress...]`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions.tsx` around lines 125 - 127, The branch checking fullSummary for
the misspelled literal 'ZOOM PASSContext' will never match (missing space) and
relying on AI-generated text is brittle; instead add and use an explicit
zoom-pass boolean flag (e.g., isZoomPass or zoomInProgress) that is set when
extractRegion is invoked by the agent and cleared when the zoom pass completes,
then change the code path that currently inspects fullSummary (the block calling
summaryStream.update and referencing fullSummary) to consult that flag and call
summaryStream.update(`${fullSummary}\n\n[Zoom Pass in progress...]`) only when
the flag is true.

Comment thread components/followup-panel.tsx
Comment on lines +158 to 165
const bounds = mapProvider === 'mapbox' && map ? map.getBounds() : null;
if (bounds) {
formData.append('bounds', JSON.stringify({
sw: { lat: bounds.getSouthWest().lat, lng: bounds.getSouthWest().lng },
ne: { lat: bounds.getNorthEast().lat, lng: bounds.getNorthEast().lng }
}))
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Bounds are not captured for the Google Maps provider.

For mapProvider === 'google', no bounds field is appended to formData. As a result, location.bounds is always undefined in resolution-search.tsx for Google Maps users. The zoom-pass coordinate remapping (lines 271–286 of resolution-search.tsx) requires location.bounds to map detected features back to lat/lng; without it, coordinates fall back to raw normalized (0–1) image-space values, which are not valid GeoJSON coordinates.

🛠️ Suggested fix — capture bounds for Google Maps provider
       const center = mapProvider === 'mapbox' && map ? map.getCenter() : mapData.cameraState?.center;
       if (center) {
         formData.append('latitude', center.lat.toString())
         formData.append('longitude', center.lng.toString())
         const bounds = mapProvider === 'mapbox' && map ? map.getBounds() : null;
         if (bounds) {
           formData.append('bounds', JSON.stringify({
             sw: { lat: bounds.getSouthWest().lat, lng: bounds.getSouthWest().lng },
             ne: { lat: bounds.getNorthEast().lat, lng: bounds.getNorthEast().lng }
           }))
+        } else if (mapData.cameraState?.bounds) {
+          formData.append('bounds', JSON.stringify(mapData.cameraState.bounds))
         }
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/header-search-button.tsx` around lines 158 - 165, The code only
captures bounds when mapProvider === 'mapbox'; update the branch that builds
formData so it also captures bounds for mapProvider === 'google' by calling
map.getBounds() (or equivalent) and appending the same JSON structure via
formData.append('bounds', JSON.stringify({...})). Ensure you use the Google Maps
LatLngBounds methods (getSouthWest/getNorthEast or toJSON) to extract lat/lng
into the sw/ne shape, and keep the existing variable names (mapProvider, map,
bounds, formData.append) so resolution-search.tsx receives location.bounds for
the zoom-pass coordinate remapping.

Comment on lines +151 to +158
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
const originalImagePart = (lastUserMessage?.content as any[]).find(p => p.type === 'image');
const originalImageData = originalImagePart?.image;
const originalImageMimeType = originalImagePart?.mimeType;

if (!originalImageData) {
throw new Error("Resolution search requires an image.");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

TypeError crash if no user message is found — optional chain doesn't guard against calling .find() on undefined.

On line 152, lastUserMessage?.content evaluates to undefined when no user message exists. The subsequent (undefined as any[]).find(...) is not guarded by optional chaining — as any[] is a TypeScript-only cast with no runtime effect — so calling .find() throws TypeError before the explicit guard at line 156–158 is ever reached. The error message "Resolution search requires an image." would therefore never be seen.

🛡️ Proposed fix
-  const originalImagePart = (lastUserMessage?.content as any[]).find(p => p.type === 'image');
+  const originalImagePart = Array.isArray(lastUserMessage?.content)
+    ? (lastUserMessage!.content as any[]).find(p => p.type === 'image')
+    : undefined;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/agents/resolution-search.tsx` around lines 151 - 158, The code calls
.find() on (lastUserMessage?.content as any[]) which can be undefined at
runtime, causing a TypeError before your explicit image check; fix by guarding
access to content before calling .find(): ensure lastUserMessage?.content is an
array (e.g., test Array.isArray(lastUserMessage?.content) or use optional
chaining like lastUserMessage?.content?.find) and only call .find() when content
is present, updating the variables originalImagePart, originalImageData and
originalImageMimeType accordingly so the existing throw new Error("Resolution
search requires an image.") runs as intended.

Comment on lines +280 to +287
} else if (!isZoomPass && processedFeature.geometry.type === 'Point' && location?.bounds) {
const [relX, relY] = processedFeature.geometry.coordinates;
const latRange = location.bounds.ne.lat - location.bounds.sw.lat;
const lngRange = location.bounds.ne.lng - location.bounds.sw.lng;
const lat = location.bounds.ne.lat - (relY * latRange);
const lng = location.bounds.sw.lng + (relX * lngRange);
processedFeature.geometry.coordinates = [lng, lat];
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Pass-1 coordinate remapping assumes the AI returns normalized (0–1) image coordinates, but the prompt never asks for this.

For zoom passes (line 266), the system prompt explicitly instructs: "All coordinates you provide MUST be relative to THIS CROP (0-1)." For pass 1 (line 280), there is no equivalent instruction. An LLM given real-world lat/lng context will almost certainly return actual geographic coordinates (e.g., [-122.5, 37.8]). Treating those values as normalized fractions and multiplying by the lat/lng range produces completely wrong GeoJSON coordinates:

  • lat = ne.lat - (37.8 * latRange) where latRange ≈ 0.1 → wildly out of bounds
  • lng = sw.lng + (-122.5 * lngRange) → same problem

Either add the same normalized-coordinate instruction to the pass-1 system prompt, or remove the remapping for pass 1 and trust the AI to return real lat/lng (which can be used directly in GeoJSON without any mapping).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/agents/resolution-search.tsx` around lines 280 - 287, The coordinate
remapping inside the branch that checks !isZoomPass is wrong because it assumes
AI returned normalized 0–1 image coords; update the logic so pass-1 trusts
real-world coordinates instead of remapping: in the block referencing
isZoomPass, processedFeature.geometry and location.bounds (the branch starting
with "else if (!isZoomPass && processedFeature.geometry.type === 'Point' &&
location?.bounds)"), remove the transformation that treats coordinates as
relative (the relX/relY -> latRange/lngRange -> computed lat,lng mapping) and
instead assign processedFeature.geometry.coordinates directly from the
AI-provided [lng, lat]; alternatively, if you prefer normalized inputs, add the
explicit normalized (0-1) requirement to the pass-1 system prompt so the
existing remap code is valid—choose one consistent approach and keep the
isZoomPass handling consistent with that choice.

Comment on lines +330 to 343
const finalObjectPromise = (async () => {
const generator = runIteration();
let lastValue: any = {};
for await (const value of generator) {
lastValue = value;
}
return lastValue;
})();

return {
partialObjectStream: runIteration(),
object: finalObjectPromise
} as any;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

runIteration() is invoked twice — double API calls, inconsistent results between partialObjectStream and object.

runIteration is an async function* generator. Every call creates a brand-new, independent execution:

const finalObjectPromise = (async () => {
    const generator = runIteration();   // ← 1st full analysis run starts here
    
})();

return {
    partialObjectStream: runIteration(), // ← 2nd full analysis run, completely independent
    object: finalObjectPromise
}

app/actions.tsx consumes both — the streaming loop reads from the 2nd generator, and await streamResult.object resolves from the 1st. Because these are separate executions, every AI call (streamObject) and every extractRegion crop is performed twice, doubling LLM costs and Tavily calls. Worse, the two runs can produce different results, so the geoJson rendered during streaming may not match the analysisResult used to persist the final state.

Fix: Run the generator once, yield values to consumers, and resolve the final promise from the last yielded value.

🐛 Proposed fix
-  const finalObjectPromise = (async () => {
-    const generator = runIteration();
-    let lastValue: any = {};
-    for await (const value of generator) {
-      lastValue = value;
-    }
-    return lastValue;
-  })();
-
-  return {
-    partialObjectStream: runIteration(),
-    object: finalObjectPromise
-  } as any;
+  let resolveObject!: (v: any) => void;
+  const objectPromise = new Promise<any>(res => { resolveObject = res; });
+
+  async function* singlePassStream() {
+    let lastValue: any = {};
+    for await (const value of runIteration()) {
+      lastValue = value;
+      yield value;
+    }
+    resolveObject(lastValue);
+  }
+
+  return {
+    partialObjectStream: singlePassStream(),
+    object: objectPromise
+  } as any;

Note: app/actions.tsx must consume partialObjectStream fully before objectPromise resolves (i.e. the for await loop must complete). The current code in processResolutionSearch already does this — it exhausts the stream before calling await streamResult.object.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/agents/resolution-search.tsx` around lines 330 - 343, The problem is that
runIteration() is called twice creating two independent generator runs; fix by
instantiating a single generator instance (const generator = runIteration()) and
use that same instance for both partialObjectStream and the final object
promise: set partialObjectStream to an async generator that yields from that
single generator instance and make finalObjectPromise iterate the same generator
to capture the last yielded value (ensuring the stream is exhausted before
resolving). Reference runIteration, partialObjectStream, and
finalObjectPromise/object in your changes.

- Add bounds to CameraState interface in MapDataContext
- Populate bounds in mapbox-map.tsx to support coordinate mapping
- Fix type error in followup-panel.tsx

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Fix user query passthrough in resolution search
- Add server-side image cropping utility using sharp
- Implement multi-pass iterative analysis loop in resolutionSearch agent
- Enhance GeoJSON schema with category and confidence fields
- Implement coordinate mapping from zoomed crops to absolute Lat/Lng
- Provide incremental UI feedback for summary and Mapbox layers
- Fix CameraState types and populate map bounds for accurate mapping
- Ensure production build passes with full type safety

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants