Skip to content

Conversation

@ngoiyaeric
Copy link
Collaborator

@ngoiyaeric ngoiyaeric commented Feb 5, 2026

This pull request implements the dual image resolution search feature. It captures both Mapbox and Google Satellite screenshots when a user triggers a resolution search. A new interactive CompareSlider allows users to swipe between these views. The results are displayed in a ResolutionCarousel, which includes a 'QCX-TERRA ANALYSIS' button for further processing of Google images. The AI agent has also been updated to extract geocoordinates and evaluate COG applicability. Additionally, the project has been updated to use 'sonner' for all notifications, replacing 'react-toastify'.


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

Summary by CodeRabbit

  • New Features

    • Multi-image carousel for resolution results with Mapbox/Google slides and per-slide actions
    • Side-by-side compare slider for Mapbox vs Google satellite imagery
    • QC analysis action available directly from carousel slides
  • Improvements

    • Capture now collects/compresses map images from both Mapbox and Google when available
    • Location/coordinate extraction included in analysis prompts and results
    • Notifications and error handling for image operations improved
  • Bug Fixes / UX

    • Immediate submit state handling and suggestion clearing to prevent stale UI states

…re slider and QCX-TERRA analysis button

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
@google-labs-jules
Copy link
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.

@vercel
Copy link
Contributor

vercel bot commented Feb 5, 2026

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

Project Deployment Actions Updated (UTC)
qcx Ready Ready Preview, Comment Feb 8, 2026 9:51am

@charliecreates charliecreates bot requested a review from CharlieHelps February 5, 2026 14:10
@CLAassistant
Copy link

CLAassistant commented Feb 5, 2026

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ ngoiyaeric
❌ google-labs-jules[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

@qodo-code-review
Copy link
Contributor

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

PR Code Suggestions ✨

No code suggestions found for the PR.

Copy link

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

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

No issues can be identified because the diff/content to review was not included. Please provide the patch so I can assess correctness, safety, and maintainability of the dual-image capture flow, CompareSlider UX/state handling, QCX-TERRA analysis trigger, AI-agent changes, and the sonner migration.

Summary of changes

Summary

No code diff was provided in the prompt (the # Diff section is empty), so there are no concrete code changes available to review.

Based on the PR description, the intended changes include:

  • Capturing both Mapbox and Google Satellite screenshots during a resolution search.
  • Introducing an interactive CompareSlider to swipe between the two views.
  • Showing results in a ResolutionCarousel with a QCX-TERRA ANALYSIS action for Google imagery.
  • Updating the AI agent to extract geocoordinates and evaluate COG applicability.
  • Migrating notifications from react-toastify to sonner.

If you share/paste the actual diff (or attach file changes), I can provide a detailed, line-referenced review.

…re slider and QCX-TERRA analysis button

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

coderabbitai bot commented Feb 5, 2026

Walkthrough

Added dual-source image capture (Mapbox + Google), passed both images and optional lat/lng through submission and resolutionSearch, introduced a ResolutionCarousel + CompareSlider UI, extended resolutionSearch schema/signature (extractedCoordinates, cogInfo, location), added image compression, and migrated toast usage to sonner.

Changes

Cohort / File(s) Summary
App actions & AI state
app/actions.tsx
Send file_mapbox/file_google, compute data URLs, include mapboxImage/googleImage and optional latitude/longitude in resolution_search payload and AI messages; render ResolutionCarousel instead of ResolutionImage.
Header capture & form handling
components/header-search-button.tsx
Capture/compress Mapbox and Google images (mapboxBlob, googleBlob), append file_mapbox/file_google and latitude/longitude to FormData, require at least one source, use compressImage.
New UI: compare & carousel
components/compare-slider.tsx, components/resolution-carousel.tsx
Add CompareSlider (draggable split view) and ResolutionCarousel (slides: compare, mapbox, google; QCX-TERRA analysis flow; wired to actions/toast).
Agent & schema
lib/agents/resolution-search.tsx
Extend resolutionSearch signature to accept location, add extractedCoordinates and cogInfo to schema, and inject coordinates into system prompt.
Image utilities
lib/utils/image-utils.ts
Add compressImage(blob, maxWidth?, maxHeight?, quality?) to resize/compress images to JPEG with error handling.
Map integration & context
components/map/mapbox-map.tsx
Switched toast usage to sonner and added imports to consume map-data-context / map-loading-context.
Chat UX & state tweaks
components/chat.tsx, components/chat-panel.tsx
Removed micro-delayed setTimeout(0) for isSubmitting, added clearing of suggestions on submit and when messages update.
Minor wiring / typings
app/actions.tsx, other files
Propagate mapboxImage/googleImage through AI-state payloads and add small typing/cast adjustments where new fields are read.

Sequence Diagram

sequenceDiagram
    participant User
    participant HeaderSearchButton
    participant MapboxCanvas
    participant GoogleStaticAPI
    participant AppActions
    participant ResolutionAgent
    participant ResolutionCarousel

    User->>HeaderSearchButton: Click "Analyze"
    HeaderSearchButton->>MapboxCanvas: Capture Mapbox canvas (if active)
    MapboxCanvas-->>HeaderSearchButton: mapboxBlob
    alt Google API key available / Google provider active
        HeaderSearchButton->>GoogleStaticAPI: Fetch Google static image
        GoogleStaticAPI-->>HeaderSearchButton: googleBlob
    end
    HeaderSearchButton->>AppActions: Submit FormData (file_mapbox?, file_google?, latitude, longitude, drawnFeatures)
    AppActions->>AppActions: Build data URLs, add mapboxImage/googleImage to messages
    AppActions->>ResolutionAgent: Call resolutionSearch(messages..., timezone, drawnFeatures?, location)
    ResolutionAgent-->>AppActions: Return analysis (includes extractedCoordinates, cogInfo, images)
    AppActions->>ResolutionCarousel: Render with mapboxImage, googleImage, initialImage
    ResolutionCarousel-->>User: Display slides / CompareSlider / QCX-TERRA actions
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

Review effort 3/5, Possible security concern

Poem

🐇 I hopped between tiles, one and two,
A slider split the sky in view.
Pixels whispered lat/lng to me,
Two images stitched — a mapset free.
Hooray — I nudged the world to see!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main feature: dual image resolution search (Mapbox and Google) plus QCX-TERRA analysis functionality, which aligns with the changeset's primary objectives.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/dual-image-resolution-search-14866169086758664624

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.

Copy link
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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
components/header-search-button.tsx (1)

135-153: ⚠️ Potential issue | 🟠 Major

Don’t disable search for Google provider when Mapbox map is absent.

Line 140 and Line 152 currently block the button whenever map is null, even if mapProvider === 'google' and the flow only needs mapData.cameraState. This prevents Google-based resolution searches on pages without a Mapbox instance.

🔧 Suggested fix
-      disabled={isAnalyzing || !map || !actions}
+      disabled={isAnalyzing || !actions || (mapProvider === 'mapbox' && !map)}
...
-    <Button variant="ghost" size="sm" onClick={handleResolutionSearch} disabled={isAnalyzing || !map || !actions}>
+    <Button
+      variant="ghost"
+      size="sm"
+      onClick={handleResolutionSearch}
+      disabled={isAnalyzing || !actions || (mapProvider === 'mapbox' && !map)}
+    >
app/actions.tsx (1)

203-369: 🛠️ Refactor suggestion | 🟠 Major

Remove the legacy resolution_search block now unreachable after the early return.

The new branch returns at Line 216, so the older resolution_search flow starting at Line 225 will never execute. Keeping both risks divergence and future confusion.

🤖 Fix all issues with AI agents
In `@components/compare-slider.tsx`:
- Around line 41-47: The root div in CompareSlider is interactive but lacks ARIA
semantics and keyboard handling; add proper accessibility by giving the
container div a role="slider", tabindex={0}, and
aria-valuemin/aria-valuemax/aria-valuenow (derived from the component's current
value state) and aria-label or aria-labelledby; implement an onKeyDown handler
(e.g., handleKeyDown) bound to the same element that listens for
ArrowLeft/ArrowRight (and Home/End/PageUp/PageDown if desired) to update the
slider position using the existing update logic (re-use or call the same
internal function that handleMove ultimately drives), and ensure containerRef,
handleMove, and className remain unchanged so keyboard-driven updates mirror
mouse/touch behavior.

In `@components/resolution-carousel.tsx`:
- Around line 31-55: The handleQCXAnalysis function currently proceeds to create
and submit a FormData blob even if fetch(googleImage) fails or returns a non-OK
response; update it to validate the fetch response before converting to blob and
submitting by checking response.ok (and/or response.status) and handling non-OK
responses (e.g., throw or show toast and return) so actions.submit is only
called with a valid blob; references: handleQCXAnalysis, response (from fetch),
blob, and actions.submit.
📜 Review details

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5c25e9f and 33b6dca.

📒 Files selected for processing (7)
  • app/actions.tsx
  • components/compare-slider.tsx
  • components/header-search-button.tsx
  • components/map/mapbox-map.tsx
  • components/resolution-carousel.tsx
  • lib/agents/resolution-search.tsx
  • resolution_search_block.txt
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-01-16T10:02:40.220Z
Learnt from: ngoiyaeric
Repo: QueueLab/QCX PR: 0
File: :0-0
Timestamp: 2026-01-16T10:02:40.220Z
Learning: The router agent and resolution search agent must implement a feedback loop where tool execution results (including GeoTIFF URLs, embeddings, and satellite analysis data) are fed back to the AI model's context through enriched messages before generating the final response, ensuring the foundational model receives and can synthesize all satellite intelligence data.

Applied to files:

  • resolution_search_block.txt
  • app/actions.tsx
  • lib/agents/resolution-search.tsx
🧬 Code graph analysis (3)
components/compare-slider.tsx (1)
lib/utils/index.ts (1)
  • cn (10-12)
app/actions.tsx (7)
lib/agents/resolution-search.tsx (1)
  • resolutionSearch (41-91)
components/map/geojson-layer.tsx (1)
  • GeoJsonLayer (13-100)
components/section.tsx (1)
  • Section (24-75)
components/resolution-carousel.tsx (1)
  • ResolutionCarousel (26-146)
components/message.tsx (1)
  • BotMessage (11-31)
components/resolution-image.tsx (1)
  • ResolutionImage (22-60)
tests/images.spec.ts (1)
  • test (5-235)
lib/agents/resolution-search.tsx (1)
lib/agents/tools/geospatial.tsx (3)
  • McpResponse (25-28)
  • Location (18-23)
  • _ (147-147)
🪛 Biome (2.3.13)
components/compare-slider.tsx

[error] 42-47: Static Elements should not be interactive.

To add interactivity such as a mouse or key event listener to a static element, give the element an appropriate role value.

(lint/a11y/noStaticElementInteractions)

🔇 Additional comments (4)
components/map/mapbox-map.tsx (1)

8-8: Sonner import update looks good.

No behavioral changes needed here.

resolution_search_block.txt (1)

1-170: Resolution search flow wiring looks coherent.

Dual-image ingestion and UI updates are consistent throughout the block.

lib/agents/resolution-search.tsx (1)

24-81: Schema + prompt extensions for coordinates/COG are clear.

The optional fields and prompt additions are consistent with the new capabilities.

app/actions.tsx (1)

893-904: ResolutionCarousel wiring in UI state looks good.

The new props mapping for mapbox/google/initial images is consistent.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +41 to +47
return (
<div
ref={containerRef}
className={cn("relative w-full aspect-square sm:aspect-video overflow-hidden cursor-ew-resize rounded-lg border bg-muted", className)}
onMouseMove={handleMove}
onTouchMove={handleMove}
>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add ARIA + keyboard support for the interactive slider container.

The root div is interactive but lacks semantic role/keyboard handling (Biome a11y rule). This blocks keyboard-only users.

♿ Suggested fix
+  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
+    const step = event.shiftKey ? 10 : 2
+    if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
+      setSliderPosition(pos => Math.max(0, pos - step))
+    } else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
+      setSliderPosition(pos => Math.min(100, pos + step))
+    }
+  }
+
   return (
     <div
       ref={containerRef}
       className={cn("relative w-full aspect-square sm:aspect-video overflow-hidden cursor-ew-resize rounded-lg border bg-muted", className)}
       onMouseMove={handleMove}
       onTouchMove={handleMove}
+      role="slider"
+      tabIndex={0}
+      aria-label="Compare Mapbox and Google Satellite imagery"
+      aria-valuemin={0}
+      aria-valuemax={100}
+      aria-valuenow={Math.round(sliderPosition)}
+      onKeyDown={handleKeyDown}
     >
📝 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
return (
<div
ref={containerRef}
className={cn("relative w-full aspect-square sm:aspect-video overflow-hidden cursor-ew-resize rounded-lg border bg-muted", className)}
onMouseMove={handleMove}
onTouchMove={handleMove}
>
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? 10 : 2
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
setSliderPosition(pos => Math.max(0, pos - step))
} else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
setSliderPosition(pos => Math.min(100, pos + step))
}
}
return (
<div
ref={containerRef}
className={cn("relative w-full aspect-square sm:aspect-video overflow-hidden cursor-ew-resize rounded-lg border bg-muted", className)}
onMouseMove={handleMove}
onTouchMove={handleMove}
role="slider"
tabIndex={0}
aria-label="Compare Mapbox and Google Satellite imagery"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(sliderPosition)}
onKeyDown={handleKeyDown}
>
🧰 Tools
🪛 Biome (2.3.13)

[error] 42-47: Static Elements should not be interactive.

To add interactivity such as a mouse or key event listener to a static element, give the element an appropriate role value.

(lint/a11y/noStaticElementInteractions)

🤖 Prompt for AI Agents
In `@components/compare-slider.tsx` around lines 41 - 47, The root div in
CompareSlider is interactive but lacks ARIA semantics and keyboard handling; add
proper accessibility by giving the container div a role="slider", tabindex={0},
and aria-valuemin/aria-valuemax/aria-valuenow (derived from the component's
current value state) and aria-label or aria-labelledby; implement an onKeyDown
handler (e.g., handleKeyDown) bound to the same element that listens for
ArrowLeft/ArrowRight (and Home/End/PageUp/PageDown if desired) to update the
slider position using the existing update logic (re-use or call the same
internal function that handleMove ultimately drives), and ensure containerRef,
handleMove, and className remain unchanged so keyboard-driven updates mirror
mouse/touch behavior.

Comment on lines 31 to 55
const handleQCXAnalysis = async () => {
if (!googleImage) return
setIsAnalyzing(true)

try {
const response = await fetch(googleImage)
const blob = await response.blob()

setMessages((currentMessages: any[]) => [
...currentMessages,
{
id: nanoid(),
component: <UserMessage content={[{ type: 'text', text: 'Performing QCX-TERRA ANALYSIS on Google Satellite image.' }]} />
}
])

const formData = new FormData()
formData.append('file', blob, 'google_analysis.png')
formData.append('action', 'resolution_search')

const responseMessage = await actions.submit(formData)
setMessages((currentMessages: any[]) => [...currentMessages, responseMessage as any])
} catch (error) {
console.error('Failed to perform QCX-TERRA ANALYSIS:', error)
toast.error('An error occurred during analysis.')
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Validate Google image fetch before submitting QCX analysis.

If the fetch fails, the current code still posts the error payload as a file. Add a response check to avoid invalid uploads.

🛠️ Suggested fix
-      const response = await fetch(googleImage)
-      const blob = await response.blob()
+      const response = await fetch(googleImage)
+      if (!response.ok) {
+        throw new Error(`Failed to fetch Google image (${response.status})`)
+      }
+      const blob = await response.blob()
📝 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
const handleQCXAnalysis = async () => {
if (!googleImage) return
setIsAnalyzing(true)
try {
const response = await fetch(googleImage)
const blob = await response.blob()
setMessages((currentMessages: any[]) => [
...currentMessages,
{
id: nanoid(),
component: <UserMessage content={[{ type: 'text', text: 'Performing QCX-TERRA ANALYSIS on Google Satellite image.' }]} />
}
])
const formData = new FormData()
formData.append('file', blob, 'google_analysis.png')
formData.append('action', 'resolution_search')
const responseMessage = await actions.submit(formData)
setMessages((currentMessages: any[]) => [...currentMessages, responseMessage as any])
} catch (error) {
console.error('Failed to perform QCX-TERRA ANALYSIS:', error)
toast.error('An error occurred during analysis.')
const handleQCXAnalysis = async () => {
if (!googleImage) return
setIsAnalyzing(true)
try {
const response = await fetch(googleImage)
if (!response.ok) {
throw new Error(`Failed to fetch Google image (${response.status})`)
}
const blob = await response.blob()
setMessages((currentMessages: any[]) => [
...currentMessages,
{
id: nanoid(),
component: <UserMessage content={[{ type: 'text', text: 'Performing QCX-TERRA ANALYSIS on Google Satellite image.' }]} />
}
])
const formData = new FormData()
formData.append('file', blob, 'google_analysis.png')
formData.append('action', 'resolution_search')
const responseMessage = await actions.submit(formData)
setMessages((currentMessages: any[]) => [...currentMessages, responseMessage as any])
} catch (error) {
console.error('Failed to perform QCX-TERRA ANALYSIS:', error)
toast.error('An error occurred during analysis.')
🤖 Prompt for AI Agents
In `@components/resolution-carousel.tsx` around lines 31 - 55, The
handleQCXAnalysis function currently proceeds to create and submit a FormData
blob even if fetch(googleImage) fails or returns a non-OK response; update it to
validate the fetch response before converting to blob and submitting by checking
response.ok (and/or response.status) and handling non-OK responses (e.g., throw
or show toast and return) so actions.submit is only called with a valid blob;
references: handleQCXAnalysis, response (from fetch), blob, and actions.submit.

…re slider and QCX-TERRA analysis button

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
…re slider and QCX-TERRA analysis button

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
Copy link
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: 1

🤖 Fix all issues with AI agents
In `@app/actions.tsx`:
- Around line 53-60: The latitude/longitude parsing currently may produce NaN or
out-of-range values and then set location; update the resolution_search branch
(the block using action === 'resolution_search' and variables lat, lng,
location) to parse latitude/longitude into numeric variables, then validate with
Number.isFinite(lat) && Number.isFinite(lng) and range checks (lat between -90
and 90, lng between -180 and 180); only set location = { lat, lng } when those
checks pass, otherwise leave location undefined (or handle as the surrounding
logic expects).
📜 Review details

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 33b6dca and e4839bd.

📒 Files selected for processing (3)
  • app/actions.tsx
  • components/resolution-carousel.tsx
  • components_chat_patch.patch
💤 Files with no reviewable changes (1)
  • components_chat_patch.patch
🧰 Additional context used
🧬 Code graph analysis (1)
components/resolution-carousel.tsx (8)
app/actions.tsx (1)
  • AI (581-660)
components/user-message.tsx (1)
  • UserMessage (15-53)
components/resolution-image.tsx (1)
  • ResolutionImage (22-60)
components/ui/button.tsx (1)
  • Button (56-56)
components/ui/carousel.tsx (5)
  • Carousel (257-257)
  • CarouselContent (258-258)
  • CarouselItem (259-259)
  • CarouselPrevious (260-260)
  • CarouselNext (261-261)
components/compare-slider.tsx (1)
  • CompareSlider (12-91)
components/search-results-image.tsx (1)
  • image (66-140)
lib/agents/resolution-search.tsx (1)
  • resolutionSearch (33-79)
🔇 Additional comments (9)
components/resolution-carousel.tsx (3)

61-81: Slides assembly looks solid.

The compare + individual + fallback ordering is clear and consistent.


83-103: Single-slide rendering is clean.

Good conditional handling for the analysis button on the Google-only slide.


105-145: Carousel rendering is well-structured.

Slide types and navigation controls are handled cleanly.

app/actions.tsx (6)

66-71: Data URL generation is consistent.

Mapping mapbox/google buffers to base64 mirrors the primary file flow.


104-149: Resolution search pipeline changes look correct.

Passing location and sanitizing message content is a good extension.


170-177: Result payload extension is appropriate.

Persisting mapbox/Google images enables proper replay and UI reconstruction.


205-214: ResolutionCarousel integration is clean.

Props are passed consistently for the initial render.


224-298: Special-case response path still looks consistent.

No issues with the updated flow or return shape.


746-757: UI state reconstruction is correct.

ResolutionCarousel now mirrors the stored mapbox/Google imagery as expected.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines 53 to +60
if (action === 'resolution_search') {
const file = formData?.get('file') as File;
const file_mapbox = formData?.get('file_mapbox') as File;
const file_google = formData?.get('file_google') as File;
const file = (formData?.get('file') as File) || file_mapbox || file_google;
const timezone = (formData?.get('timezone') as string) || 'UTC';
const lat = formData?.get('latitude') ? parseFloat(formData.get('latitude') as string) : undefined;
const lng = formData?.get('longitude') ? parseFloat(formData.get('longitude') as string) : undefined;
const location = (lat !== undefined && lng !== undefined) ? { lat, lng } : undefined;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Validate latitude/longitude before forming location.

Line 58–60 can pass NaN (or out-of-range) values into location, which may break downstream geo logic. Add finite + range checks before setting location.

✅ Suggested fix
-    const lat = formData?.get('latitude') ? parseFloat(formData.get('latitude') as string) : undefined;
-    const lng = formData?.get('longitude') ? parseFloat(formData.get('longitude') as string) : undefined;
-    const location = (lat !== undefined && lng !== undefined) ? { lat, lng } : undefined;
+    const lat = formData?.get('latitude') ? parseFloat(formData.get('latitude') as string) : undefined;
+    const lng = formData?.get('longitude') ? parseFloat(formData.get('longitude') as string) : undefined;
+    const location =
+      Number.isFinite(lat) &&
+      Number.isFinite(lng) &&
+      Math.abs(lat as number) <= 90 &&
+      Math.abs(lng as number) <= 180
+        ? { lat: lat as number, lng: lng as number }
+        : undefined;
🤖 Prompt for AI Agents
In `@app/actions.tsx` around lines 53 - 60, The latitude/longitude parsing
currently may produce NaN or out-of-range values and then set location; update
the resolution_search branch (the block using action === 'resolution_search' and
variables lat, lng, location) to parse latitude/longitude into numeric
variables, then validate with Number.isFinite(lat) && Number.isFinite(lng) and
range checks (lat between -90 and 90, lng between -180 and 180); only set
location = { lat, lng } when those checks pass, otherwise leave location
undefined (or handle as the surrounding logic expects).

…re slider and QCX-TERRA analysis button

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Added `compressImage` utility to resize and compress screenshots before sending to server actions.
- Implemented image compression in `HeaderSearchButton` and `ResolutionCarousel`.
- Updated 'QCX-TERRA ANALYSIS' button styling to match platform theme using `variant="default"`.
- Resolved syntax and type errors in `app/actions.tsx` and `ResolutionCarousel`.
- Continued from previous PR to include Mapbox-Google comparison slider and carousel.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
Copy link
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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
components/header-search-button.tsx (2)

150-171: ⚠️ Potential issue | 🔴 Critical

Button is always disabled when using the Google Maps provider.

Both the desktop (Line 155) and mobile (Line 167) buttons include !map in their disabled condition. The map ref is the Mapbox map instance, which will be null when the user has switched to the Google provider. This makes the resolution search button permanently disabled for Google Maps users, even though handleResolutionSearch (Line 96) has a valid code path for the Google provider.

🐛 Proposed fix
       disabled={isAnalyzing || !map || !actions}

should account for the active provider:

-      disabled={isAnalyzing || !map || !actions}
+      disabled={isAnalyzing || (mapProvider === 'mapbox' && !map) || !actions}

Apply the same fix to both the desktop button (Line 155) and mobile button (Line 167).


104-104: ⚠️ Potential issue | 🟡 Minor

Unclamped zoom level for Google Static Maps.

Math.round(Math.log2(40000000 / (range || 1))) can produce values outside Google Static Maps' supported range (0–21). For instance, a very small range yields zoom > 21; a very large range could go negative.

🛡️ Proposed fix
-        const zoom = Math.round(Math.log2(40000000 / (range || 1)));
+        const zoom = Math.min(21, Math.max(0, Math.round(Math.log2(40000000 / (range || 1)))));
🤖 Fix all issues with AI agents
In `@components/header-search-button.tsx`:
- Around line 124-128: The file names used when appending image blobs to
FormData (in the mapboxBlob/googleBlob handling and the fallback 'file')
incorrectly use .png while compressImage returns image/jpeg; update the
FormData.append calls for mapboxBlob, googleBlob and the fallback 'file' in this
component (references: mapboxBlob, googleBlob, formData.append('file', ...)) to
use the correct extension by either switching the hardcoded names to .jpg or
dynamically deriving the extension from each blob's MIME type (blob.type) before
calling formData.append so filename and MIME match.

In `@components/resolution-carousel.tsx`:
- Around line 27-28: The cast to any in ResolutionCarousel loses type safety;
replace "as any" by a proper actions interface: either import and reuse the
existing HeaderActions (from header-search-button.tsx) or declare a
ResolutionActions interface matching the actions used in ResolutionCarousel,
then cast useActions<typeof AI>() to that interface (update the const actions =
useActions<typeof AI>() as ResolutionActions/HeaderActions). Ensure the
interface includes the specific methods/properties referenced inside
ResolutionCarousel so TypeScript can validate usages.
- Around line 52-54: handleQCXAnalysis is creating FormData with only 'file' and
'action', which causes the server-side resolutionSearch to default to UTC and
empty drawn-features; update the handleQCXAnalysis FormData to also append the
current 'timezone' and 'drawnFeatures' values (matching the keys used by the
initial resolution search) by reading them from the component's props/state or
the same source header-search-button uses so the re-analysis preserves timezone
and drawn-feature context expected by resolutionSearch.

In `@lib/utils/image-utils.ts`:
- Around line 11-21: The current scaling only reduces the dominant dimension and
can leave the other dimension > max; change the logic to compute a single scale
= Math.min(maxWidth/width, maxHeight/height, 1) and apply it to both width and
height (width = Math.round(width * scale); height = Math.round(height * scale));
then set the canvas.width and canvas.height to these rounded integers so the
canvas uses exact integer dimensions. Update the code that references
width/height (the variables width, height, maxWidth, maxHeight and the canvas
width/height assignments) to use this new scale and rounding.
- Line 49: The blob URL created with URL.createObjectURL(blob) in compressImage
(where img.src is set) must be saved to a local variable (e.g., objectUrl) and
revoked with URL.revokeObjectURL(objectUrl) in both the img.onload and
img.onerror handlers to avoid leaking blob URLs; update the img.src assignment
to use the saved objectUrl and ensure revoke is called after the image is
processed or on error.
📜 Review details

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e4839bd and 66ae964.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • components/header-search-button.tsx
  • components/resolution-carousel.tsx
  • lib/utils/image-utils.ts
🧰 Additional context used
🧬 Code graph analysis (2)
components/resolution-carousel.tsx (7)
app/actions.tsx (1)
  • AI (581-660)
lib/utils/image-utils.ts (1)
  • compressImage (4-51)
components/user-message.tsx (1)
  • UserMessage (15-53)
components/resolution-image.tsx (1)
  • ResolutionImage (22-60)
components/ui/carousel.tsx (11)
  • Carousel (257-257)
  • CarouselContent (258-258)
  • CarouselItem (259-259)
  • CarouselPrevious (260-260)
  • CarouselNext (261-261)
  • props (178-194)
  • props (156-172)
  • props (49-149)
  • props (229-252)
  • props (200-223)
  • api (101-107)
components/compare-slider.tsx (1)
  • CompareSlider (12-91)
lib/agents/resolution-search.tsx (1)
  • resolutionSearch (33-79)
components/header-search-button.tsx (4)
lib/utils/image-utils.ts (1)
  • compressImage (4-51)
components/map/google-map.tsx (1)
  • GoogleMapComponent (12-86)
components/map/map-query-handler.tsx (2)
  • prevData (39-48)
  • toolOutput (33-73)
components/map/mapbox-map.tsx (3)
  • prevData (167-167)
  • prevData (347-356)
  • map (67-168)
🔇 Additional comments (5)
components/header-search-button.tsx (3)

76-95: Missing response.ok check on the Google Static Map fetch.

When fetching the Google Static Map during a Mapbox session (Line 85), response.ok is checked — good. However, response.blob() on a non-OK response would still produce a blob containing an error HTML/JSON body. This is already handled. Looks correct on re-read.

Actually, re-reading: Line 85 does check if (response.ok). This is fine.


39-47: Early-return guard and pre-checks look good.

The null checks for map (when Mapbox) and actions with user-friendly toast messages are well-structured.


60-74: Mapbox canvas capture and compression with graceful fallback is solid.

The pattern of capturing, compressing, and falling back to the raw blob on compression failure is a good resilience approach.

components/resolution-carousel.tsx (2)

66-86: Slide-building logic is clear and handles all combinations correctly.

The fallback chain (both → individual → initial → null) is well-structured. The guard at Line 86 returning null when no slides are built is a clean no-op.


110-149: Carousel rendering with conditional navigation looks good.

Navigation buttons are correctly gated behind slides.length > 1. The use of CompareSlider for the comparison slide and ResolutionImage for individual slides composes the existing components well.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +124 to +128
if (mapboxBlob) formData.append('file_mapbox', mapboxBlob, 'mapbox_capture.png')
if (googleBlob) formData.append('file_google', googleBlob, 'google_capture.png')

// Keep 'file' for backward compatibility if needed, or just use the first available
formData.append('file', (mapboxBlob || googleBlob)!, 'map_capture.png')
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

File extension says .png but compressed blob is JPEG.

compressImage outputs image/jpeg blobs, but the filenames passed to FormData.append use .png extensions (mapbox_capture.png, google_capture.png, map_capture.png). If server-side logic infers MIME type from the filename, this mismatch could cause issues. Use .jpg instead, or determine the extension based on whether compression succeeded or fell back to the raw PNG blob.

🤖 Prompt for AI Agents
In `@components/header-search-button.tsx` around lines 124 - 128, The file names
used when appending image blobs to FormData (in the mapboxBlob/googleBlob
handling and the fallback 'file') incorrectly use .png while compressImage
returns image/jpeg; update the FormData.append calls for mapboxBlob, googleBlob
and the fallback 'file' in this component (references: mapboxBlob, googleBlob,
formData.append('file', ...)) to use the correct extension by either switching
the hardcoded names to .jpg or dynamically deriving the extension from each
blob's MIME type (blob.type) before calling formData.append so filename and MIME
match.

Comment on lines +27 to +28
export function ResolutionCarousel({ mapboxImage, googleImage, initialImage }: ResolutionCarouselProps) {
const actions = useActions<typeof AI>() as any
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Prefer a typed interface over as any for actions.

header-search-button.tsx defines a HeaderActions interface and uses it for the cast. Here, as any discards all type safety. Consider reusing or sharing the same interface for consistency.

♻️ Proposed fix
+interface CarouselActions {
+  submit: (formData: FormData) => Promise<any>;
+}
+
 export function ResolutionCarousel({ mapboxImage, googleImage, initialImage }: ResolutionCarouselProps) {
-  const actions = useActions<typeof AI>() as any
+  const actions = useActions<typeof AI>() as unknown as CarouselActions
🤖 Prompt for AI Agents
In `@components/resolution-carousel.tsx` around lines 27 - 28, The cast to any in
ResolutionCarousel loses type safety; replace "as any" by a proper actions
interface: either import and reuse the existing HeaderActions (from
header-search-button.tsx) or declare a ResolutionActions interface matching the
actions used in ResolutionCarousel, then cast useActions<typeof AI>() to that
interface (update the const actions = useActions<typeof AI>() as
ResolutionActions/HeaderActions). Ensure the interface includes the specific
methods/properties referenced inside ResolutionCarousel so TypeScript can
validate usages.

Comment on lines +52 to +54
const formData = new FormData()
formData.append('file', blob, 'google_analysis.png')
formData.append('action', 'resolution_search')
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's find the server-side action handlers and understand how timezone/drawnFeatures are used
rg -n 'resolutionSearch' --type=ts --type=tsx -g '!node_modules/**' | head -20

Repository: QueueLab/QCX

Length of output: 83


🏁 Script executed:

# Search for timezone and drawnFeatures usage in server actions
rg -n 'timezone|drawnFeatures' --type=ts -C 2 -g '!node_modules/**' -g '**/app/actions*' | head -100

Repository: QueueLab/QCX

Length of output: 1593


🏁 Script executed:

# Check the header-search-button.tsx file to see lines 131-132
fd -t f 'header-search-button.tsx' | xargs cat -n | grep -A 5 -B 5 '^.*13[0-2]'

Repository: QueueLab/QCX

Length of output: 851


🏁 Script executed:

# Search for FormData append patterns to understand what fields are typically sent
rg -n "FormData\(\)" --type=tsx --type=ts -A 5 | head -50

Repository: QueueLab/QCX

Length of output: 83


QCX-TERRA re-analysis loses timezone and drawn-feature context.

The handleQCXAnalysis FormData only includes file and action, but omits timezone and drawnFeatures that are sent in the initial resolution search (header-search-button.tsx lines 131-132). The server-side resolutionSearch handler actively uses both parameters (app/actions.tsx lines 45, 57, 104), so the re-analysis will default to UTC and an empty drawn-features array instead of preserving the original analysis context. If this re-analysis is intended to be a fresh, context-free pass on the Google image, this is acceptable — but if it should maintain the original analysis depth, these fields should be propagated.

🤖 Prompt for AI Agents
In `@components/resolution-carousel.tsx` around lines 52 - 54, handleQCXAnalysis
is creating FormData with only 'file' and 'action', which causes the server-side
resolutionSearch to default to UTC and empty drawn-features; update the
handleQCXAnalysis FormData to also append the current 'timezone' and
'drawnFeatures' values (matching the keys used by the initial resolution search)
by reading them from the component's props/state or the same source
header-search-button uses so the re-analysis preserves timezone and
drawn-feature context expected by resolutionSearch.

Comment on lines +11 to +21
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Scaling logic can exceed maxHeight (or maxWidth) for non-dominant dimensions.

The code only scales the larger dimension. When maxWidth and maxHeight define a different aspect ratio than the source, the non-scaled dimension can still exceed its limit. For example, a 2000×1500 image with maxWidth=1024, maxHeight=512 produces 1024×768, violating maxHeight.

A standard approach is to compute the scale factor for both dimensions and pick the smaller one:

♻️ Proposed fix
-      if (width > height) {
-        if (width > maxWidth) {
-          height *= maxWidth / width;
-          width = maxWidth;
-        }
-      } else {
-        if (height > maxHeight) {
-          width *= maxHeight / height;
-          height = maxHeight;
-        }
-      }
+      const scale = Math.min(1, maxWidth / width, maxHeight / height);
+      width = Math.round(width * scale);
+      height = Math.round(height * scale);

Using Math.round also addresses the floating-point canvas dimension issue (Lines 23-24) — canvas width/height are integers, and setting them to floats causes implicit truncation.

📝 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 (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
const scale = Math.min(1, maxWidth / width, maxHeight / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
🤖 Prompt for AI Agents
In `@lib/utils/image-utils.ts` around lines 11 - 21, The current scaling only
reduces the dominant dimension and can leave the other dimension > max; change
the logic to compute a single scale = Math.min(maxWidth/width, maxHeight/height,
1) and apply it to both width and height (width = Math.round(width * scale);
height = Math.round(height * scale)); then set the canvas.width and
canvas.height to these rounded integers so the canvas uses exact integer
dimensions. Update the code that references width/height (the variables width,
height, maxWidth, maxHeight and the canvas width/height assignments) to use this
new scale and rounding.

};

img.onerror = () => reject(new Error('Failed to load image for compression'));
img.src = URL.createObjectURL(blob);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Memory leak: Object URL is never revoked.

URL.createObjectURL(blob) allocates a blob URL that persists until explicitly revoked or the document is unloaded. Since compressImage can be called multiple times per session (every capture/compress cycle), each call leaks a blob URL reference.

Revoke the URL in both the onload and onerror callbacks.

🔧 Proposed fix
     img.onload = () => {
+      URL.revokeObjectURL(img.src);
       let width = img.width;
       let height = img.height;
       // ...
     };
 
-    img.onerror = () => reject(new Error('Failed to load image for compression'));
+    img.onerror = () => {
+      URL.revokeObjectURL(img.src);
+      reject(new Error('Failed to load image for compression'));
+    };
     img.src = URL.createObjectURL(blob);
📝 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
img.src = URL.createObjectURL(blob);
img.onload = () => {
URL.revokeObjectURL(img.src);
let width = img.width;
let height = img.height;
// ...
};
img.onerror = () => {
URL.revokeObjectURL(img.src);
reject(new Error('Failed to load image for compression'));
};
img.src = URL.createObjectURL(blob);
🤖 Prompt for AI Agents
In `@lib/utils/image-utils.ts` at line 49, The blob URL created with
URL.createObjectURL(blob) in compressImage (where img.src is set) must be saved
to a local variable (e.g., objectUrl) and revoked with
URL.revokeObjectURL(objectUrl) in both the img.onload and img.onerror handlers
to avoid leaking blob URLs; update the img.src assignment to use the saved
objectUrl and ensure revoke is called after the image is processed or on error.

@ngoiyaeric
Copy link
Collaborator Author

@coderabbitai the chat input does not respond

@ngoiyaeric
Copy link
Collaborator Author

@coderabbitai or I think its taking too long and the inquiry text is out of bounds for the UI

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

@ngoiyaeric I'll investigate the chat input issue. Let me examine the chat component to understand what might be causing it to become unresponsive.


🏁 Script executed:

#!/bin/bash
# Read the chat component to understand the implementation
cat components/chat.tsx

Length of output: 7375


🏁 Script executed:

#!/bin/bash
# Check if there are any error logs or issues in the submission flow
rg -n "setIsSubmitting" components/chat.tsx -C 5

Length of output: 2321


🏁 Script executed:

#!/bin/bash
# Look for the form submission and input handling
rg -n "submitMessage|handleSubmit" components/chat.tsx -C 10

Length of output: 1708


🏁 Script executed:

#!/bin/bash
# Check the ChatPanel component to understand the form submission logic
cat components/chat-panel.tsx

Length of output: 9978


🏁 Script executed:

#!/bin/bash
# Also check if there's any disabled state management in the input
rg -n "disabled|isSubmitting" components/chat-panel.tsx -C 3

Length of output: 905


@ngoiyaeric I found the issue! The problem is caused by the removal of setTimeout in the recent patch to components/chat.tsx. This created a race condition where the form submission happens before the input state updates.

Root Cause

When a suggestion is selected or the EmptyScreen's submitMessage is called:

  1. setInput(message) updates the input state
  2. setIsSubmitting(true) is called immediately after
  3. The useEffect triggers and calls submitForm()
  4. But the input prop in ChatPanel hasn't updated yet (React batches state updates)
  5. ChatPanel's handleSubmit sees an empty input and returns early (line 96-98)
  6. The input is never cleared, leaving the component stuck

The original setTimeout(..., 0) deferred the submission to the next event loop tick, ensuring the input state propagated first.

Solution

Here's the fix for components/chat.tsx. You need to restore the deferred state update pattern:

// In the SuggestionsDropdown onSelect handler (around line 112-116)
onSelect={query => {
  setInput(query)
  setSuggestions(null)
  // Use a small timeout to ensure state update before submission
  setTimeout(() => {
    setIsSubmitting(true)
  }, 0)
}}

// In both EmptyScreen submitMessage handlers (lines 154-157 and 193-196)
submitMessage={message => {
  setInput(message)
  setTimeout(() => {
    setIsSubmitting(true)
  }, 0)
}}

Alternatively, for a cleaner solution, you could modify ChatPanel to accept an optional submitWithMessage prop that handles both the input update and submission in a single action, avoiding the state synchronization issue entirely. Let me know if you'd like me to implement that approach instead.


💡 Use @coderabbitai plan to create a plan for code changes

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

@ngoiyaeric Let me investigate the chat input issue and the UI text overflow problem.


🏁 Script executed:

#!/bin/bash
# Find and read the chat component file
fd -e tsx -e ts "chat.tsx" --exec cat {}

Length of output: 7375


🏁 Script executed:

#!/bin/bash
# Search for setIsSubmitting usage patterns in the codebase
rg -n "setIsSubmitting" -A 3 -B 3 --type=tsx --type=ts

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Look for input-related styling and overflow handling
rg -n "overflow|text-overflow|whitespace" components/chat.tsx -C 2

Length of output: 335


🏁 Script executed:

#!/bin/bash
# Check the header search button for any timing or UI issues
cat components/header-search-button.tsx

Length of output: 6666


🏁 Script executed:

#!/bin/bash
# Read the ChatPanel component to check input styling
fd -e tsx -e ts "chat-panel.tsx" --exec cat {}

Length of output: 9978


🏁 Script executed:

#!/bin/bash
# Check for any overflow or text styling in chat-related components
rg -n "input|textarea" components/chat-panel.tsx -C 3

Length of output: 4239


🏁 Script executed:

#!/bin/bash
# Look for the patch file to see exactly what was removed
cat components_chat_patch.patch

Length of output: 59


@ngoiyaeric I've identified two issues in your PR:

1. Race Condition: Chat Input Not Responding

React processes state updates after event handlers have finished running in a process called batching. The patch removed setTimeout(0) which was deferring the form submission, and this creates a race condition.

In components/chat.tsx (lines 115-117):

onSelect={query => {
  setInput(query)           // State update is queued
  setSuggestions(null)
  setIsSubmitting(true)     // Immediately triggers submission
}}

The problem: setInput(query) queues a state update but doesn't apply it immediately. When setIsSubmitting(true) fires right after, the useEffect (lines 85-90) calls submitForm() before the input value has actually updated, so the form submits with an empty or stale value.

Solution: Restore the setTimeout to allow state to settle:

onSelect={query => {
  setInput(query)
  setSuggestions(null)
  // Defer submission to next tick to allow state to settle
  setTimeout(() => setIsSubmitting(true), 0)
}}

Apply the same fix to the EmptyScreen at line 158:

submitMessage={message => {
  setInput(message)
  setTimeout(() => setIsSubmitting(true), 0)
}}

2. Text Overflow in UI

The Textarea in components/chat-panel.tsx (line 244) lacks overflow handling. Long text without spaces can exceed boundaries.

Solution: Add overflow control classes to the Textarea:

className={cn(
  'resize-none w-full min-h-12 rounded-fill border border-input pl-14 pr-12 pt-3 pb-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
  'break-words overflow-wrap-anywhere',  // Add these overflow classes
  isMobile
    ? 'mobile-chat-input input bg-background'
    : 'bg-muted'
)}

These changes will fix both the input responsiveness and text overflow issues. Would you like me to create an issue to track these fixes?

@ngoiyaeric
Copy link
Collaborator Author

@coderabbitai the input chat for follow up after resolution search does not input any text, there is no response. Also the recommended queries on the on the chat input is displaying even after response.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 7, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the chat component to check suggestions logic
cat components/chat.tsx

Length of output: 7375


🏁 Script executed:

#!/bin/bash
# Check the resolution carousel to see how it handles message submission
cat components/resolution-carousel.tsx

Length of output: 5148


🏁 Script executed:

#!/bin/bash
# Look for any patterns where suggestions should be cleared
rg -n "setSuggestions|suggestions" components/chat.tsx -C 5

Length of output: 3335


🏁 Script executed:

#!/bin/bash
# Check if there's any disabled or loading state that might block input
rg -n "disabled|isSubmitting|isLoading" components/chat-panel.tsx -C 3

Length of output: 905


🏁 Script executed:

#!/bin/bash
# Check the actions.tsx resolution_search implementation
rg -n "resolution_search" app/actions.tsx -A 30 -B 5

Length of output: 6537


@ngoiyaeric I found the issues! There are two separate problems causing the chat to malfunction:

1. Missing setTimeout (Race Condition)

The previous fix wasn't fully applied. In components/chat.tsx, line 115 has a comment about using setTimeout, but the actual code is missing:

Current code (line 112-117):

onSelect={query => {
  setInput(query)
  setSuggestions(null)
  // Use a small timeout to ensure state update before submission
  setIsSubmitting(true)  // ❌ This fires immediately!
}}

Should be:

onSelect={query => {
  setInput(query)
  setSuggestions(null)
  // Use a small timeout to ensure state update before submission
  setTimeout(() => {
    setIsSubmitting(true)
  }, 0)
}}

The same issue exists in the EmptyScreen handler (line 154-157):

submitMessage={message => {
  setInput(message)
  setTimeout(() => {
    setIsSubmitting(true)
  }, 0)
}}

2. Suggestions Not Cleared After Response

When new messages arrive (like after resolution search completes), the suggestions dropdown should be hidden but there's no code doing this. Add this useEffect in components/chat.tsx after line 51:

useEffect(() => {
  setShowEmptyScreen(messages.length === 0)
}, [messages])

// Add this new effect:
useEffect(() => {
  // Clear suggestions whenever messages change
  if (messages.length > 0) {
    setSuggestions(null)
  }
}, [messages])

This ensures that when ResolutionCarousel (or any other component) adds new messages, the suggestions dropdown is automatically cleared.

Summary of Changes

File: components/chat.tsx

  1. Line 115: Wrap setIsSubmitting(true) in setTimeout(() => {...}, 0)
  2. Line 156: Wrap setIsSubmitting(true) in setTimeout(() => {...}, 0)
  3. After line 51: Add new useEffect to clear suggestions when messages change

These two fixes will:

  • ✅ Allow chat input to accept text after resolution search
  • ✅ Hide suggestions dropdown when responses arrive
  • ✅ Fix the unresponsive input issue

Would you like me to create a plan to implement these changes?

@ngoiyaeric
Copy link
Collaborator Author

image @coderabbitai the suggested prompts are being displayed even after response

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 7, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the current chat.tsx to check suggestions management
cat components/chat.tsx

Length of output: 7375


@ngoiyaeric The screenshot confirms the issue—the suggestions dropdown is still visible after a response. I can see the fix is not yet implemented in the code.

Root Cause

When new messages arrive (like after a resolution search response), nothing clears the suggestions state. The dropdown remains visible because setSuggestions(null) is never called.

Fix Required

In components/chat.tsx, add this useEffect immediately after line 51:

useEffect(() => {
  setShowEmptyScreen(messages.length === 0)
}, [messages])

// Add this new effect to clear suggestions when messages arrive:
useEffect(() => {
  if (messages.length > 0) {
    setSuggestions(null)
  }
}, [messages])

This ensures that whenever the messages array updates (which happens when responses arrive), the suggestions dropdown is automatically hidden.

Additional Issue to Fix

While you're editing this file, also fix line 115 where the setTimeout is commented but not implemented:

Current (line 112-117):

onSelect={query => {
  setInput(query)
  setSuggestions(null)
  // Use a small timeout to ensure state update before submission
  setIsSubmitting(true)  // ❌ Should be wrapped in setTimeout
}}

Should be:

onSelect={query => {
  setInput(query)
  setSuggestions(null)
  // Use a small timeout to ensure state update before submission
  setTimeout(() => {
    setIsSubmitting(true)
  }, 0)
}}

These two changes will:
✅ Hide suggestions dropdown when responses arrive
✅ Fix the race condition preventing chat input from working properly

Would you like me to create a plan to implement both fixes?

Copy link
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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
components/chat.tsx (1)

52-61: 🧹 Nitpick | 🔵 Trivial

Consider merging the two useEffect hooks that share the same [messages] dependency.

Lines 52–54 and 57–61 both react to messages changes. They can be combined into a single effect for clarity.

♻️ Proposed merge
- useEffect(() => {
-   setShowEmptyScreen(messages.length === 0)
- }, [messages])
-
- // Clear suggestions when a response is received or messages change
- useEffect(() => {
-   if (messages.length > 0) {
-     setSuggestions(null)
-   }
- }, [messages])
+ useEffect(() => {
+   setShowEmptyScreen(messages.length === 0)
+   if (messages.length > 0) {
+     setSuggestions(null)
+   }
+ }, [messages])
components/chat-panel.tsx (1)

235-278: ⚠️ Potential issue | 🟡 Minor

Textarea lacks overflow-wrap handling — long unbroken text will overflow.

As noted in PR comments, the Textarea has no break-words or overflow-wrap styling. Long URLs or unbroken strings will overflow the input container.

🛠️ Proposed fix
             className={cn(
-              'resize-none w-full min-h-12 rounded-fill border border-input pl-14 pr-12 pt-3 pb-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
+              'resize-none w-full min-h-12 rounded-fill border border-input pl-14 pr-12 pt-3 pb-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 break-words [overflow-wrap:anywhere]',
app/actions.tsx (2)

140-150: ⚠️ Potential issue | 🟡 Minor

sanitizedHistory is computed but never used — dead code.

sanitizedHistory replaces image parts with "IMAGE_PROCESSED" placeholders but is never referenced after construction. Only sanitizedMessages (line 129) is passed to querySuggestor on line 151.

🧹 Remove dead code
-        const sanitizedHistory = currentMessages.map((m: any) => {
-          if (m.role === "user" && Array.isArray(m.content)) {
-            return {
-              ...m,
-              content: m.content.map((part: any) =>
-                part.type === "image" ? { ...part, image: "IMAGE_PROCESSED" } : part
-              )
-            }
-          }
-          return m
-        });

160-194: ⚠️ Potential issue | 🟠 Major

Storing full base64 data URLs for up to three images in AI state risks significant storage bloat.

Lines 175–177 embed dataUrl, mapboxDataUrl, and googleDataUrl (all base64-encoded screenshots) inside the resolution_search_result message content. This JSON blob is persisted to the database via onSetAIStatesaveChat. Each screenshot can be hundreds of KB to several MB in base64, so a single resolution search could add multiple megabytes to the stored chat. Over time this will degrade database performance and increase storage costs.

Consider storing images in object storage (e.g., S3/R2) and saving only the URL references in AI state, or at minimum compress/resize before base64-encoding.

🤖 Fix all issues with AI agents
In `@app/actions.tsx`:
- Around line 54-56: The current fallback logic sets file =
(formData?.get('file') as File) || file_mapbox || file_google, which causes
dataUrl to duplicate mapboxDataUrl when file_mapbox is used and results in
storing the same base64 twice (image and mapboxImage). Fix by deriving a single
primaryDataUrl based on which source was actually chosen: if file ===
file_mapbox use mapboxDataUrl as primaryDataUrl (avoid re-reading/encoding the
same file), otherwise compute dataUrl for the chosen file; then store
primaryDataUrl in the result.image and keep mapboxDataUrl only in
result.mapboxImage. Make the change around the variables file, file_mapbox,
file_google, dataUrl, mapboxDataUrl, image and mapboxImage to ensure no
duplicate base64 strings are produced.
- Around line 231-234: The product definition strings are hardcoded in the
server action (variable definition computed from userInput) making marketing
copy brittle; extract both product descriptions and their pricing URLs into a
configuration source (e.g., a constants/config object or CMS-backed content) and
replace the inline literals in the userInput handling logic with references to
those keys; update the branch that sets definition (the conditional using
userInput.toLowerCase().trim() and the variable definition) to look up the
appropriate config entry (e.g., PRODUCT_DEFINITIONS['planet_computer'] or via a
content service) so copy and links can be edited without changing
app/actions.tsx.

In `@components/resolution-carousel.tsx`:
- Line 53: The file name and MIME/type are mismatched: compressImage (via
canvas.toBlob with 'image/jpeg') produces a JPEG blob but the code appends it as
'google_analysis.png' in formData.append; update the formData append to use a
matching filename/extension (e.g., 'google_analysis.jpg') or derive the
extension from the blob.type before calling formData.append('file', blob,
filename) so the appended filename matches the blob MIME (refer to
compressImage, formData.append('file', blob, 'google_analysis.png') and the
canvas.toBlob('image/jpeg') usage).
📜 Review details

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 66ae964 and 290929a.

📒 Files selected for processing (4)
  • app/actions.tsx
  • components/chat-panel.tsx
  • components/chat.tsx
  • components/resolution-carousel.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
components/resolution-carousel.tsx (6)
app/actions.tsx (1)
  • AI (582-661)
lib/utils/image-utils.ts (1)
  • compressImage (4-51)
components/user-message.tsx (1)
  • UserMessage (15-53)
components/resolution-image.tsx (1)
  • ResolutionImage (22-60)
components/compare-slider.tsx (1)
  • CompareSlider (12-91)
lib/agents/resolution-search.tsx (1)
  • resolutionSearch (33-79)
app/actions.tsx (1)
lib/agents/resolution-search.tsx (3)
  • resolutionSearch (41-91)
  • resolutionSearch (33-79)
  • DrawnFeature (26-31)
🔇 Additional comments (3)
components/chat.tsx (1)

119-124: The setInputsetIsSubmitting sequence relies on React batching — confirm this works reliably.

setInput(query) followed immediately by setIsSubmitting(true) depends on React 18+ batching both updates into a single render, after which the useEffect on isSubmitting (line 96) fires submitForm. Because the ChatPanel receives input as a prop and handleSubmit reads that prop, the sequencing should be correct under React 18+ automatic batching. The PR comments flagged a race condition here from a prior version that used setTimeout; the current approach with the useEffect intermediary is functionally equivalent and avoids the race.

components/chat-panel.tsx (1)

105-105: Good addition — clearing suggestions on submit prevents stale dropdown state.

app/actions.tsx (1)

743-758: getUIStateFromAIState correctly reconstructs ResolutionCarousel from stored image fields.

The mapping reads mapboxImage and googleImage from the parsed analysis result and passes them to ResolutionCarousel alongside initialImage. This aligns with the storage format established in the submit function.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +54 to +56
const file_mapbox = formData?.get('file_mapbox') as File;
const file_google = formData?.get('file_google') as File;
const file = (formData?.get('file') as File) || file_mapbox || file_google;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

File fallback creates redundant data: when file falls back to file_mapbox, dataUrl duplicates mapboxDataUrl.

Line 56 falls back: const file = (formData?.get('file') as File) || file_mapbox || file_google. If no explicit file is provided, file becomes file_mapbox, and line 74 converts it to dataUrl — which is identical to mapboxDataUrl (line 68). The result JSON then stores the same base64 string twice under image and mapboxImage, doubling the storage cost for that image.

♻️ Suggested approach
     const buffer = await file.arrayBuffer();
     const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`;
+    // Avoid duplicate: if file fell back to mapbox/google, reuse that data URL
+    const primaryDataUrl = (file === file_mapbox && mapboxDataUrl)
+      ? mapboxDataUrl
+      : (file === file_google && googleDataUrl)
+        ? googleDataUrl
+        : dataUrl;

Then use primaryDataUrl as the image field in the stored result instead of dataUrl.

Also applies to: 67-74

🤖 Prompt for AI Agents
In `@app/actions.tsx` around lines 54 - 56, The current fallback logic sets file =
(formData?.get('file') as File) || file_mapbox || file_google, which causes
dataUrl to duplicate mapboxDataUrl when file_mapbox is used and results in
storing the same base64 twice (image and mapboxImage). Fix by deriving a single
primaryDataUrl based on which source was actually chosen: if file ===
file_mapbox use mapboxDataUrl as primaryDataUrl (avoid re-reading/encoding the
same file), otherwise compute dataUrl for the chosen file; then store
primaryDataUrl in the result.image and keep mapboxDataUrl only in
result.mapboxImage. Make the change around the variables file, file_mapbox,
file_google, dataUrl, mapboxDataUrl, image and mapboxImage to ensure no
duplicate base64 strings are produced.

Comment on lines +231 to 234
if (userInput && (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?')) {
const definition = userInput.toLowerCase().trim() === 'what is a planet computer?'
? `A planet computer is a proprietary environment aware system that interoperates weather forecasting, mapping and scheduling using cutting edge multi-agents to streamline automation and exploration on a planet. Available for our Pro and Enterprise customers. [QCX Pricing](https://www.queue.cx/#pricing)`

: `QCX-Terra is a model garden of pixel level precision geospatial foundational models for efficient land feature predictions from satellite imagery. Available for our Pro and Enterprise customers. [QCX Pricing] (https://www.queue.cx/#pricing)`;
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Hardcoded product definition strings with inline marketing copy are fragile and difficult to maintain.

These definitions (lines 231–234) are hardcoded with pricing links and product descriptions. Consider moving them to a configuration file or CMS to keep marketing content out of server action logic.

🤖 Prompt for AI Agents
In `@app/actions.tsx` around lines 231 - 234, The product definition strings are
hardcoded in the server action (variable definition computed from userInput)
making marketing copy brittle; extract both product descriptions and their
pricing URLs into a configuration source (e.g., a constants/config object or
CMS-backed content) and replace the inline literals in the userInput handling
logic with references to those keys; update the branch that sets definition (the
conditional using userInput.toLowerCase().trim() and the variable definition) to
look up the appropriate config entry (e.g.,
PRODUCT_DEFINITIONS['planet_computer'] or via a content service) so copy and
links can be edited without changing app/actions.tsx.

])

const formData = new FormData()
formData.append('file', blob, 'google_analysis.png')
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Filename/MIME-type mismatch: compressed blob is JPEG but named .png.

compressImage (from lib/utils/image-utils.ts) produces a JPEG blob via canvas.toBlob(..., 'image/jpeg', quality), but the file is appended as 'google_analysis.png'. This could confuse downstream consumers expecting the extension to match the actual format.

🛠️ Proposed fix
-      formData.append('file', blob, 'google_analysis.png')
+      formData.append('file', blob, 'google_analysis.jpg')
📝 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
formData.append('file', blob, 'google_analysis.png')
formData.append('file', blob, 'google_analysis.jpg')
🤖 Prompt for AI Agents
In `@components/resolution-carousel.tsx` at line 53, The file name and MIME/type
are mismatched: compressImage (via canvas.toBlob with 'image/jpeg') produces a
JPEG blob but the code appends it as 'google_analysis.png' in formData.append;
update the formData append to use a matching filename/extension (e.g.,
'google_analysis.jpg') or derive the extension from the blob.type before calling
formData.append('file', blob, filename) so the appended filename matches the
blob MIME (refer to compressImage, formData.append('file', blob,
'google_analysis.png') and the canvas.toBlob('image/jpeg') usage).

@ngoiyaeric ngoiyaeric force-pushed the feat/dual-image-resolution-search-14866169086758664624 branch from 290929a to 66ae964 Compare February 8, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1.0.0 Standard release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants