Skip to content

Merge Resolution Search Enhancement (f89e3f7) into Main#490

Open
ngoiyaeric wants to merge 46 commits intomainfrom
feat/merge-resolution-enhancement-8671537456349162349
Open

Merge Resolution Search Enhancement (f89e3f7) into Main#490
ngoiyaeric wants to merge 46 commits intomainfrom
feat/merge-resolution-enhancement-8671537456349162349

Conversation

@ngoiyaeric
Copy link
Collaborator

@ngoiyaeric ngoiyaeric commented Feb 4, 2026

User description

This pull request merges the enhancements from commit f89e3f7 into the main branch. These changes improve the resolution search capability by integrating user-drawn map features into the AI's context and providing better visual feedback via the new ResolutionImage component. It also fixes non-clickable follow-up queries and refactors the suggestions UI for a more fluid user experience.


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


PR Type

Enhancement


Description

  • Integrate drawn map features into AI context for spatial awareness

  • Add ResolutionImage component for visual feedback in chat

  • Refactor suggestions UI for better interactivity and accessibility

  • Include map imagery in resolution search results and tool outputs

  • Pass drawn features to researcher agent for improved analysis


Diagram Walkthrough

flowchart LR
  A["User draws on map"] -->|drawnFeatures| B["Form submission"]
  B -->|includes drawnFeatures| C["Resolution Search"]
  C -->|spatial context| D["Researcher Agent"]
  D -->|enhanced analysis| E["Response with map data"]
  E -->|geoJson + image| F["ResolutionImage component"]
  F -->|visual display| G["Chat UI"]
  H["Suggestions UI"] -->|refactored| I["Better interactivity"]
Loading

File Walkthrough

Relevant files
Enhancement
9 files
actions.tsx
Integrate drawn features and add ResolutionImage display 
+45/-33 
chat-panel.tsx
Include drawn features in form submission                               
+3/-3     
chat.tsx
Refactor suggestions rendering for better UX                         
+46/-53 
followup-panel.tsx
Add drawn features to followup query submission                   
+5/-3     
header-search-button.tsx
Include drawn features in resolution search request           
+3/-2     
resolution-image.tsx
New component for displaying map imagery with modal           
+60/-0   
search-related.tsx
Refactor to use click handlers instead of form submission
+10/-25 
researcher.tsx
Add drawn features to system prompt context                           
+9/-3     
geospatial.tsx
Generate Google Static Map URLs for query results               
+20/-1   
Formatting
1 files
chat-history-client.tsx
Minor whitespace formatting cleanup                                           
+1/-1     

Summary by CodeRabbit

  • New Features

    • Image preview/comparison modal for map imagery; inline thumbnail previews in responses.
    • Multimodal map uploads (Mapbox, Google, legacy) with image-backed search results.
    • User-drawn features included in geospatial searches and displayed alongside results.
    • Map preview shown before map query execution.
  • Improvements

    • Suggestions and related-query interactions streamlined for click-driven submission.
    • UI now renders images and GeoJSON together; resolution searches run in background with immediate previews.
    • Better error logging and user-facing error messages for capture/processing failures.

CJWTRUST and others added 30 commits January 20, 2026 01:20
Restored the branch to the expected head at commit 488b47c.
This recovers several missing features and architectural improvements:
- Integrated HistoryToggleProvider and HistorySidebar.
- Integrated UsageToggleProvider and the new UsageView component.
- Removed legacy usage-sidebar.tsx.
- Restored pricing and feature updates in PurchaseCreditsPopup.
- Fixed layout structure in app/layout.tsx to support these new global state providers.

Resolved previous merge conflicts and incorrect force-push state.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Reset branch HEAD to historical recovery point 488b47c.
- Applied latest code fixes from orphan commit 166aee9, including Mapbox rendering optimizations and GeoJSON streaming.
- Improved type safety in app/actions.tsx by removing 'as any' casts and properly typing message arrays.
- Refined getModel utility to support vision-aware model fallback.
- Documented non-blocking background task pattern in server actions.
- Updated feature/billing-integration branch with the restored state.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Reset branch HEAD to historical recovery point 488b47c.
- Applied latest code fixes from orphan commit 166aee9.
- Resolved TypeScript build error in app/actions.tsx by casting GeoJSON data to FeatureCollection.
- Improved type safety for AIState message filtering and content handling.
- Implemented background processing for resolution search with immediate UI streaming.
- Optimized chat history fetching to trigger only when the sidebar is open.
- Ensured Mapbox style loading is robust against re-renders.
- Cleaned up dead code and improved documentation in server actions.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Reset branch HEAD to historical recovery point 488b47c.
- Applied latest code fixes from orphan commit 166aee9.
- Resolved TypeScript build error in app/actions.tsx by casting GeoJSON data to FeatureCollection.
- Improved type safety for AIState message filtering and content handling.
- Implemented background processing for resolution search with immediate UI streaming.
- Re-enabled auto-opening of the pricing popup in components/header.tsx.
- Optimized chat history fetching to trigger only when the sidebar is open.
- Ensured Mapbox style loading is robust against re-renders.
- Cleaned up dead code and improved documentation in server actions.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Restored historical state from 488b47c and merged latest changes.
- Implemented streaming for resolution search in lib/agents/resolution-search.tsx and app/actions.tsx to improve response time.
- Re-enabled auto-opening pricing popup in components/header.tsx.
- Resolved all TypeScript build errors in app/actions.tsx.
- Restored Grok model support for vision tasks in lib/utils/index.ts.
- Optimized chat history loading in components/sidebar/chat-history-client.tsx.
- Improved Mapbox style loading robustness in components/map/mapbox-map.tsx.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Synchronized branch with origin/main, prioritizing main's code structure and latest features.
- Bridged the history with historical recovery point 488b47c.
- Implemented streaming for resolution search in lib/agents/resolution-search.tsx and app/actions.tsx to resolve performance issues.
- Restored the auto-opening Pricing Popup and Usage View in the Header component.
- Integrated the Timezone Clock and time context into the restored resolution search logic.
- Resolved TypeScript build errors with proper type casting and fixed a missing 'use client' directive in components/history.tsx.
- Ensured all required providers (History, Usage, etc.) are correctly wrapped in the root layout.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Synchronized branch with origin/main, prioritizing main's code structure and latest features.
- Bridged the history with historical recovery point 488b47c.
- Integrated UsageView and billing UI into the Chat component, toggled by the tent tree icon.
- Implemented streaming for resolution search in lib/agents/resolution-search.tsx and app/actions.tsx for better performance.
- Restored the auto-opening Pricing Popup in the Header component.
- Improved type safety across server actions and Mapbox components.
- Ensured mutual exclusion between Settings, Usage, and Map views.
- Fixed a missing 'use client' directive in components/history.tsx.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Integrated UsageView into the main Chat component for both mobile and desktop.
- Ensured mutual exclusion between Settings, Usage, and Map views in the UI panel.
- Updated ConditionalLottie to hide the loading animation when the Usage View is open.
- Synchronized with origin/main while prioritizing its code structure.
- Maintained historical recovery and performance optimizations for resolution search.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Synchronized with origin/main, prioritizing main branch code and features.
- Fully restored historical context and missing changes from commit 488b47c.
- Integrated Usage and Billing UI (UsageView) into the Chat component.
- Implemented streaming for resolution search analysis to improve perceived performance.
- Re-enabled auto-opening pricing popup in components/header.tsx.
- Refined ConditionalLottie visibility to avoid overlaps with the Usage UI.
- Ensured mutual exclusion between Settings, Usage, and Map views in the side panel.
- Improved type safety across server actions and Mapbox components.
- Resolved build failures related to missing client directives and type mismatches.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
…ability

- Recovered historical state from commit 488b47c and synchronized with main.
- Optimized resolution search by refactoring to stream partial summaries.
- Fixed Mapbox memory leaks by ensuring all event listeners are removed on cleanup.
- Improved StreamableValue stability by initializing with default values.
- Integrated Usage View with mutual exclusion logic and Lottie player visibility fixes.
- Refined model selection for Grok vision and Gemini 1.5 Pro.
- Integrated timezone-aware analysis using tz-lookup.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Fixed Type error in `app/actions.tsx` by casting `content` to `string` in `createStreamableValue`.
- Addressed ESLint warnings in `components/map/mapbox-map.tsx` and `components/chat-panel.tsx` by adding missing dependencies to `useEffect` and `useCallback` hooks.
- Ensured `relatedQueries` streamable value is initialized with an empty state for stability.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Reduced vertical padding in `.mobile-chat-input-area` in `globals.css`.
- Removed redundant `.mobile-chat-input` class and associated styles from `globals.css`.
- Adjusted `ChatPanel` to use more compact padding and a smaller minimum height on mobile.
- Removed excessive left padding (`pl-14`) on the mobile chat input since the attachment button is moved to the icons bar.
- Cleaned up unused mobile-specific CSS classes in `globals.css`.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
…8048805505961867280

Reduce padding in mobile chat input area
Replaced all occurrences of the old Stripe checkout link with the new URL:
https://buy.stripe.com/14A3cv7K72TR3go14Nasg02

Updated files:
- components/mobile-icons-bar.tsx
- components/header.tsx

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
- Updated `EmptyScreen` example buttons to use `whitespace-normal`, `text-left`, and `items-start` for better multi-line support.
- Added `break-words` to `BotMessage` container to prevent horizontal overflow from long strings.
- Improved `SuggestionsDropdown` and `SearchRelated` buttons with wrapping and top-alignment.
- Replaced filename truncation with `break-all` wrapping in `ChatPanel` file attachments.
- Ensured consistent icon alignment for multi-line buttons.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
…911856121500176

Ensure text wrapping for long sentences across UI components
- Optimized resolution search with streaming.
- Improved timezone handling and AI context.
- Fixed UI mutual exclusion and loading state logic.
- Stabilized Mapbox cleanup and event listeners.
- Resolved build errors and ESLint warnings.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
…head features

- Reverted branch to commit 6acfbe5.
- Merged changes from main (e95a25b).
- Preserved Tentree Icon (UsageView logic), History layout (HistoryContainer), and PurchaseCreditsPopup from 6acfbe5 as requested.
- Updated Stripe checkout URL in PurchaseCreditsPopup to the official one (14A3cv7K72TR3go14Nasg02).
- Re-applied bug fixes from 6acfbe5 (type casting in app/actions.tsx, ESLint dependencies in components/map/mapbox-map.tsx and components/chat-panel.tsx, and relatedQueries initialization).
- Verified build and synchronization with main branch features (e.g., resolution search enhancements).

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
…ranch head features

- Reverted branch to commit 6acfbe5 and merged with main (e95a25b).
- Preserved Tentree Icon (Usage View), history layout, and PurchaseCreditsPopup from 6acfbe5.
- Restored branch-specific UI components: Header, History button, Profile toggle, and Usage integration in Chat.
- Synchronized all other logic with main, including resolution search enhancements.
- Fixed an infinite re-render loop in Mapbox map by optimizing useEffect dependencies (removed cameraState from initialization effect).
- Re-applied critical bug fixes from 6acfbe5:
  - Type casting in app/actions.tsx (getUIStateFromAIState).
  - Streamable value initialization for stability.
  - setSuggestions dependency in ChatPanel.
- Updated Stripe checkout URL in PurchaseCreditsPopup to the official link.
- Verified successful build with 'bun run build'.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
ngoiyaeric and others added 9 commits February 2, 2026 15:36
…resh-14763946411330416327

Update Usage View to Yearly Refresh
This commit refactors the desktop header icon container in `components/header.tsx` to ensure all icons are equidistant.

Key changes:
- Changed the icon container from `justify-between w-1/2` to `justify-center flex-1` with a fixed `gap-10`.
- Removed the unused `<div id="timezone-clock-portal" />` which was causing irregular spacing between the TentTree and ModeToggle icons.
- Applied `className="contents"` to the `<div id="header-search-portal" />` so that it doesn't affect the flex layout when empty.
- These changes ensure that icons are always perfectly centered and equidistant regardless of the number of active icons.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
…1736717079589

Standardize Header Icon Spacing
- Modified `mapbox-map.tsx` to only show `NavigationControl` when in `DrawingMode` on desktop.
- Fixed a bug in the mode change `useEffect` where previous state was being updated prematurely, preventing cleanup logic from executing correctly.
- Updated `tests/map.spec.ts` to switch to `DrawingMode` before verifying zoom control visibility.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
…94765400062008

Conditional Map Zoom Controls and Mode Transition Fix
- Merged commit f89e3f7 into main history.
- Ensured components/header-search-button.tsx correctly includes 'drawnFeatures' in search requests.
- Integrated ResolutionImage component for visual feedback in chat.
- Refactored SearchRelated and Chat suggestions UI for better interactivity.
- Included map drawings in researcher agent context for spatial awareness.

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 4, 2026

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

Project Deployment Actions Updated (UTC)
qcx Ready Ready Preview, Comment Feb 4, 2026 1:03pm

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

CLAassistant commented Feb 4, 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 3 committers have signed the CLA.

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

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 4, 2026

Warning

Rate limit exceeded

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

⌛ 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.

📥 Commits

Reviewing files that changed from the base of the PR and between b49f7ca and 8e37c34.

📒 Files selected for processing (10)
  • app/actions.tsx
  • app_actions_diff.patch
  • cleanup_actions.py
  • components/chat.tsx
  • components_chat_patch.patch
  • lib/agents/researcher.tsx
  • lib/agents/resolution-search.tsx
  • lib/utils/index.ts
  • update_actions_v3.py
  • update_researcher.py
🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title references a specific commit hash and branch merge but lacks clarity on the actual feature being delivered; it doesn't convey meaningful information about the enhancement itself. Revise the title to describe the main feature (e.g., 'Add resolution search enhancement with drawn features and map imagery') rather than referencing commit hashes and branch names.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/merge-resolution-enhancement-8671537456349162349

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
Contributor

qodo-code-review bot commented Feb 4, 2026

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
🔴
API key exposure

Description: The new getGoogleStaticMapUrl() constructs a Google Static Maps URL containing the API key
(key=${apiKey}) and this URL is added to mcp_response.mapUrl and rendered client-side
(e.g., via ResolutionImage), which can expose the Google Maps API key to end users and
logs/network traces.
geospatial.tsx [158-412]

Referred Code
function getGoogleStaticMapUrl(latitude: number, longitude: number): string {
  const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || process.env.GOOGLE_MAPS_API_KEY;
  if (!apiKey) return '';
  return `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&zoom=15&size=640x480&scale=2&markers=color:red%7C${latitude},${longitude}&key=${apiKey}`;
}

/**
 * Main geospatial tool executor.
 */
export const geospatialTool = ({
  uiStream,
  mapProvider
}: {
  uiStream: ReturnType<typeof createStreamableUI>
  mapProvider?: MapProvider
}) => ({
  description: `Use this tool for location-based queries including: 
  There a plethora of tools inside this tool accessible on the mapbox mcp server where switch case into the tool of choice for that use case
  If the Query is supposed to use multiple tools in a sequence you must access all the tools in the sequence and then provide a final answer based on the results of all the tools used. 

Static image tool:


 ... (clipped 234 lines)
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

🔴
Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status:
Misspelled identifier: The new identifier groupeId is misspelled and obscures intent compared to a clear name
like groupId.

Referred Code
const groupeId = nanoid();

async function processResolutionSearch() {

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status:
Sensitive data logging: The new console.log outputs mapData.drawnFeatures, potentially logging user-generated
geospatial data that could be sensitive and should not be emitted to logs in production.

Referred Code
console.log('Chat.tsx: drawnFeatures changed, calling updateDrawingContext', mapData.drawnFeatures);
updateDrawingContext(id, {
  drawnFeatures: mapData.drawnFeatures,
  cameraState: mapData.cameraState,
});

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status:
Missing input validation: The new drawnFeatures input is accepted from FormData and passed into
resolutionSearch/researcher after JSON.parse without schema validation or bounds checks.

Referred Code
const drawnFeaturesString = formData?.get('drawnFeatures') as string;
let drawnFeatures: DrawnFeature[] = [];
try {
  drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : [];
} catch (e) {
  console.error('Failed to parse drawnFeatures:', e);
}

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
Unvalidated JSON input: The server action parses drawnFeatures from FormData without validating shape/size, which
may cause downstream errors or performance issues if malformed or oversized input is
submitted.

Referred Code
const drawnFeaturesString = formData?.get('drawnFeatures') as string;
let drawnFeatures: DrawnFeature[] = [];
try {
  drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : [];
} catch (e) {
  console.error('Failed to parse drawnFeatures:', e);
}

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status:
Error details logged: The new error path logs the raw parsing error object for drawnFeatures, which may include
stack/context that should be avoided in user-adjacent logs without sanitization.

Referred Code
try {
  drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : [];
} catch (e) {
  console.error('Failed to parse drawnFeatures:', e);
}

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@qodo-code-review
Copy link
Contributor

qodo-code-review bot commented Feb 4, 2026

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Security
Protect API key by proxying requests

To prevent exposing the Google Maps API key, proxy the static map image request
through a dedicated server-side API route instead of fetching it directly from
the client.

components/header-search-button.tsx [76-82]

-let staticMapUrl = `https://maps.googleapis.com/maps/api/staticmap?center=${center.lat},${center.lng}&zoom=${zoom}&size=640x480&scale=2&maptype=satellite&key=${apiKey}`;
-
-const response = await fetch(staticMapUrl);
+const apiUrl = `/api/map-image?lat=${center.lat}&lng=${center.lng}&zoom=${zoom}`;
+const response = await fetch(apiUrl);
 if (!response.ok) {
-  throw new Error('Failed to fetch static map image.');
+  throw new Error('Failed to fetch static map image via proxy.');
 }
 blob = await response.blob();
  • Apply / Chat
Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies a significant security vulnerability by exposing an API key on the client-side and proposes a robust solution using a server-side proxy.

High
Prioritize secure server-side API key

Reverse the logic for API key retrieval to prioritize the more secure
server-side GOOGLE_MAPS_API_KEY over the public NEXT_PUBLIC_GOOGLE_MAPS_API_KEY.

lib/agents/tools/geospatial.tsx [158-162]

 function getGoogleStaticMapUrl(latitude: number, longitude: number): string {
-  const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || process.env.GOOGLE_MAPS_API_KEY;
+  const apiKey = process.env.GOOGLE_MAPS_API_KEY || process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
   if (!apiKey) return '';
   return `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&zoom=15&size=640x480&scale=2&markers=color:red%7C${latitude},${longitude}&key=${apiKey}`;
 }
  • Apply / Chat
Suggestion importance[1-10]: 8

__

Why: This is a valid security improvement, as it correctly suggests prioritizing the more secure server-side API key over the public client-side key in server-side code.

Medium
High-level
Avoid storing base64 image data
Suggestion Impact:The commit did not implement uploading to object storage or storing URLs, but it did add mitigation to reduce base64 persistence by sanitizing message history (replacing image payloads with placeholders) before saving back into AI state, limiting how much base64 data is retained.

code diff:

@@ -117,11 +147,24 @@
           if (Array.isArray(m.content)) {
             return {
               ...m,
-              content: m.content.filter(part => part.type !== 'image')
+              content: m.content.filter((part: any) => part.type !== 'image')
             } as CoreMessage
           }
           return m
         })
+
+        const currentMessages = aiState.get().messages;
+        const sanitizedHistory = currentMessages.map(m => {
+          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
+        });
 
         const relatedQueries = await querySuggestor(uiStream, sanitizedMessages);
         uiStream.append(
@@ -135,7 +178,7 @@
         aiState.done({
           ...aiState.get(),
           messages: [
-            ...aiState.get().messages,
+            ...sanitizedHistory,
             {
               id: groupeId,
               role: 'assistant',
@@ -147,7 +190,7 @@
               role: 'assistant',
               content: JSON.stringify({
                 ...analysisResult,
-                image: dataUrl
+                image: JSON.stringify({ mapbox: mapboxDataUrl, google: googleDataUrl })
               }),
               type: 'resolution_search_result'
             },

Instead of storing large base64 encoded images in the AI state, upload them to
an object storage service and store the resulting URL. This improves performance
and reduces storage costs.

Examples:

app/actions.tsx [148-151]
              content: JSON.stringify({
                ...analysisResult,
                image: dataUrl
              }),

Solution Walkthrough:

Before:

// app/actions.tsx
async function submit(formData) {
  // ...
  if (action === 'resolution_search') {
    const file = formData.get('file');
    const buffer = await file.arrayBuffer();
    const dataUrl = `data:image/png;base64,...`; // Create base64 data URL
    // ...
    aiState.done({
      ...aiState.get(),
      messages: [
        ...aiState.get().messages,
        {
          type: 'resolution_search_result',
          content: JSON.stringify({ ..., image: dataUrl }) // Store raw base64 data
        },
      ]
    });
  }
}

After:

// app/actions.tsx
import { uploadToStorage } from '@/lib/storage'; // Hypothetical storage utility

async function submit(formData) {
  // ...
  if (action === 'resolution_search') {
    const file = formData.get('file');
    const imageUrl = await uploadToStorage(file); // Upload file and get URL
    // ...
    aiState.done({
      ...aiState.get(),
      messages: [
        ...aiState.get().messages,
        {
          type: 'resolution_search_result',
          content: JSON.stringify({ ..., image: imageUrl }) // Store URL instead
        },
      ]
    });
  }
}
Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies that storing large base64 image data in the AI state is an inefficient architectural choice that will negatively impact performance and storage costs, proposing a valid, standard solution.

Medium
General
Only assign valid static map URLs

Add a check to ensure getGoogleStaticMapUrl returns a non-empty string before
assigning it to mcpData.mapUrl.

lib/agents/tools/geospatial.tsx [410-412]

 if (mapProvider === 'google' && mcpData.location.latitude && mcpData.location.longitude && !mcpData.mapUrl) {
-  mcpData.mapUrl = getGoogleStaticMapUrl(mcpData.location.latitude, mcpData.location.longitude);
+  const url = getGoogleStaticMapUrl(mcpData.location.latitude, mcpData.location.longitude)
+  if (url) {
+    mcpData.mapUrl = url
+  }
 }
  • Apply / Chat
Suggestion importance[1-10]: 7

__

Why: The suggestion adds a useful defensive check to ensure mcpData.mapUrl is not assigned an empty string, which prevents potential issues with rendering an invalid image source.

Medium
Add isGenerating to user message

Add the isGenerating: false property to the userMessage object for UI
consistency.

components/search-related.tsx [32-35]

 const userMessage = {
   id: nanoid(),
+  isGenerating: false,
   component: <UserMessage content={query} />
 }
  • Apply / Chat
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly points out a missing property isGenerating which improves consistency with other message objects in the application, although its absence may not cause an immediate bug.

Low
  • Update

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.

Key issues are around persisting base64 image data in chat state (likely to cause serious performance/storage problems) and a few maintainability pitfalls (typoed groupeId, brittle setTimeout submission ordering). ResolutionImage opts out of Next’s image handling via an eslint disable without a clear need, and SearchRelated now lacks in-flight guards and uses unstable keys. The server-side geospatial helper should prefer server-only API keys over NEXT_PUBLIC_* to avoid accidental reliance on client-exposed secrets.

Additional notes (2)
  • Maintainability | app/actions.tsx:87-87
    The new groupeId (typo) is now created early and used for both the assistant response and the GeoJSON layer id. This improves consistency, but naming is a problem: groupeId is easy to misread and repeated across the file.

This is a maintainability footgun because the id is now used for multiple UI artifacts (message grouping and map layer id), so clarity matters.

  • Maintainability | components/search-related.tsx:58-64
    SearchRelated now uses key={index} and triggers network actions per click. Using the index as key can cause subtle UI issues if the list changes (e.g., streaming updates, reordering). This component is also a perfect place to disable the button while a submit is in-flight to prevent duplicate submissions.

Since you removed pending from useStreamableValue, you currently have no guard against double clicks.

Summary of changes

What this PR changes

  • Adds user-drawn map context to AI requests

    • components/chat-panel.tsx, components/followup-panel.tsx, and components/header-search-button.tsx now append drawnFeatures to FormData.
    • app/actions.tsx parses drawnFeatures once and passes it through to resolutionSearch(...) and researcher(...).
  • Improves resolution search visual feedback

    • New components/resolution-image.tsx shows a clickable preview + dialog for the captured map image.
    • app/actions.tsx renders <ResolutionImage src={dataUrl} /> alongside the streamed bot response.
    • resolution_search_result messages now persist { ...analysisResult, image: dataUrl } so historical UI can re-render the image.
  • Refactors related query click handling

    • components/search-related.tsx switches from a form submit pattern to direct button onClick, generating a FormData with related_query.
  • Enhances geospatial tool outputs for Google

    • lib/agents/tools/geospatial.tsx adds a helper to create Google Static Map URLs and injects mapUrl into MCP responses when provider is google.
    • app/actions.tsx shows the returned map preview image above MapQueryHandler when available.
  • Incorporates drawn features into the default system prompt

    • lib/agents/researcher.tsx includes a summary of drawn features in the default prompt when present.
  • UI suggestion overlay adjustments

    • components/chat.tsx centralizes suggestion overlay rendering via renderSuggestions() and applies blur/pointer-event blocking under the overlay.

Comment on lines 43 to 51
const action = formData?.get('action') as string;
const drawnFeaturesString = formData?.get('drawnFeatures') as string;
let drawnFeatures: DrawnFeature[] = [];
try {
drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : [];
} catch (e) {
console.error('Failed to parse drawnFeatures:', e);
}

Copy link

Choose a reason for hiding this comment

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

drawnFeatures is parsed at the top-level of submit() even for actions that never need it. More importantly, the value is computed from client-provided JSON without any size/shape guardrails. A large payload (or maliciously large JSON) can increase server work and bloat logs/state.

Even if you trust the source, it’s cheap to add a minimal cap (e.g., max length of the raw string / max number of features) and only parse when needed (or when present).

Suggestion

Consider lazy-parsing drawnFeatures only when required (e.g., when action === 'resolution_search' or when you’re about to call researcher(...)), and add basic guardrails:

  • Reject/ignore if drawnFeaturesString.length exceeds a reasonable threshold (e.g., 50–200KB)
  • After parsing, clamp drawnFeatures.length (e.g., max 200) and drop oversized measurement strings

Example sketch:

const rawDrawn = (formData?.get('drawnFeatures') as string) ?? ''
const shouldParse = action === 'resolution_search' || rawDrawn
let drawnFeatures: DrawnFeature[] = []
if (shouldParse && rawDrawn && rawDrawn.length < 200_000) {
  try {
    const parsed = JSON.parse(rawDrawn)
    if (Array.isArray(parsed)) drawnFeatures = parsed.slice(0, 200)
  } catch {
    // ignore
  }
}

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

Comment on lines 145 to 151
{
id: groupeId,
role: 'assistant',
content: JSON.stringify(analysisResult),
content: JSON.stringify({
...analysisResult,
image: dataUrl
}),
Copy link

Choose a reason for hiding this comment

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

You’re storing the full dataUrl (base64 image) into AI state (resolution_search_result). That can massively inflate persisted chat state, slow down UI state hydration, and potentially exceed storage limits depending on your saveChat implementation.

This is particularly risky because static map screenshots can be large, and every resolution search will permanently embed the image bytes into chat history.

Suggestion

Avoid persisting base64 data URLs in chat history. Prefer storing:

  • a short-lived object storage URL (S3/GCS/R2) for the captured image, or
  • a server-generated cache key referencing stored bytes, or
  • omit the image from persisted state and rely on the already-rendered UI stream for the immediate session.

Minimal improvement if you can’t add storage yet: store only the original capture parameters (provider, center/zoom/bounds) and re-fetch on render.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

Comment on lines 706 to 709

return {
id,
component: (
Copy link

Choose a reason for hiding this comment

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

getUIStateFromAIState directly JSON.parses resolution_search_result without a try/catch. If older messages exist (or partial/corrupt content), this will throw during render and can break the entire chat UI.

You already handle backward compatibility in the user message case; this should get similar protection.

Suggestion

Wrap parsing with a guard and fail soft:

case 'resolution_search_result': {
  let analysisResult: any
  try {
    analysisResult = JSON.parse(content as string)
  } catch {
    return { id, component: null }
  }
  const geoJson = analysisResult.geoJson as FeatureCollection
  const image = analysisResult.image as string
  // ...
}

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines 1 to 55
/* eslint-disable @next/next/no-img-element */
'use client'

import React from 'react'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'

interface ResolutionImageProps {
src: string
className?: string
alt?: string
}

export function ResolutionImage({ src, className, alt = 'Map Imagery' }: ResolutionImageProps) {
if (!src) return null

return (
<div className={cn('mt-2 mb-4', className)}>
<Dialog>
<DialogTrigger asChild>
<motion.div
className="w-fit cursor-pointer relative glassmorphic overflow-hidden rounded-lg border bg-muted"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card className="border-none shadow-none bg-transparent">
<CardContent className="p-2">
<img
src={src}
alt={alt}
className="max-w-xs max-h-64 rounded-md object-contain"
/>
</CardContent>
</Card>
</motion.div>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl max-h-[90vh] p-1 glassmorphic border-none">
<DialogHeader className="sr-only">
<DialogTitle>{alt}</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-center h-full">
<img
src={src}
alt={`${alt} Full`}
className="h-auto w-full object-contain max-h-[85vh] rounded-md"
/>
</div>
Copy link

Choose a reason for hiding this comment

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

ResolutionImage disables @next/next/no-img-element and uses raw <img> tags. If this app is on Next.js with the Image Optimization pipeline available, you’re opting out globally for this file without documenting why.

Also, because src may be a remote URL (e.g., mapUrl from the tool) or a data URL, you need to be explicit about security/perf tradeoffs (remote domains, referrer policy, loading behavior).

Suggestion

Prefer next/image when feasible, and only fall back to <img> for data: URLs.

One approach:

  • If src.startsWith('data:') use <img>
  • Else use <Image> with configured images.remotePatterns

Additionally consider adding loading="lazy" to the thumbnail <img> and referrerPolicy="no-referrer" for remote map URLs.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

Comment on lines 106 to 125
const renderSuggestions = () => {
if (!suggestions) return null;
return (
<div className="absolute inset-0 z-20 flex flex-col items-start p-4">
<SuggestionsDropdown
suggestions={suggestions}
onSelect={query => {
setInput(query)
setSuggestions(null)
// Use a small timeout to ensure state update before submission
setTimeout(() => {
setIsSubmitting(true)
}, 0)
}}
onClose={() => setSuggestions(null)}
className="relative bottom-auto mb-0 w-full shadow-none border-none bg-transparent"
/>
</div>
);
};
Copy link

Choose a reason for hiding this comment

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

renderSuggestions() is recreated on every render and closes over state; that’s fine, but the new overlay uses absolute inset-0 with z-20 and the underlying container is now relative min-h-[100px]. On desktop, this risks overlaying only the messages area (not the whole chat column) depending on DOM structure.

More importantly: you’re using setTimeout(..., 0) to submit after setting input. That’s a brittle ordering hack; React state batching and concurrent rendering can still lead to edge cases.

Suggestion

Eliminate the setTimeout submission hack by submitting with the selected query directly (or by using a callback/ref that triggers submission after state commit):

  • Option A: provide ChatPanelRef.submitForm() and call it immediately after setting input (and close suggestions)
  • Option B: keep a pendingSubmitQuery state and useEffect to submit when it changes

This makes follow-up clicks deterministic.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

Comment on lines +155 to +162
/**
* Helper to generate a Google Static Map URL
*/
function getGoogleStaticMapUrl(latitude: number, longitude: number): string {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return '';
return `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&zoom=15&size=640x480&scale=2&markers=color:red%7C${latitude},${longitude}&key=${apiKey}`;
}
Copy link

Choose a reason for hiding this comment

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

getGoogleStaticMapUrl reads NEXT_PUBLIC_GOOGLE_MAPS_API_KEY on the server side and falls back to GOOGLE_MAPS_API_KEY. This mixes client-exposed and server-only secrets. If the server has access to GOOGLE_MAPS_API_KEY, it should prefer that and only use the public key as a last resort.

Also, returning '' when the key is missing means downstream UI silently won’t show previews; that’s fine, but consider logging once (not per request) or surfacing a debug flag.

Suggestion

Prefer server-only env first:

const apiKey = process.env.GOOGLE_MAPS_API_KEY ?? process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY

Optionally, add a single warning log when mapProvider === 'google' and no key is configured.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

@charliecreates charliecreates bot removed the request for review from CharlieHelps February 4, 2026 10:31
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: 4

Caution

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

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

86-86: ⚠️ Potential issue | 🔴 Critical

useMapData is called outside of MapDataProvider context.

The useMapData() hook is invoked at line 86, but the MapDataProvider wrappers are only added inside the mobile (line 130) and desktop (line 178) return blocks. This means the hook is called before the component tree is wrapped with the provider, which will cause a runtime error or return undefined context values.

🐛 Proposed fix: Move MapDataProvider to wrap the entire component or move useMapData inside the conditional branches

Option 1 - Wrap the entire component with MapDataProvider at a higher level, or:

Option 2 - Create a wrapper component:

-export function Chat({ id }: ChatProps) {
+function ChatContent({ id }: ChatProps) {
   const router = useRouter()
   // ... rest of component logic
+}
+
+export function Chat({ id }: ChatProps) {
+  return (
+    <MapDataProvider>
+      <ChatContent id={id} />
+    </MapDataProvider>
+  );
 }

This ensures useMapData() is always called within the provider context.

🤖 Fix all issues with AI agents
In `@app/actions.tsx`:
- Line 88: Rename the misspelled variable groupeId to groupId throughout the
file: change the declaration const groupeId = nanoid() to const groupId =
nanoid() and update every usage/reference (including the other occurrence noted
and any functions or JSX that consume it) to the new name to avoid runtime
undefined errors; ensure related imports/exports, state hooks, and props that
referenced groupeId are updated to groupId as well and run type checks/compile
to confirm no remaining references.

In `@components/resolution-image.tsx`:
- Around line 28-44: The DialogTrigger currently wraps a non-focusable
motion.div which prevents keyboard users from opening the dialog; update the
trigger to be keyboard accessible by replacing the motion.div with a focusable
element (e.g., use motion.button as the child of DialogTrigger or wrap a
<button> with motion props) or add role="button", tabIndex={0} and appropriate
onKeyDown handlers to the motion element; change the element used in the
DialogTrigger (look for DialogTrigger and the motion.div in ResolutionImage
component) so it becomes focusable and responds to Enter/Space to open the
dialog.

In `@lib/agents/tools/geospatial.tsx`:
- Around line 409-412: The current guard ignores valid 0 coordinates because it
uses truthy checks; update the condition in the block that sets mcpData.mapUrl
(checking mapProvider, mcpData.location.latitude, mcpData.location.longitude,
and mcpData.mapUrl) to explicitly test for null/undefined or use Number.isFinite
on mcpData.location.latitude and mcpData.location.longitude so
latitude/longitude of 0 are accepted, then call getGoogleStaticMapUrl as before.
- Around line 155-162: The getGoogleStaticMapUrl function currently falls back
to process.env.GOOGLE_MAPS_API_KEY which can expose a server-only secret in
client-rendered URLs; remove that fallback so the function only reads
process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY, and if it's missing return an empty
string (i.e., update getGoogleStaticMapUrl to use solely
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY and do not reference GOOGLE_MAPS_API_KEY
anywhere).
📜 Review details

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c9bd9b2 and 9ba4dfd.

📒 Files selected for processing (10)
  • app/actions.tsx
  • components/chat-panel.tsx
  • components/chat.tsx
  • components/followup-panel.tsx
  • components/header-search-button.tsx
  • components/resolution-image.tsx
  • components/search-related.tsx
  • components/sidebar/chat-history-client.tsx
  • lib/agents/researcher.tsx
  • lib/agents/tools/geospatial.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-01-20T10:04:04.963Z
Learnt from: ngoiyaeric
Repo: QueueLab/QCX PR: 0
File: :0-0
Timestamp: 2026-01-20T10:04:04.963Z
Learning: The application has a comprehensive credits and payment UI system with: (1) PurchaseCreditsPopup that shows after user authentication with a 7-day cooldown, (2) History panel on the left side containing CreditsDisplay at the top and ChatHistoryClient below, (3) TentTree icon in header that opens UsageSidebar on the right side showing usage history and billing options. All these components are coordinated through RootLayout and only render for authenticated users.

Applied to files:

  • components/sidebar/chat-history-client.tsx
🧬 Code graph analysis (5)
components/header-search-button.tsx (1)
components/map/google-map.tsx (2)
  • features (38-52)
  • df (39-46)
components/chat-panel.tsx (5)
components/map/mapbox-map.tsx (5)
  • feature (79-165)
  • prevData (167-167)
  • feature (301-303)
  • map (67-168)
  • map (240-308)
components/map/google-map.tsx (2)
  • features (38-52)
  • df (39-46)
components/map/map-data-context.tsx (1)
  • MapData (15-32)
components/map/map-query-handler.tsx (1)
  • prevData (39-48)
components/map/google-geojson-layer.tsx (1)
  • feature (27-44)
app/actions.tsx (4)
lib/agents/resolution-search.tsx (2)
  • DrawnFeature (26-31)
  • resolutionSearch (33-79)
components/map/geojson-layer.tsx (1)
  • GeoJsonLayer (13-100)
components/resolution-image.tsx (1)
  • ResolutionImage (22-60)
components/map/map-query-handler.tsx (3)
  • MapQueryHandler (30-82)
  • GeospatialToolOutput (18-23)
  • prevData (39-48)
components/chat.tsx (5)
components/suggestions-dropdown.tsx (1)
  • SuggestionsDropdown (16-116)
lib/utils/index.ts (1)
  • cn (12-14)
components/empty-screen.tsx (1)
  • EmptyScreen (27-59)
components/chat-messages.tsx (1)
  • ChatMessages (11-70)
lib/db/schema.ts (1)
  • messages (26-37)
components/search-related.tsx (2)
lib/schema/related.tsx (1)
  • PartialRelated (13-13)
components/copilot.tsx (1)
  • e (70-90)
🔇 Additional comments (17)
components/sidebar/chat-history-client.tsx (1)

124-136: No concerns with the layout-only change.

Spacing-only adjustment, no behavioral impact detected.

components/search-related.tsx (1)

28-57: Solid switch to click-driven related queries.

Clean handler flow with explicit FormData and nanoid IDs; no issues spotted.

components/header-search-button.tsx (1)

76-94: Changes look good.

Static map scaling and drawnFeatures payload addition are consistent and safe.

components/chat-panel.tsx (1)

118-120: Drawn-features payload inclusion looks correct.

No issues with the FormData augmentation.

components/followup-panel.tsx (1)

28-30: Drawn-features inclusion is consistent with the new flow.

No concerns here.

lib/agents/researcher.tsx (3)

15-15: LGTM!

Import of DrawnFeature type is correctly added to support the new functionality.


20-27: LGTM!

The system prompt enhancement is well-implemented:

  • Cleanly extends the function signature with an optional parameter
  • Conditionally includes drawn features only when present
  • Template literal formatting is appropriate for multi-line prompt construction

88-89: LGTM!

The drawnFeatures parameter is correctly:

  • Added as an optional parameter at the end of the function signature
  • Passed through to getDefaultSystemPrompt when building the system prompt
  • Only used when a custom dynamic prompt is not provided (line 102-105)

Also applies to: 105-105

components/chat.tsx (2)

106-125: LGTM!

The renderSuggestions helper cleanly centralizes the suggestions dropdown logic:

  • Properly guards against null suggestions
  • Uses consistent timeout pattern for state synchronization before submission
  • Correctly wires up onSelect and onClose handlers

151-167: LGTM!

The layout refactoring is well-structured:

  • Consistent pattern applied to both mobile and desktop layouts
  • Blur effect on background content provides clear visual feedback
  • renderSuggestions() helper eliminates code duplication

Also applies to: 193-208

app/actions.tsx (7)

44-50: LGTM!

Robust error handling for JSON parsing of drawnFeatures:

  • Initializes to empty array before parsing
  • Catches parse errors gracefully with console logging
  • Falls back to empty array on failure

105-112: LGTM!

GeoJsonLayer is correctly appended when analysisResult.geoJson exists, with proper null check and unique ID assignment.


148-151: LGTM!

The image data URL is properly included in the resolution_search_result content, enabling the UI state reconstruction to display the image preview later via getUIStateFromAIState.


179-184: LGTM!

ResolutionImage is correctly rendered in the initial response section to provide immediate visual feedback of the uploaded image alongside the streaming bot message.


410-411: LGTM!

The drawnFeatures parameter is correctly passed to the researcher function, completing the data flow from form submission through to the AI agent's system prompt.


702-718: LGTM!

The resolution_search_result case correctly:

  • Parses the stored JSON content
  • Extracts both geoJson and image fields
  • Conditionally renders ResolutionImage and GeoJsonLayer components

731-749: LGTM!

The MAP_QUERY_TRIGGER handling is enhanced with:

  • Proper extraction of mapUrl and place_name using optional chaining
  • Conditional rendering of ResolutionImage with descriptive alt text
  • MapQueryHandler remains as the primary functional component

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


// Create a streamable value for the summary.
const summaryStream = createStreamableValue<string>('');
const groupeId = nanoid();
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

Consider renaming groupeId to groupId for clarity.

The variable name groupeId appears to be a typo or non-standard spelling. Using groupId would align with common English naming conventions and improve readability.

✏️ Suggested rename
-    const groupeId = nanoid();
+    const groupId = nanoid();

Apply the same change at line 203 and update all references.

🤖 Prompt for AI Agents
In `@app/actions.tsx` at line 88, Rename the misspelled variable groupeId to
groupId throughout the file: change the declaration const groupeId = nanoid() to
const groupId = nanoid() and update every usage/reference (including the other
occurrence noted and any functions or JSX that consume it) to the new name to
avoid runtime undefined errors; ensure related imports/exports, state hooks, and
props that referenced groupeId are updated to groupId as well and run type
checks/compile to confirm no remaining references.

Comment on lines 28 to 44
<DialogTrigger asChild>
<motion.div
className="w-fit cursor-pointer relative glassmorphic overflow-hidden rounded-lg border bg-muted"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card className="border-none shadow-none bg-transparent">
<CardContent className="p-2">
<img
src={src}
alt={alt}
className="max-w-xs max-h-64 rounded-md object-contain"
/>
</CardContent>
</Card>
</motion.div>
</DialogTrigger>
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

Make the dialog trigger keyboard-accessible.

DialogTrigger wraps a motion.div, which is not focusable by default, so keyboard users can’t open the dialog. Use a button (or add role/tabIndex + key handlers) to meet accessibility requirements.

♿ Suggested fix
-        <DialogTrigger asChild>
-          <motion.div
+        <DialogTrigger asChild>
+          <motion.button
+            type="button"
             className="w-fit cursor-pointer relative glassmorphic overflow-hidden rounded-lg border bg-muted"
             whileHover={{ scale: 1.02 }}
             whileTap={{ scale: 0.98 }}
           >
             <Card className="border-none shadow-none bg-transparent">
               <CardContent className="p-2">
                 <img
                   src={src}
                   alt={alt}
                   className="max-w-xs max-h-64 rounded-md object-contain"
                 />
               </CardContent>
             </Card>
-          </motion.div>
+          </motion.button>
         </DialogTrigger>
📝 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
<DialogTrigger asChild>
<motion.div
className="w-fit cursor-pointer relative glassmorphic overflow-hidden rounded-lg border bg-muted"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card className="border-none shadow-none bg-transparent">
<CardContent className="p-2">
<img
src={src}
alt={alt}
className="max-w-xs max-h-64 rounded-md object-contain"
/>
</CardContent>
</Card>
</motion.div>
</DialogTrigger>
<DialogTrigger asChild>
<motion.button
type="button"
className="w-fit cursor-pointer relative glassmorphic overflow-hidden rounded-lg border bg-muted"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card className="border-none shadow-none bg-transparent">
<CardContent className="p-2">
<img
src={src}
alt={alt}
className="max-w-xs max-h-64 rounded-md object-contain"
/>
</CardContent>
</Card>
</motion.button>
</DialogTrigger>
🤖 Prompt for AI Agents
In `@components/resolution-image.tsx` around lines 28 - 44, The DialogTrigger
currently wraps a non-focusable motion.div which prevents keyboard users from
opening the dialog; update the trigger to be keyboard accessible by replacing
the motion.div with a focusable element (e.g., use motion.button as the child of
DialogTrigger or wrap a <button> with motion props) or add role="button",
tabIndex={0} and appropriate onKeyDown handlers to the motion element; change
the element used in the DialogTrigger (look for DialogTrigger and the motion.div
in ResolutionImage component) so it becomes focusable and responds to
Enter/Space to open the dialog.

Comment on lines +155 to +162
/**
* Helper to generate a Google Static Map URL
*/
function getGoogleStaticMapUrl(latitude: number, longitude: number): string {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return '';
return `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&zoom=15&size=640x480&scale=2&markers=color:red%7C${latitude},${longitude}&key=${apiKey}`;
}
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

🧩 Analysis chain

🏁 Script executed:

# Find context around getGoogleStaticMapUrl usage
rg -B 3 -A 3 "getGoogleStaticMapUrl" lib/agents/tools/geospatial.tsx

Repository: QueueLab/QCX

Length of output: 1054


🏁 Script executed:

# Check if mapUrl is sent in mcp_response and to client
rg "mapUrl" lib/agents/tools/geospatial.tsx -B 2 -A 2

Repository: QueueLab/QCX

Length of output: 1421


🏁 Script executed:

# Search for mcp_response and how it's handled
rg "mcp_response" lib/agents/tools/geospatial.tsx -B 5 -A 5

Repository: QueueLab/QCX

Length of output: 2103


🏁 Script executed:

# Check for any other GOOGLE_MAPS_API_KEY usage in codebase
rg "GOOGLE_MAPS_API_KEY" --type tsx --type ts

Repository: QueueLab/QCX

Length of output: 83


🏁 Script executed:

# Check environment variable patterns in codebase
rg "NEXT_PUBLIC_GOOGLE_MAPS" --type tsx --type ts -B 2 -A 2

Repository: QueueLab/QCX

Length of output: 83


🌐 Web query:

Google Static Maps API key security best practices client-side exposure

💡 Result:

Static Maps API key security when the key is exposed client-side (e.g., in an <img> URL)

  1. Assume the API key will be public (anyone can view/copy it from page source/network). Instead of trying to “hide” it, lock it down with restrictions. [1]

  2. Apply both restriction types to the key used for Static Web APIs:

    • Website restriction (HTTP referrers): allow only your domains.
    • API restriction: allow only Maps Static API (and nothing else).
      Google explicitly recommends applying both when the key is publicly exposed on a web page (like an <img> tag). [1]
  3. Use a separate key per app/site (and per environment) (dev/staging/prod) to limit blast radius and simplify rotation. [1]

  4. Use URL signing (digital signature) and keep the signing secret server-side

    • Google strongly recommends using an API key + digital signature for Maps Static API. [2]
    • Never sign in client-side JavaScript and never embed the URL signing secret in client code or your repo; instead generate signed URLs on your server (or via a secure proxy) and return/render the final signed URL. [1]
  5. Limit/disable unsigned requests

    • In Google Cloud Console quotas for Maps Static API, reduce “Unsigned requests” quota (commonly to 0) so only signed requests succeed. [2]
  6. Operational hygiene

    • Delete unused keys/services, monitor key usage, and rotate keys carefully (rotation can break deployed clients if not planned). [1]
    • If you also have server-side Maps usage, split client vs server usage into separate projects/keys, and use appropriate restrictions (server keys typically use IP restrictions; web uses referrers). [1], [3]

Sources: Google Maps Platform API security best practices [1], Maps Static API digital signature guidance (incl. limiting unsigned requests) [2], Google Maps Platform FAQ on restrictions & Static Maps signatures [3].


🌐 Web query:

NEXT_PUBLIC environment variables security Next.js API key exposure

💡 Result:

  • In Next.js, only env vars prefixed with NEXT_PUBLIC_ are exposed to the browser; Next.js inlines (hard-codes) their values into the client JavaScript bundle at build time (next build). Anyone can view them by inspecting the shipped JS. [1]
  • Therefore, never put secrets (API keys, tokens, DB creds) in NEXT_PUBLIC_*—they should be treated as public. [1]

How to avoid API key exposure

  • Keep secrets as server-only env vars (no NEXT_PUBLIC_) and use them only in server contexts (Route Handlers/API routes, Server Actions, server components during dynamic rendering, getServerSideProps, etc.). [1]
  • If the browser needs data from a 3rd-party API that requires a secret, call that 3rd-party API from your server (Next.js route handler/API route) and return only the necessary result to the client. [1]

Common safe uses of NEXT_PUBLIC_*

  • Non-secret configuration like analytics IDs, public base URLs, feature flags that are okay to reveal. [1]

Deployment note (Vercel)

  • Vercel recognizes framework “public” prefixes like NEXT_PUBLIC_ for Next.js and makes them available during the build step accordingly—reinforcing that these are meant for client-visible values, not secrets. [2]

Rule of thumb: If leaking it would cost money or grant access, it must not be NEXT_PUBLIC_*. [1]

Sources:
[1] Next.js docs – Environment Variables (“Bundling Environment Variables for the Browser”, build-time inlining, NEXT_PUBLIC_) https://nextjs.org/docs/14/app/building-your-application/configuring/environment-variables
[2] Vercel docs – Framework environment variables (prefixes like NEXT_PUBLIC_) https://vercel.com/docs/environment-variables/framework-environment-variables


Remove the fallback to GOOGLE_MAPS_API_KEY to avoid exposing a server-only secret in client URLs.

The mcp_response containing mapUrl is returned to and rendered on the client. If GOOGLE_MAPS_API_KEY (a server-only variable) is used as the fallback, its value will be embedded in the URL and exposed to the browser. Use only NEXT_PUBLIC_GOOGLE_MAPS_API_KEY, which is intended for client-side use and should be restricted to Maps Static API with HTTP referrer constraints in the Google Cloud Console.

🔒 Suggested fix
 function getGoogleStaticMapUrl(latitude: number, longitude: number): string {
-  const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || process.env.GOOGLE_MAPS_API_KEY;
+  const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
   if (!apiKey) return '';
   return `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&zoom=15&size=640x480&scale=2&markers=color:red%7C${latitude},${longitude}&key=${apiKey}`;
 }
📝 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
/**
* Helper to generate a Google Static Map URL
*/
function getGoogleStaticMapUrl(latitude: number, longitude: number): string {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return '';
return `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&zoom=15&size=640x480&scale=2&markers=color:red%7C${latitude},${longitude}&key=${apiKey}`;
}
/**
* Helper to generate a Google Static Map URL
*/
function getGoogleStaticMapUrl(latitude: number, longitude: number): string {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
if (!apiKey) return '';
return `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&zoom=15&size=640x480&scale=2&markers=color:red%7C${latitude},${longitude}&key=${apiKey}`;
}
🤖 Prompt for AI Agents
In `@lib/agents/tools/geospatial.tsx` around lines 155 - 162, The
getGoogleStaticMapUrl function currently falls back to
process.env.GOOGLE_MAPS_API_KEY which can expose a server-only secret in
client-rendered URLs; remove that fallback so the function only reads
process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY, and if it's missing return an empty
string (i.e., update getGoogleStaticMapUrl to use solely
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY and do not reference GOOGLE_MAPS_API_KEY
anywhere).

Comment on lines +409 to +412
// Enhance with Google Static Map URL if provider is google and we have coordinates
if (mapProvider === 'google' && mcpData.location.latitude && mcpData.location.longitude && !mcpData.mapUrl) {
mcpData.mapUrl = getGoogleStaticMapUrl(mcpData.location.latitude, mcpData.location.longitude);
}
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

Handle 0/0 coordinates correctly.

The truthy check skips valid coordinates at 0 latitude/longitude. Use explicit null/undefined or finite-number checks.

✅ Suggested fix
- if (mapProvider === 'google' && mcpData.location.latitude && mcpData.location.longitude && !mcpData.mapUrl) {
+ if (
+   mapProvider === 'google' &&
+   Number.isFinite(mcpData.location.latitude) &&
+   Number.isFinite(mcpData.location.longitude) &&
+   !mcpData.mapUrl
+ ) {
     mcpData.mapUrl = getGoogleStaticMapUrl(mcpData.location.latitude, mcpData.location.longitude);
   }
📝 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
// Enhance with Google Static Map URL if provider is google and we have coordinates
if (mapProvider === 'google' && mcpData.location.latitude && mcpData.location.longitude && !mcpData.mapUrl) {
mcpData.mapUrl = getGoogleStaticMapUrl(mcpData.location.latitude, mcpData.location.longitude);
}
// Enhance with Google Static Map URL if provider is google and we have coordinates
if (
mapProvider === 'google' &&
Number.isFinite(mcpData.location.latitude) &&
Number.isFinite(mcpData.location.longitude) &&
!mcpData.mapUrl
) {
mcpData.mapUrl = getGoogleStaticMapUrl(mcpData.location.latitude, mcpData.location.longitude);
}
🤖 Prompt for AI Agents
In `@lib/agents/tools/geospatial.tsx` around lines 409 - 412, The current guard
ignores valid 0 coordinates because it uses truthy checks; update the condition
in the block that sets mcpData.mapUrl (checking mapProvider,
mcpData.location.latitude, mcpData.location.longitude, and mcpData.mapUrl) to
explicitly test for null/undefined or use Number.isFinite on
mcpData.location.latitude and mcpData.location.longitude so latitude/longitude
of 0 are accepted, then call getGoogleStaticMapUrl as before.

…archer agent

- Restored side-by-side Mapbox and Google Satellite previews in ResolutionImage.
- Updated HeaderSearchButton to capture both Mapbox and Google imagery.
- Modified app/actions.tsx to process and display multi-image results.
- Improved researcher agent's prompt to always include user-drawn features and measurements.
- Switched to sonner for toast notifications in HeaderSearchButton for consistency.

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: 4

Caution

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

⚠️ Outside diff range comments (2)
app/actions.tsx (1)

689-714: ⚠️ Potential issue | 🟡 Minor

Wrap switch case declarations in block scope.

Variables declared with const/let inside switch cases without block scope can leak to other cases, causing potential runtime issues. Biome flagged this at lines 690 and 703-705.

🔧 Suggested fix
         case 'assistant':
+          {
           const answer = createStreamableValue(content as string)
           answer.done(content as string)
           switch (type) {
             case 'response':
               return {
                 id,
                 component: (
                   <Section title="response">
                     <BotMessage content={answer.value} />
                   </Section>
                 )
               }
-            case 'related':
-              const relatedQueries = createStreamableValue<RelatedQueries>({
-                items: []
-              })
+            case 'related': {
+              const relatedQueries = createStreamableValue<RelatedQueries>({
+                items: []
+              })
               relatedQueries.done(JSON.parse(content as string))
               return {
                 id,
                 component: (
                   <Section title="Related" separator={true}>
                     <SearchRelated relatedQueries={relatedQueries.value} />
                   </Section>
                 )
               }
+            }
             case 'followup':
               ...
           }
+          }
           break
components/header-search-button.tsx (1)

120-120: 🧹 Nitpick | 🔵 Trivial

Button disabled when !map, but Google-only capture may be possible.

The button is disabled when !map, but lines 74-87 can capture a Google preview using mapData.cameraState even without a Mapbox map instance. Consider allowing the button when cameraState.center exists:

♻️ Alternative disabled condition
-      disabled={isAnalyzing || !map || !actions}
+      disabled={isAnalyzing || (!map && !mapData.cameraState?.center) || !actions}

Apply the same change to line 132 for the mobile button.

🤖 Fix all issues with AI agents
In `@app/actions.tsx`:
- Around line 191-196: The success path in processResolutionSearch currently
calls aiState.done() but doesn't call isGenerating.done(false) and
uiStream.done(), leaving streams incomplete; update the success branch (after
aiState.done()) to also call isGenerating.done(false) and uiStream.done() and
ensure summaryStream.done(...) is invoked consistently (mirror the catch block
cleanup) so all three streams (isGenerating, uiStream, summaryStream) are
completed on both success and error.
- Around line 171-174: The image field is being JSON.stringified twice causing
nested string encoding; update the payload construction in app/actions.tsx where
you build content (the object that spreads analysisResult) so that image is
stored as an object (image: { mapbox: mapboxDataUrl, google: googleDataUrl })
instead of JSON.stringify(...), and then update the read-side parsing logic that
inspects analysisResult.image (the code around the current try/catch at lines
~732-739) to expect and handle an object directly (remove the extra JSON.parse
attempt or fall back only if a string is encountered).

In `@update_actions_v2.py`:
- Around line 1-240: This migration script (update_actions_v2.py) is a one‑time
transformer that rewrites the blocks matching "if (action ===
'resolution_search')" and "case 'resolution_search_result': {" in
app/actions.tsx and should not live at the repo root; either delete the file or
move it to a dedicated archive (e.g., scripts/migrations/) and add a short
README noting it was used to inject/replace the "if (action ===
'resolution_search')" and "case 'resolution_search_result': {" blocks, the date,
and that it must not be re-run; if you keep it archived, also make it
non‑runnable (e.g., wrap main logic behind a clear guard or rename so it won't
be accidentally executed).

In `@update_actions.py`:
- Around line 1-374: Delete the one-time migration scripts update_actions.py and
update_actions_v2.py from the repository; these tools have already been applied
(the old "const file = formData?.get('file')" pattern is replaced by
"mapboxFile"/"googleFile") so keeping them causes confusion. Remove both files
and any references to them (CI jobs, npm scripts, docs) to avoid accidental
re-run or clutter.
📜 Review details

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9ba4dfd and b49f7ca.

📒 Files selected for processing (6)
  • app/actions.tsx
  • components/header-search-button.tsx
  • components/resolution-image.tsx
  • lib/agents/researcher.tsx
  • update_actions.py
  • update_actions_v2.py
🧰 Additional context used
🧬 Code graph analysis (4)
update_actions.py (1)
lib/agents/resolution-search.tsx (2)
  • resolutionSearch (33-79)
  • msg (64-64)
components/resolution-image.tsx (3)
lib/utils/index.ts (1)
  • cn (12-14)
components/ui/dialog.tsx (5)
  • Dialog (112-112)
  • DialogTrigger (116-116)
  • DialogContent (117-117)
  • DialogHeader (118-118)
  • DialogTitle (120-120)
components/ui/card.tsx (2)
  • Card (79-79)
  • CardContent (79-79)
components/header-search-button.tsx (4)
components/map/google-map.tsx (2)
  • GoogleMapComponent (12-86)
  • apiKey (20-29)
components/map/mapbox-map.tsx (3)
  • map (337-358)
  • prevData (347-356)
  • prevData (167-167)
components/map/map-3d.tsx (1)
  • prevData (35-45)
components/map/map-query-handler.tsx (1)
  • prevData (39-48)
update_actions_v2.py (1)
lib/agents/resolution-search.tsx (2)
  • resolutionSearch (33-79)
  • msg (64-64)
🪛 Biome (2.3.13)
app/actions.tsx

[error] 690-690: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)


[error] 703-705: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)

🪛 Ruff (0.14.14)
update_actions_v2.py

[warning] 3-3: Unnecessary mode argument

Remove mode argument

(UP015)

🔇 Additional comments (4)
components/resolution-image.tsx (1)

24-36: LGTM!

The source fallback logic cleanly handles the three-prop scenario (src, mapboxSrc, googleSrc) with sensible defaults, and the early return prevents rendering when no sources are available.

lib/agents/researcher.tsx (1)

20-27: LGTM!

The drawnFeatures integration is well-structured: the default prompt embeds the context inline, while dynamic prompts get it appended via drawnFeaturesContext. The identical wording ensures consistent behavior across both paths.

Also applies to: 102-109

components/header-search-button.tsx (2)

59-71: LGTM!

The dual-capture approach cleanly handles both Mapbox canvas capture and optional Google Static Map fetch. The zoom calculation fallback from range is a reasonable approximation.


74-87: Good resilience: Google fetch failure doesn't abort the flow.

The try/catch around the Google Static Map fetch ensures that if Google's API fails or is unavailable, the user can still proceed with just the Mapbox capture. This is a good UX pattern.

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

Comment on lines +171 to +174
content: JSON.stringify({
...analysisResult,
image: JSON.stringify({ mapbox: mapboxDataUrl, google: googleDataUrl })
}),
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

Double JSON.stringify creates nested string encoding.

The image data is stringified twice: once at line 173 and again as part of the outer JSON.stringify at line 171. While the read-side (lines 732-739) handles this with a try/catch fallback, consider storing the image object directly to simplify parsing:

♻️ Alternative approach
             content: JSON.stringify({
               ...analysisResult,
-              image: JSON.stringify({ mapbox: mapboxDataUrl, google: googleDataUrl })
+              image: { mapbox: mapboxDataUrl, google: googleDataUrl }
             }),

Then update the read side:

-              const imageData = analysisResult.image as string;
+              const imageData = analysisResult.image;
               let mapboxSrc = '';
               let googleSrc = '';

               if (imageData) {
-                try {
-                  const parsed = JSON.parse(imageData);
-                  mapboxSrc = parsed.mapbox || '';
-                  googleSrc = parsed.google || '';
-                } catch (e) {
-                  mapboxSrc = imageData;
-                }
+                if (typeof imageData === 'string') {
+                  // Legacy format fallback
+                  mapboxSrc = imageData;
+                } else {
+                  mapboxSrc = imageData.mapbox || '';
+                  googleSrc = imageData.google || '';
+                }
               }
🤖 Prompt for AI Agents
In `@app/actions.tsx` around lines 171 - 174, The image field is being
JSON.stringified twice causing nested string encoding; update the payload
construction in app/actions.tsx where you build content (the object that spreads
analysisResult) so that image is stored as an object (image: { mapbox:
mapboxDataUrl, google: googleDataUrl }) instead of JSON.stringify(...), and then
update the read-side parsing logic that inspects analysisResult.image (the code
around the current try/catch at lines ~732-739) to expect and handle an object
directly (remove the extra JSON.parse attempt or fall back only if a string is
encountered).

Comment on lines 191 to 196
} catch (error) {
console.error('Error in resolution search:', error);
summaryStream.error(error);
} finally {
console.error('Failed to process resolution search:', error);
summaryStream.done('An error occurred during analysis.');
isGenerating.done(false);
uiStream.done();
}
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

Missing isGenerating.done(false) and uiStream.done() on success path.

In the processResolutionSearch function, the catch block calls isGenerating.done(false) and uiStream.done(), but the success path (after aiState.done()) does not. This could leave streams in an incomplete state.

🐛 Proposed fix
             }
           ]
         })
+        isGenerating.done(false)
+        uiStream.done()
       } catch (error) {
         console.error('Failed to process resolution search:', error);
         summaryStream.done('An error occurred during analysis.');
         isGenerating.done(false);
         uiStream.done();
       }
🤖 Prompt for AI Agents
In `@app/actions.tsx` around lines 191 - 196, The success path in
processResolutionSearch currently calls aiState.done() but doesn't call
isGenerating.done(false) and uiStream.done(), leaving streams incomplete; update
the success branch (after aiState.done()) to also call isGenerating.done(false)
and uiStream.done() and ensure summaryStream.done(...) is invoked consistently
(mirror the catch block cleanup) so all three streams (isGenerating, uiStream,
summaryStream) are completed on both success and error.

Comment on lines 1 to 240
import sys

with open('app/actions.tsx', 'r') as f:
lines = f.readlines()

start_idx = -1
end_idx = -1

for i, line in enumerate(lines):
if "if (action === 'resolution_search') {" in line:
start_idx = i
# Find the matching closing brace
brace_count = 0
for j in range(i, len(lines)):
brace_count += lines[j].count('{')
brace_count -= lines[j].count('}')
if brace_count == 0:
end_idx = j
break
break

if start_idx != -1 and end_idx != -1:
print(f"Replacing lines {start_idx+1} to {end_idx+1}")
new_block = """ if (action === 'resolution_search') {
const mapboxFile = formData?.get('mapboxFile') as File;
const googleFile = formData?.get('googleFile') as File;
const legacyFile = formData?.get('file') as File;
const timezone = (formData?.get('timezone') as string) || 'UTC';

let mapboxDataUrl = '';
let googleDataUrl = '';

if (mapboxFile) {
const buffer = await mapboxFile.arrayBuffer();
mapboxDataUrl = `data:${mapboxFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}
if (googleFile) {
const buffer = await googleFile.arrayBuffer();
googleDataUrl = `data:${googleFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}

// Fallback if only 'file' was provided (backward compatibility)
if (!mapboxDataUrl && !googleDataUrl && legacyFile) {
const buffer = await legacyFile.arrayBuffer();
mapboxDataUrl = `data:${legacyFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}

if (!mapboxDataUrl && !googleDataUrl) {
throw new Error('No files provided for resolution search.');
}

const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
message =>
message.role !== 'tool' &&
message.type !== 'followup' &&
message.type !== 'related' &&
message.type !== 'end' &&
message.type !== 'resolution_search_result'
);

const userInput = 'Analyze this map view.';
const contentParts: any[] = [{ type: 'text', text: userInput }]

if (mapboxDataUrl) {
contentParts.push({ type: 'image', image: mapboxDataUrl, mimeType: 'image/png' })
}
if (googleDataUrl) {
contentParts.push({ type: 'image', image: googleDataUrl, mimeType: 'image/png' })
}

const content = contentParts as any

aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{ id: nanoid(), role: 'user', content, type: 'input' }
]
});
messages.push({ role: 'user', content });

const summaryStream = createStreamableValue<string>('');
const groupeId = nanoid();

async function processResolutionSearch() {
try {
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures);

let fullSummary = '';
for await (const partialObject of streamResult.partialObjectStream) {
if (partialObject.summary) {
fullSummary = partialObject.summary;
summaryStream.update(fullSummary);
}
}

const analysisResult = await streamResult.object;
summaryStream.done(analysisResult.summary || 'Analysis complete.');

if (analysisResult.geoJson) {
uiStream.append(
<GeoJsonLayer
id={groupeId}
data={analysisResult.geoJson as FeatureCollection}
/>
);
}

messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' });

const sanitizedMessages: CoreMessage[] = messages.map(m => {
if (Array.isArray(m.content)) {
return {
...m,
content: m.content.filter(part => part.type !== 'image')
} as CoreMessage
}
return m
})

const relatedQueries = await querySuggestor(uiStream, sanitizedMessages);
uiStream.append(
<Section title="Follow-up">
<FollowupPanel />
</Section>
);

await new Promise(resolve => setTimeout(resolve, 500));

aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'assistant',
content: analysisResult.summary || 'Analysis complete.',
type: 'response'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify({
...analysisResult,
image: JSON.stringify({ mapbox: mapboxDataUrl, google: googleDataUrl })
}),
type: 'resolution_search_result'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify(relatedQueries),
type: 'related'
},
{
id: groupeId,
role: 'assistant',
content: 'followup',
type: 'followup'
}
]
})
} catch (error) {
console.error('Failed to process resolution search:', error);
summaryStream.done('An error occurred during analysis.');
isGenerating.done(false);
uiStream.done();
}
}

processResolutionSearch();

uiStream.update(
<Section title="response">
<ResolutionImage mapboxSrc={mapboxDataUrl} googleSrc={googleDataUrl} />
<BotMessage content={summaryStream.value} />
</Section>
);

return {
id: nanoid(),
isGenerating: isGenerating.value,
component: uiStream.value,
isCollapsed: isCollapsed.value
};
}
"""
lines[start_idx:end_idx+1] = [new_block]

# Replace getUIStateFromAIState block
for i, line in enumerate(lines):
if "case 'resolution_search_result': {" in line:
start_idx = i
brace_count = 0
for j in range(i, len(lines)):
brace_count += lines[j].count('{')
brace_count -= lines[j].count('}')
if brace_count == 0:
end_idx = j
break

print(f"Replacing UI block at line {start_idx+1} to {end_idx+1}")
new_ui = """ case 'resolution_search_result': {
const analysisResult = JSON.parse(content as string);
const geoJson = analysisResult.geoJson as FeatureCollection;
const imageData = analysisResult.image as string;
let mapboxSrc = '';
let googleSrc = '';

if (imageData) {
try {
const parsed = JSON.parse(imageData);
mapboxSrc = parsed.mapbox || '';
googleSrc = parsed.google || '';
} catch (e) {
// Fallback for older image format which was just a single string
mapboxSrc = imageData;
}
}

return {
id,
component: (
<>
{(mapboxSrc || googleSrc) && (
<ResolutionImage mapboxSrc={mapboxSrc} googleSrc={googleSrc} />
)}
{geoJson && (
<GeoJsonLayer id={id} data={geoJson} />
)}
</>
)
}
}
"""
lines[start_idx:end_idx+1] = [new_ui]
break

with open('app/actions.tsx', 'w') as f:
f.writelines(lines)
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:

#!/bin/bash
# Check if the patch script's target code already exists in app/actions.tsx

echo "=== Checking for mapboxFile pattern (from resolution_search block) ==="
rg -n "const mapboxFile = formData" app/actions.tsx 2>/dev/null | head -5 || echo "Not found"

echo ""
echo "=== Checking for imageData pattern (from resolution_search_result case) ==="
rg -n "const imageData = analysisResult.image" app/actions.tsx 2>/dev/null | head -5 || echo "Not found"

echo ""
echo "=== Checking if update_actions_v2.py exists ==="
ls -la update_actions_v2.py 2>/dev/null || echo "Script not found"

echo ""
echo "=== Checking if app/actions.tsx exists ==="
ls -la app/actions.tsx 2>/dev/null || echo "File not found"

Repository: QueueLab/QCX

Length of output: 563


Remove or archive this patch script — the transformation has already been applied.

Both code blocks targeted by this script already exist in app/actions.tsx (lines 53 and 727). Since this is a one-time migration utility, it should be removed from the repository or moved to a scripts/migrations/ directory with clear documentation about when and why it was used. Leaving it at the root risks accidental re-execution.

🧰 Tools
🪛 Ruff (0.14.14)

[warning] 3-3: Unnecessary mode argument

Remove mode argument

(UP015)

🤖 Prompt for AI Agents
In `@update_actions_v2.py` around lines 1 - 240, This migration script
(update_actions_v2.py) is a one‑time transformer that rewrites the blocks
matching "if (action === 'resolution_search')" and "case
'resolution_search_result': {" in app/actions.tsx and should not live at the
repo root; either delete the file or move it to a dedicated archive (e.g.,
scripts/migrations/) and add a short README noting it was used to inject/replace
the "if (action === 'resolution_search')" and "case 'resolution_search_result':
{" blocks, the date, and that it must not be re-run; if you keep it archived,
also make it non‑runnable (e.g., wrap main logic behind a clear guard or rename
so it won't be accidentally executed).

Comment on lines 1 to 374
import sys

content = open('app/actions.tsx').read()

# Replace resolution_search block
search_block = """ if (action === 'resolution_search') {
const file = formData?.get('file') as File;
const timezone = (formData?.get('timezone') as string) || 'UTC';

if (!file) {
throw new Error('No file provided for resolution search.');
}

const buffer = await file.arrayBuffer();
const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`;

const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
message =>
message.role !== 'tool' &&
message.type !== 'followup' &&
message.type !== 'related' &&
message.type !== 'end' &&
message.type !== 'resolution_search_result'
);

const userInput = 'Analyze this map view.';
const content: CoreMessage['content'] = [
{ type: 'text', text: userInput },
{ type: 'image', image: dataUrl, mimeType: file.type }
];

aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{ id: nanoid(), role: 'user', content, type: 'input' }
]
});
messages.push({ role: 'user', content });

const summaryStream = createStreamableValue<string>('');
const groupeId = nanoid();

async function processResolutionSearch() {
try {
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures);

let fullSummary = '';
for await (const partialObject of streamResult.partialObjectStream) {
if (partialObject.summary) {
fullSummary = partialObject.summary;
summaryStream.update(fullSummary);
}
}

const analysisResult = await streamResult.object;
summaryStream.done(analysisResult.summary || 'Analysis complete.');

if (analysisResult.geoJson) {
uiStream.append(
<GeoJsonLayer
id={groupeId}
data={analysisResult.geoJson as FeatureCollection}
/>
);
}

messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' });

const sanitizedMessages: CoreMessage[] = messages.map(m => {
if (Array.isArray(m.content)) {
return {
...m,
content: m.content.filter(part => part.type !== 'image')
} as CoreMessage
}
return m
})

const relatedQueries = await querySuggestor(uiStream, sanitizedMessages);
uiStream.append(
<Section title="Follow-up">
<FollowupPanel />
</Section>
);

await new Promise(resolve => setTimeout(resolve, 500));

aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'assistant',
content: analysisResult.summary || 'Analysis complete.',
type: 'response'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify({
...analysisResult,
image: dataUrl
}),
type: 'resolution_search_result'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify(relatedQueries),
type: 'related'
},
{
id: groupeId,
role: 'assistant',
content: 'followup',
type: 'followup'
}
]
})
} catch (error) {
console.error('Failed to process resolution search:', error);
summaryStream.done('An error occurred during analysis.');
isGenerating.done(false);
uiStream.done();
}
}

processResolutionSearch();

uiStream.update(
<Section title="response">
<ResolutionImage src={dataUrl} />
<BotMessage content={summaryStream.value} />
</Section>
);

return {
id: nanoid(),
isGenerating: isGenerating.value,
component: uiStream.value,
isCollapsed: isCollapsed.value
}
}"""

replace_block = """ if (action === 'resolution_search') {
const mapboxFile = formData?.get('mapboxFile') as File;
const googleFile = formData?.get('googleFile') as File;
const legacyFile = formData?.get('file') as File;
const timezone = (formData?.get('timezone') as string) || 'UTC';

let mapboxDataUrl = '';
let googleDataUrl = '';

if (mapboxFile) {
const buffer = await mapboxFile.arrayBuffer();
mapboxDataUrl = `data:${mapboxFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}
if (googleFile) {
const buffer = await googleFile.arrayBuffer();
googleDataUrl = `data:${googleFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}

// Fallback if only 'file' was provided (backward compatibility)
if (!mapboxDataUrl && !googleDataUrl && legacyFile) {
const buffer = await legacyFile.arrayBuffer();
mapboxDataUrl = `data:${legacyFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}

if (!mapboxDataUrl && !googleDataUrl) {
throw new Error('No files provided for resolution search.');
}

const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
message =>
message.role !== 'tool' &&
message.type !== 'followup' &&
message.type !== 'related' &&
message.type !== 'end' &&
message.type !== 'resolution_search_result'
);

const userInput = 'Analyze this map view.';
const contentParts: any[] = [{ type: 'text', text: userInput }]

if (mapboxDataUrl) {
contentParts.push({ type: 'image', image: mapboxDataUrl, mimeType: 'image/png' })
}
if (googleDataUrl) {
contentParts.push({ type: 'image', image: googleDataUrl, mimeType: 'image/png' })
}

const content = contentParts as any

aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{ id: nanoid(), role: 'user', content, type: 'input' }
]
});
messages.push({ role: 'user', content });

const summaryStream = createStreamableValue<string>('');
const groupeId = nanoid();

async function processResolutionSearch() {
try {
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures);

let fullSummary = '';
for await (const partialObject of streamResult.partialObjectStream) {
if (partialObject.summary) {
fullSummary = partialObject.summary;
summaryStream.update(fullSummary);
}
}

const analysisResult = await streamResult.object;
summaryStream.done(analysisResult.summary || 'Analysis complete.');

if (analysisResult.geoJson) {
uiStream.append(
<GeoJsonLayer
id={groupeId}
data={analysisResult.geoJson as FeatureCollection}
/>
);
}

messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' });

const sanitizedMessages: CoreMessage[] = messages.map(m => {
if (Array.isArray(m.content)) {
return {
...m,
content: m.content.filter(part => part.type !== 'image')
} as CoreMessage
}
return m
})

const relatedQueries = await querySuggestor(uiStream, sanitizedMessages);
uiStream.append(
<Section title="Follow-up">
<FollowupPanel />
</Section>
);

await new Promise(resolve => setTimeout(resolve, 500));

aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'assistant',
content: analysisResult.summary || 'Analysis complete.',
type: 'response'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify({
...analysisResult,
image: JSON.stringify({ mapbox: mapboxDataUrl, google: googleDataUrl })
}),
type: 'resolution_search_result'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify(relatedQueries),
type: 'related'
},
{
id: groupeId,
role: 'assistant',
content: 'followup',
type: 'followup'
}
]
})
} catch (error) {
console.error('Failed to process resolution search:', error);
summaryStream.done('An error occurred during analysis.');
isGenerating.done(false);
uiStream.done();
}
}

processResolutionSearch();

uiStream.update(
<Section title="response">
<ResolutionImage mapboxSrc={mapboxDataUrl} googleSrc={googleDataUrl} />
<BotMessage content={summaryStream.value} />
</Section>
);

return {
id: nanoid(),
isGenerating: isGenerating.value,
component: uiStream.value,
isCollapsed: isCollapsed.value
}
}"""

if search_block in content:
content = content.replace(search_block, replace_block)
else:
print("Warning: resolution_search block not found!")

# Replace resolution_search_result in getUIStateFromAIState
search_ui = """ case 'resolution_search_result': {
const analysisResult = JSON.parse(content as string);
const geoJson = analysisResult.geoJson as FeatureCollection;
const image = analysisResult.image as string;

return {
id,
component: (
<>
{image && <ResolutionImage src={image} />}
{geoJson && (
<GeoJsonLayer id={id} data={geoJson} />
)}
</>
)
}
}"""

replace_ui = """ case 'resolution_search_result': {
const analysisResult = JSON.parse(content as string);
const geoJson = analysisResult.geoJson as FeatureCollection;
const imageData = analysisResult.image as string;
let mapboxSrc = '';
let googleSrc = '';

if (imageData) {
try {
const parsed = JSON.parse(imageData);
mapboxSrc = parsed.mapbox || '';
googleSrc = parsed.google || '';
} catch (e) {
// Fallback for older image format which was just a single string
mapboxSrc = imageData;
}
}

return {
id,
component: (
<>
{(mapboxSrc || googleSrc) && (
<ResolutionImage mapboxSrc={mapboxSrc} googleSrc={googleSrc} />
)}
{geoJson && (
<GeoJsonLayer id={id} data={geoJson} />
)}
</>
)
}
}"""

if search_ui in content:
content = content.replace(search_ui, replace_ui)
else:
print("Warning: resolution_search_result block not found!")

with open('app/actions.tsx', 'w') as f:
f.write(content)
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Looking for update_actions scripts ==="
fd -t f "update_actions" --extension py

echo ""
echo "=== Checking app/actions.tsx for transformation markers ==="
if [ -f "app/actions.tsx" ]; then
  echo "File exists. Checking for new transformation evidence..."
  rg "mapboxFile|googleFile" app/actions.tsx | head -5
  echo "---"
  rg "mapboxSrc|googleSrc" app/actions.tsx | head -5
else
  echo "app/actions.tsx does not exist"
fi

echo ""
echo "=== Listing all files in repository root and key directories ==="
ls -la | head -20

Repository: QueueLab/QCX

Length of output: 2145


🏁 Script executed:

#!/bin/bash
echo "=== Comparing update_actions.py and update_actions_v2.py ==="
echo ""
echo "File sizes:"
wc -l update_actions.py update_actions_v2.py

echo ""
echo "=== Git history of these files ==="
git log --oneline update_actions.py update_actions_v2.py 2>/dev/null | head -10

echo ""
echo "=== Checking for differences between the two scripts ==="
diff -u update_actions.py update_actions_v2.py | head -100

Repository: QueueLab/QCX

Length of output: 3592


🏁 Script executed:

#!/bin/bash
echo "=== Checking if OLD code patterns still exist in app/actions.tsx ==="
echo "Looking for single 'file' parameter (old pattern):"
rg "const file = formData\?\.get\('file'\)" app/actions.tsx
echo "Exit code: $?"

echo ""
echo "=== Checking if NEW code patterns are present (new migration) ==="
echo "Looking for mapboxFile and googleFile:"
rg "const (mapboxFile|googleFile)" app/actions.tsx | wc -l

Repository: QueueLab/QCX

Length of output: 296


Remove stale migration scripts to avoid confusion.

Both update_actions.py and update_actions_v2.py are one-time migration tools that have already been applied to app/actions.tsx (verified: old const file = formData?.get('file') pattern is gone, new mapboxFile/googleFile patterns are present). They serve no further purpose and should be deleted to keep the repository clean.

🤖 Prompt for AI Agents
In `@update_actions.py` around lines 1 - 374, Delete the one-time migration
scripts update_actions.py and update_actions_v2.py from the repository; these
tools have already been applied (the old "const file = formData?.get('file')"
pattern is replaced by "mapboxFile"/"googleFile") so keeping them causes
confusion. Remove both files and any references to them (CI jobs, npm scripts,
docs) to avoid accidental re-run or clutter.

- Synchronized with latest main branch (commit 649e109).
- Restored multi-image (Mapbox and Google Satellite) preview support in ResolutionImage and HeaderSearchButton.
- Updated app/actions.tsx to process dual imagery while preserving recent performance and stability fixes (e.g., sanitized history, initialized summary stream).
- Improved researcher agent's prompt to include full drawing context (type, measurement, and geometry).
- Switched to sonner for toast notifications in HeaderSearchButton.

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

@coderabbitai how both images be returned like this
image

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 4, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the ResolutionImage component to understand how it renders both images
cat components/resolution-image.tsx

Length of output: 3788


🏁 Script executed:

#!/bin/bash
# Check the header-search-button.tsx to see how both images are captured
cat components/header-search-button.tsx | head -150

Length of output: 4997


🏁 Script executed:

#!/bin/bash
# Check actions.tsx to see how the images are processed and returned
# Look for the resolution_search action handler
rg -A 30 "action === 'resolution_search'" app/actions.tsx

Length of output: 1278


@ngoiyaeric, great question! The dual-image display works through a complete pipeline from capture to rendering. Here's how both images are returned side-by-side:

The Flow

1. Capture Phase (header-search-button.tsx, lines 60-78)

Both images are captured separately:

let mapboxBlob: Blob | null = null;
let googleBlob: Blob | null = null;

// Capture Mapbox canvas
if (map) {
  const canvas = map.getCanvas();
  mapboxBlob = await new Promise<Blob | null>(resolve => {
    canvas.toBlob(resolve, 'image/png');
  });
}

// Fetch Google Static Map via API
if (center) {
  const staticMapUrl = `https://maps.googleapis.com/maps/api/staticmap?...`;
  const response = await fetch(staticMapUrl);
  googleBlob = await response.blob();
}

2. Upload Phase (header-search-button.tsx, lines 85-87)

Both files are sent separately:

if (mapboxBlob) formData.append('mapboxFile', mapboxBlob, 'mapbox_capture.png')
if (googleBlob) formData.append('googleFile', googleBlob, 'google_capture.png')

3. Processing Phase (actions.tsx)

Both files are converted to data URLs:

let mapboxDataUrl = '';
let googleDataUrl = '';

if (mapboxFile) {
  const buffer = await mapboxFile.arrayBuffer();
  mapboxDataUrl = `data:${mapboxFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}
if (googleFile) {
  const buffer = await googleFile.arrayBuffer();
  googleDataUrl = `data:${googleFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}

4. Rendering Phase (resolution-image.tsx)

The ResolutionImage component renders both images in a responsive grid:

const hasBoth = mSrc && gSrc  // Check if both sources exist

// Thumbnail view uses 2-column grid when both exist
<CardContent className={cn("p-2 grid gap-2", hasBoth ? "grid-cols-2" : "grid-cols-1")}>
  {mSrc && (
    <div className="space-y-1">
      <img src={mSrc} alt={`${alt} Mapbox`} ... />
      <p>Mapbox</p>
    </div>
  )}
  {gSrc && (
    <div className="space-y-1">
      <img src={gSrc} alt={`${alt} Google`} ... />
      <p>Google Satellite</p>
    </div>
  )}
</CardContent>

Key Design Pattern

The component uses conditional grid layout:

  • When both images exist: grid-cols-2 → side-by-side display
  • When only one exists: grid-cols-1 → single image display
  • The modal dialog also uses the same pattern: md:grid-cols-2 for comparison view

This design maintains backward compatibility while supporting the new dual-image feature! 🎨

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants