Skip to content

Comments

Re-implementation of PR #533#541

Open
ngoiyaeric wants to merge 1 commit intomainfrom
re-implement-pr-533
Open

Re-implementation of PR #533#541
ngoiyaeric wants to merge 1 commit intomainfrom
re-implement-pr-533

Conversation

@ngoiyaeric
Copy link
Collaborator

@ngoiyaeric ngoiyaeric commented Feb 20, 2026

This PR re-implements the functionality from PR #533 on the current main branch. It includes elevation heatmap support and related geospatial tool improvements.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added elevation visualization as an interactive heatmap layer on maps, displaying terrain data with color-coded intensity and elevation values on click
    • Introduced enhanced map results display combining geographic data with optional elevation statistics
    • Added elevation API endpoint for calculating terrain metrics and statistics for geographic areas
  • Improvements

    • Streamlined search and analysis workflow for faster results
    • Improved state persistence for chat history and user sessions

@vercel
Copy link
Contributor

vercel bot commented Feb 20, 2026

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

Project Deployment Actions Updated (UTC)
qcx Ready Ready Preview, Comment Feb 20, 2026 0:33am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 20, 2026

Walkthrough

Refactored core state management with new public UIState and AIState types, added server-side user handling for chat persistence, introduced elevation API routes for terrain data, and created new map visualization components (ElevationHeatmapLayer, MapResultsContainer). Simplified component type constraints across multiple files. Added elevation utility functions and external HTMX fallback script.

Changes

Cohort / File(s) Summary
Core State & Actions Refactoring
app/actions.tsx
Introduced UIState and AIState types replacing inline definitions; added server-side user retrieval; refactored resolution_search handling with mapboxImage/googleImage data; wired new UI rendering branches for elevation, carousel, and related queries; updated onSetAIState for persistence; added getUIStateFromAIState function mapping message types to components.
Elevation API Infrastructure
app/api/elevation/route.ts
Added GET and POST handlers for elevation data fetching. GET accepts bounds and gridSize, queries Mapbox Terrain v2 via tilequery, returns points with elevations and statistics. POST processes GeoJSON features, aggregates elevation results.
Elevation Utilities
lib/utils/elevation.ts
Introduced three utility functions: getBoundsFromFeatures (computes bbox from feature geometries), decodeMapboxTerrainRGB (decodes Mapbox terrain RGB encoding), getElevationColor (normalizes elevation to color spectrum).
New Map Components
components/map/elevation-heatmap-layer.tsx, components/map/map-results-container.tsx
Added ElevationHeatmapLayer for Mapbox heatmap visualization with elevation intensity scaling and click popups. Added MapResultsContainer wrapper that dynamically loads GeoJsonLayer and ElevationHeatmapLayer with optional elevation data.
Component Type Loosening
components/header-search-button.tsx, components/mobile-icons-bar.tsx, components/resolution-carousel.tsx, components/search-related.tsx
Removed AI import and replaced useActions/useUIState generic constraints (from typeof AI to any), loosening type safety for hook usage.
Agent Schema & Configuration
lib/agents/resolution-search.tsx, lib/agents/tools/geospatial.tsx
Extended resolutionSearchSchema with elevationData object (requested, bounds, gridSize). Removed dynamic config file loading in getConnectedMcpClient, replaced with fixed fallback object.
Layout & Fallback Script
app/layout.tsx, public/htmx-fallback.js
Replaced inline HTMX safety fallback in layout with external Script component loading /htmx-fallback.js. New fallback file defines IIFE-wrapped stub handlers for HTMX events.

Sequence Diagram

sequenceDiagram
    participant User
    participant Client as Client (Browser)
    participant Server as Server (Actions)
    participant ElevAPI as Elevation API
    participant Mapbox as Mapbox Terrain v2
    participant MapUI as Map Component

    User->>Client: Request with elevation data
    Client->>Server: Call AI action with bounds/gridSize
    Server->>ElevAPI: POST /api/elevation with features
    ElevAPI->>Mapbox: Query terrain RGB for grid points
    Mapbox-->>ElevAPI: Return elevation data per point
    ElevAPI-->>Server: Return aggregated elevation statistics
    Server->>Server: Attach elevation data to AIState
    Server-->>Client: Stream UIState with MapResultsContainer
    Client->>MapUI: Render ElevationHeatmapLayer
    MapUI->>Mapbox: Add heatmap source & layers
    Mapbox-->>MapUI: Render elevation visualization
    MapUI-->>User: Display elevation heat map
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

Backend, Review effort 4/5

Poem

🐰 Elevation dreams take shape,
New states mapped with grace,
Terrain painted heatmap-wise,
Components dance in place—
A rabbit's joy: data flows,
Where mountains meet the UI flows!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.41% 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 're-implementation of PR #533' is too vague and lacks specific context about what functionality is being re-implemented, making it difficult for team members reviewing history to understand the actual changes. Consider a more descriptive title that specifies the main feature, e.g., 'Add elevation heatmap visualization and geospatial tools' or 'Re-implement elevation data handling and map visualization'.
✅ 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 docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch re-implement-pr-533

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

❤️ Share

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

Copy link

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

This re-implementation adds the elevation heatmap feature, but there are several high-impact problems: the elevation API currently fans out to hundreds of external requests per call (likely rate-limit/latency failures), chat persistence was simplified in a way that can break invariants and save data under 'anonymous', and getUIStateFromAIState now drops user messages (likely a UI regression when loading chats). Additionally, elevation fetch URLs are built via raw string concatenation without encoding, and some client components removed type safety by switching useActions/useUIState to any. These should be addressed before merging to avoid regressions and production instability.

Additional notes (4)
  • Maintainability | components/header-search-button.tsx:12-12
    Several components replaced useActions<typeof AI>() / useUIState<typeof AI>() with any. This removes type safety around action names and return shapes and makes it easy to ship runtime errors (e.g. calling a missing action, or assuming submit returns a certain UI state shape).

Given this PR also changes UIState/AIState shapes, this is the worst time to drop types.

  • Maintainability | components/mobile-icons-bar.tsx:26-32
    useActions<any>() / useUIState<any>() repeats the same type-safety regression. This makes it easy to silently break clearChat/submit interactions.

  • Maintainability | components/resolution-carousel.tsx:17-17
    useActions<any>() / useUIState<any>() again—this spreads any through core user flows.

Also, actions is cast to any anyway; consider defining a minimal ResolutionActions interface (like you did in header-search-button) and using that instead of any to at least constrain the surface area.

  • Maintainability | components/search-related.tsx:22-22
    SearchRelated now types setMessages callback arg as any, which is another type-safety drop and makes it easy to break message rendering.

This component is part of the core chat loop; it should keep the UI state type consistent with UIState from app/actions.tsx.

Summary of changes

Summary

This PR re-introduces elevation heatmap support and refactors map result rendering.

Key changes

  • Server actions / chat flow (app/actions.tsx)

    • Adds location + timezone parsing from FormData.
    • Swaps GeoJsonLayer rendering for a new MapResultsContainer that can render both GeoJSON overlays and elevation heatmaps.
    • Fetches elevation points from a new endpoint (/api/elevation) when analysisResult.elevationData.requested is set.
    • Changes chat persistence behavior:
      • Uses getCurrentUserIdOnServer() in more places.
      • Simplifies onSetAIState to saveChat({ id, messages }, userId).
    • Refactors getUIStateFromAIState to primarily render assistant messages by type and skip user/system roles.
  • New elevation API (app/api/elevation/route.ts)

    • Adds a GET endpoint that samples a grid of points within a bbox and queries Mapbox Terrain tilequery for ele.
    • Adds a POST endpoint for batch elevation queries over polygon features.
  • New map UI pieces

    • components/map/elevation-heatmap-layer.tsx: Mapbox GL heatmap + point layer with popups.
    • components/map/map-results-container.tsx: Wrapper that conditionally renders GeoJSON + elevation layers.
  • Script loading (app/layout.tsx, public/htmx-fallback.js)

    • Replaces inline dangerouslySetInnerHTML with next/script loading a static fallback file.
  • Resolution search schema/tooling (lib/agents/resolution-search.tsx)

    • Extends schema with elevationData instructions (bounds/gridSize/requested).
  • Geospatial tool config (lib/agents/tools/geospatial.tsx)

    • Removes optional mapbox_mcp_config.json loading and uses a simple fallback config.
  • Client hooks typing

    • Several components switch useActions<typeof AI>() / useUIState<typeof AI>() to any.

Comment on lines +134 to +156
// Handle elevation heat map if requested
let elevationPointsData = null;
if (analysisResult.elevationData?.requested && analysisResult.elevationData.bounds) {
try {
const elevationResponse = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/elevation?` +
`bounds=${JSON.stringify(analysisResult.elevationData.bounds)}&gridSize=${analysisResult.elevationData.gridSize || 20}${
drawnFeatures.length > 0 && drawnFeatures[0].geometry
? `&geometry=${JSON.stringify(drawnFeatures[0].geometry)}`
: ''
}`
);

if (elevationResponse.ok) {
const elevationData = await elevationResponse.json();
if (elevationData.success && elevationData.points.length > 0) {
elevationPointsData = elevationData;
}
}
} catch (error) {
console.error('Error fetching elevation data:', error);
}
}

Choose a reason for hiding this comment

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

The elevation request is built by concatenating JSON.stringify(...) into the querystring. This will routinely break due to unescaped characters ([, ], {, }, quotes) and can also easily exceed typical URL length limits for larger geometries. It’s also using NEXT_PUBLIC_BASE_URL || 'http://localhost:3000', which is brittle in server actions (multi-env, previews, proxies).

This is a correctness issue: a large polygon or even just the bbox JSON can yield a 414/400 and silently skip heatmap rendering.

Suggestion

Use URLSearchParams (or new URL()) to properly encode parameters, and strongly consider switching this call to POST /api/elevation with a JSON body to avoid URL length limits and encoding pitfalls.

Example:

const url = new URL('/api/elevation', process.env.NEXT_PUBLIC_BASE_URL)
url.searchParams.set('gridSize', String(analysisResult.elevationData.gridSize ?? 20))
url.searchParams.set('bounds', JSON.stringify(analysisResult.elevationData.bounds))
if (drawnFeatures[0]?.geometry) url.searchParams.set('geometry', JSON.stringify(drawnFeatures[0].geometry))
const elevationResponse = await fetch(url)

Or better:

const elevationResponse = await fetch(`${baseUrl}/api/elevation`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ features: drawnFeatures.map(f => ({ geometry: f.geometry })), gridSize })
})

Reply with "@CharlieHelps yes please" if you'd like me to add a commit implementing this safely (including a server-side base URL helper).

Comment on lines +563 to 571
export function getUIStateFromAIState(aiState: Chat) {
return aiState.messages
.map((message, index) => {
const { role, content, id, type, name } = message

if (
!type ||
type === 'end' ||
(isSharePage && type === 'related') ||
(isSharePage && type === 'followup')
)
if (role === 'user' || role === 'system') {
return null
}

Choose a reason for hiding this comment

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

getUIStateFromAIState now drops all role === 'user' messages. That’s a behavioral change from the previous implementation where user inputs were rendered into the UI state. If Chat.messages includes user messages (likely), then loading an existing chat would show only assistant messages, losing the conversational context in the UI.

This is a user-visible regression unless another component reconstructs user messages elsewhere.

Suggestion

Keep user messages in the UI state mapping (as before), and only skip system (and maybe internal-only message types like end). For example:

if (role === 'system' || type === 'end') return null
if (role === 'user') {
  return { id, component: <UserMessage content={parsedUserContent} chatId={chatId} showShare={index === 0} /> }
}

Reply with "@CharlieHelps yes please" if you'd like me to add a commit that restores user-message rendering while preserving your new map/elevation rendering paths.

Comment on lines +551 to 560
onSetAIState: async ({ state, done }) => {
'use server'

const aiState = getAIState() as AIState
if (aiState) {
const uiState = getUIStateFromAIState(aiState)
return uiState
}
return initialUIState
},
onSetAIState: async ({ state }) => {
'use server'

if (!state.messages.some(e => e.type === 'response')) {
return
}

const { chatId, messages } = state
const createdAt = new Date()
const path = `/search/${chatId}`

let title = 'Untitled Chat'
if (messages.length > 0) {
const firstMessageContent = messages[0].content
if (typeof firstMessageContent === 'string') {
try {
const parsedContent = JSON.parse(firstMessageContent)
title = parsedContent.input?.substring(0, 100) || 'Untitled Chat'
} catch (e) {
title = firstMessageContent.substring(0, 100)
}
} else if (Array.isArray(firstMessageContent)) {
const textPart = (
firstMessageContent as { type: string; text?: string }[]
).find(p => p.type === 'text')
title =
textPart && textPart.text
? textPart.text.substring(0, 100)
: 'Image Message'
}
}

const updatedMessages: AIMessage[] = [
...messages,
{
id: nanoid(),
role: 'assistant',
content: `end`,
type: 'end'
}
]

const { getCurrentUserIdOnServer } = await import(
'@/lib/auth/get-current-user'
)
const actualUserId = await getCurrentUserIdOnServer()

if (!actualUserId) {
console.error('onSetAIState: User not authenticated. Chat not saved.')
return
}

const chat: Chat = {
id: chatId,
createdAt,
userId: actualUserId,
path,
title,
messages: updatedMessages
if (done) {
const userId = (await getCurrentUserIdOnServer()) || 'anonymous'
const { chatId, messages } = state as AIState
saveChat({ id: chatId, messages } as any, userId).catch(e =>
console.error('Failed to save chat:', e)
)
}
await saveChat(chat, actualUserId)
}

Choose a reason for hiding this comment

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

onSetAIState now saves { id: chatId, messages } as any and uses userId || 'anonymous'. This is a large change from the previous logic which:

  • appended an end marker,
  • computed title, createdAt, path,
  • refused to save if unauthenticated.

Two concrete risks visible here:

  1. Persisting chats under 'anonymous' can mix multiple anonymous sessions together depending on how storage is keyed, and may violate expected access control semantics.
  2. Saving a partial Chat shape via as any bypasses invariants (title/path/createdAt), leading to inconsistent downstream behavior when listing chats/search pages expect those fields.
Suggestion

Avoid as any and restore/centralize the chat persistence contract:

  • Either require authentication before saving (return early if no user), or generate a per-session anonymous user id.
  • Build a full Chat object including createdAt, path, and a reasonable title.
  • If you intentionally changed the persistence model, update saveChat’s signature to accept the new shape instead of casting.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit that restores the previous persistence invariants while keeping your new onSetAIState(done) gating.

Comment on lines +58 to +90
const token = process.env.MAPBOX_ACCESS_TOKEN || process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN;

// Fetch elevation data using Mapbox Terrain vector tiles (v2)
// This tileset contains contour lines with 'ele' property
const elevationPromises = points.map(async (point) => {
try {
const response = await fetch(
`https://api.mapbox.com/v4/mapbox.mapbox-terrain-v2/tilequery/${point.lng},${point.lat}.json?access_token=${token}`
);

if (response.ok) {
const data = await response.json();
if (data.features && data.features.length > 0) {
// Find the feature with the highest elevation in this point's vicinity
// or just take the first one if it has 'ele'
const elevations = data.features
.map((f: any) => f.properties.ele)
.filter((e: any) => e !== undefined);

if (elevations.length > 0) {
point.elevation = Math.max(...elevations);
}
}
}
} catch (error) {
console.error(`Error fetching elevation for ${point.lng},${point.lat}:`, error);
}
return point;
});

const elevationData = await Promise.all(elevationPromises);
const validPoints = elevationData.filter(p => p.elevation !== null);

Choose a reason for hiding this comment

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

The elevation API performs a Mapbox Tilequery request for every grid point concurrently via Promise.all(points.map(...)). With the default (gridSize+1)^2 points, gridSize=20 yields 441 outbound requests per API call, which is very likely to trigger rate limits and will also be slow/expensive. Additionally, there’s no guard if MAPBOX_ACCESS_TOKEN is missing—requests will still be attempted with access_token=null/undefined.

This endpoint will be unstable under real traffic and can cause cascading latency issues.

Suggestion

Add (1) a required-token check, and (2) concurrency limiting + sane caps:

  • Reject or return { success: false } with 500/503 if token is missing.
  • Cap gridSize (e.g. <= 30) and/or total points.
  • Limit concurrency with a pool (e.g. p-limit) and consider caching results by rounded lat/lng.

Sketch:

if (!token) return NextResponse.json({ error: 'Mapbox token missing' }, { status: 500 })
const gridSize = Math.min(parsedGridSize, 30)
const limit = pLimit(10)
const elevationData = await Promise.all(points.map(p => limit(() => fetchElevation(p))))

Reply with "@CharlieHelps yes please" if you'd like me to add a commit adding token validation + concurrency limiting and a gridSize cap.

Comment on lines +31 to +41
const gridSize = gridSizeParam ? parseInt(gridSizeParam) : 20;
const geometry = geometryParam ? JSON.parse(geometryParam) : null;

const [west, south, east, north] = bounds;

const points: Array<{ lng: number; lat: number; elevation: number | null }> = [];
const lonStep = (east - west) / gridSize;
const latStep = (north - south) / gridSize;

const polygon = geometry ? turf.polygon(geometry.coordinates) : null;

Choose a reason for hiding this comment

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

geometryParam is parsed with JSON.parse without a try/catch, while boundsParam is guarded. A single malformed geometry query parameter will throw and return a 500 instead of a 400.

Also, turf.polygon(geometry.coordinates) assumes the incoming geometry always matches what turf.polygon expects (array of linear rings). If geometry is a GeoJSON Polygon object, you should pass coordinates but validate geometry.type === 'Polygon' first to avoid hard failures.

Suggestion

Wrap geometry parsing in a try/catch and validate the geometry type before constructing a turf polygon:

let geometry: any = null
if (geometryParam) {
  try { geometry = JSON.parse(geometryParam) } catch { return NextResponse.json({ error: 'Invalid geometry parameter' }, { status: 400 }) }
  if (geometry?.type !== 'Polygon' || !Array.isArray(geometry.coordinates)) {
    return NextResponse.json({ error: 'geometry must be a GeoJSON Polygon' }, { status: 400 })
  }
}
const polygon = geometry ? turf.polygon(geometry.coordinates) : null

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

Comment on lines +131 to +159
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { features, gridSize = 20 } = body;

if (!features || !Array.isArray(features)) {
return NextResponse.json(
{ error: 'Missing or invalid features array' },
{ status: 400 }
);
}

const results = await Promise.all(
features.map(async (feature: any) => {
if (feature.geometry.type !== 'Polygon') {
return null;
}

const bbox = turf.bbox(feature);
const params = new URLSearchParams({
bounds: JSON.stringify(bbox),
gridSize: gridSize.toString(),
geometry: JSON.stringify(feature.geometry),
});

const url = new URL(`/api/elevation?${params}`, req.url);
const response = await GET(new NextRequest(url));
return await response.json();
})

Choose a reason for hiding this comment

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

In POST, the handler calls GET(new NextRequest(url)). This is an unusual pattern and can be problematic because it bypasses normal Next.js request handling/middleware and can lose request context. It also re-parses URL params and repeats validation paths.

This is better implemented by extracting the core elevation sampling logic into a shared function and calling it from both GET and POST.

Suggestion

Refactor to a shared internal function like computeElevation({ bounds, gridSize, geometry }) and call it from both handlers. This avoids constructing synthetic requests and keeps behavior consistent.

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

Comment on lines +57 to +139
const onMapLoad = () => {
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'geojson',
data: geojson
})

map.addLayer({
id: heatmapLayerId,
type: 'heatmap',
source: sourceId,
paint: {
'heatmap-weight': ['interpolate', ['linear'], ['get', 'intensity'], 0, 0, 1, 1],
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 15, 3],
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0, 'rgba(33,102,172,0)',
0.2, 'rgb(103,169,207)',
0.4, 'rgb(209,229,240)',
0.6, 'rgb(253,219,199)',
0.8, 'rgb(239,138,98)',
1, 'rgb(178,24,43)'
],
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 15, 20],
'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.7, 15, 0.5]
}
})

map.addLayer({
id: pointsLayerId,
type: 'circle',
source: sourceId,
minzoom: 14,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 22, 8],
'circle-color': [
'interpolate',
['linear'],
['get', 'intensity'],
0, '#2166ac',
0.25, '#67a9cf',
0.5, '#f7f7f7',
0.75, '#ef8a62',
1, '#b2182b'
],
'circle-stroke-color': 'white',
'circle-stroke-width': 1,
'circle-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 0.8]
}
})

const clickHandler = (e: any) => {
if (!e.features || e.features.length === 0) return
const elevation = e.features[0].properties?.elevation
if (elevation !== undefined) {
new mapboxgl.Popup()
.setLngLat(e.lngLat)
.setHTML(`<strong>Elevation:</strong> ${elevation}m`)
.addTo(map)
}
}

map.on('click', pointsLayerId, clickHandler)
map.on('mouseenter', pointsLayerId, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', pointsLayerId, () => { map.getCanvas().style.cursor = '' })
}
}

if (map.isStyleLoaded()) {
onMapLoad()
} else {
map.once('load', onMapLoad)
}

return () => {
if (map) {
if (map.getLayer(pointsLayerId)) map.removeLayer(pointsLayerId)
if (map.getLayer(heatmapLayerId)) map.removeLayer(heatmapLayerId)
if (map.getSource(sourceId)) map.removeSource(sourceId)
}
}

Choose a reason for hiding this comment

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

The new ElevationHeatmapLayer registers event handlers (click, mouseenter, mouseleave) but the cleanup only removes layers/sources. If the layer is removed without also removing handlers, Mapbox can keep references around and leak handlers (or throw warnings).

Additionally, the onMapLoad function is registered via map.once('load', onMapLoad) but is never removed on cleanup if the component unmounts before load fires.

Suggestion

Store handlers and unregister them in cleanup, and cancel the pending load handler:

const clickHandler = (e: mapboxgl.MapLayerMouseEvent) => { ... }
const enterHandler = () => { map.getCanvas().style.cursor = 'pointer' }
const leaveHandler = () => { map.getCanvas().style.cursor = '' }

map.on('click', pointsLayerId, clickHandler)
map.on('mouseenter', pointsLayerId, enterHandler)
map.on('mouseleave', pointsLayerId, leaveHandler)

return () => {
  map.off('click', pointsLayerId, clickHandler)
  map.off('mouseenter', pointsLayerId, enterHandler)
  map.off('mouseleave', pointsLayerId, leaveHandler)
  map.off('load', onMapLoad)
  ...remove layers/sources...
}

Reply with "@CharlieHelps yes please" if you'd like me to add a commit addressing the handler cleanup properly.

Comment on lines +10 to +28
interface MapResultsContainerProps {
id: string
geoJson?: FeatureCollection
elevationPoints?: {
points: any[]
statistics: any
}
}

export function MapResultsContainer({ id, geoJson, elevationPoints }: MapResultsContainerProps) {
return (
<>
{geoJson && <GeoJsonLayer id={id} data={geoJson} />}
{elevationPoints && elevationPoints.points && elevationPoints.points.length > 0 && (
<ElevationHeatmapLayer
id={`${id}-elevation`}
points={elevationPoints.points}
statistics={elevationPoints.statistics}
/>

Choose a reason for hiding this comment

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

elevationPoints is typed as { points: any[]; statistics: any }, and the child layer takes points with a defined shape. Using any here makes it easy to pass the wrong structure and only discover it at runtime.

Since the API response structure is known (points, statistics, etc.), define a shared ElevationResponse type and use it here and in actions.tsx.

Suggestion

Introduce a shared type, e.g. in lib/types/elevation.ts:

  • export interface ElevationPoint { lng: number; lat: number; elevation: number }
  • export interface ElevationStatistics { min; max; average; count }
  • export interface ElevationResponse { success: boolean; points: ElevationPoint[]; statistics: ElevationStatistics; bounds: ...; gridSize: number }

Then update MapResultsContainerProps and the JSON parsing in actions.tsx.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit that replaces these any usages with shared types end-to-end.

@charliecreates charliecreates bot removed the request for review from CharlieHelps February 20, 2026 12:37
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: 19

Caution

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

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

240-243: ⚠️ Potential issue | 🔴 Critical

isCollapsed also not finalized in processResolutionSearch.

Same issue as the processEvents function: the finally block on lines 240-243 calls isGenerating.done(false) and uiStream.done() but never isCollapsed.done(false).

Proposed fix
     } finally {
       isGenerating.done(false)
+      isCollapsed.done(false)
       uiStream.done()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/actions.tsx` around lines 240 - 243, The finally block in
processResolutionSearch fails to finalize isCollapsed, mirroring the same bug
from processEvents; update the finally block inside processResolutionSearch to
call isCollapsed.done(false) in addition to isGenerating.done(false) and
uiStream.done() so isCollapsed is always cleaned up when the function completes
or errors.

456-522: ⚠️ Potential issue | 🔴 Critical

isCollapsed StreamableValue is never finalized outside the inquire branch.

isCollapsed is created on line 56 but only done()'d in the inquire path (line 460). In the researcher/writer path (lines 473-522) and the error path (lines 485-489), isCollapsed is left open. An unfinalized StreamableValue can cause the client to hang waiting for further updates.

Proposed fix
       if (hasError) {
         isGenerating.done(false)
+        isCollapsed.done(false)
         uiStream.done()
         return
       }

And at the end of the else block:

       isGenerating.done(false)
+      isCollapsed.done(false)
       uiStream.done()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/actions.tsx` around lines 456 - 522, The isCollapsed StreamableValue is
only finalized in the inquire branch; add isCollapsed.done(false) in the
researcher/writer and error paths so the client isn't left waiting: in the
hasError branch (after isGenerating.done(false) and before uiStream.done() /
return) call isCollapsed.done(false), and in the successful else flow call
isCollapsed.done(false) before isGenerating.done(false) and uiStream.done()
(i.e., finalize isCollapsed just prior to ending generation and closing uiStream
in the code that follows researcher/writer/querySuggestor).
components/header-search-button.tsx (2)

119-132: ⚠️ Potential issue | 🔴 Critical

FormData keys file_mapbox and file_google don't match what the server reads.

The server action (app/actions.tsx lines 79-80) reads formData.get('mapboxImage') and formData.get('googleImage') as strings, but this component appends blobs under 'file_mapbox' and 'file_google'. The mapbox/google images will never reach the ResolutionCarousel.

Either align the keys, or convert the blobs to data URLs on the client and append them as string values under the expected keys.

Proposed fix — add string data URLs for the expected keys
   if (mapboxBlob) formData.append('file_mapbox', mapboxBlob, 'mapbox_capture.png')
   if (googleBlob) formData.append('file_google', googleBlob, 'google_capture.png')

+  // Provide data URLs for the server to pass through to the carousel
+  if (mapboxBlob) {
+    const mapboxDataUrl = await blobToDataURL(mapboxBlob)
+    formData.append('mapboxImage', mapboxDataUrl)
+  }
+  if (googleBlob) {
+    const googleDataUrl = await blobToDataURL(googleBlob)
+    formData.append('googleImage', googleDataUrl)
+  }
+
   // Keep 'file' for backward compatibility
   formData.append('file', (mapboxBlob || googleBlob)!, 'map_capture.png')

Where blobToDataURL converts a Blob to a base64 data URL string.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/header-search-button.tsx` around lines 119 - 132, The client is
appending blobs as 'file_mapbox' and 'file_google' but the server
(app/actions.tsx reading mapboxImage and googleImage) expects string fields
named 'mapboxImage' and 'googleImage'; fix header-search-button.tsx by
converting mapboxBlob and googleBlob to base64 data URLs (use a blobToDataURL
helper) and append those strings to formData under the keys 'mapboxImage' and
'googleImage' (keep the existing blob keys if backward compatibility is
desired), ensuring you still append 'file' only if needed and preserve other
formData fields like action, timezone, and drawnFeatures.

150-167: ⚠️ Potential issue | 🟠 Major

Desktop and mobile buttons are permanently disabled when mapProvider is 'google'.

Both buttons use disabled={isAnalyzing || !map || !actions}, but map comes from useMap() which is hardcoded to Mapbox (typed as MapboxMap | null). When mapProvider === 'google', map is always null, making the button permanently disabled — even though lines 96-117 contain a fully functional Google Maps code path.

The fix is to check mapProvider before treating a missing map as a disable condition:

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

Apply to both the desktop button (line 155) and mobile button (line 167).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/header-search-button.tsx` around lines 150 - 167, desktopButton
and mobileButton are being disabled because useMap() returns a Mapbox-typed
`map` which is null when mapProvider === 'google'; update the disabled checks in
the `desktopButton` and `mobileButton` JSX (where `disabled={isAnalyzing || !map
|| !actions}` is used) to account for `mapProvider` so that a missing `map` only
disables the button when the provider is Mapbox (e.g., change the condition to
require `map` only when `mapProvider !== 'google'`, keeping the `isAnalyzing`
and `!actions` checks unchanged) and keep `handleResolutionSearch` as the click
handler.
📜 Review details

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f622145 and b2fc965.

📒 Files selected for processing (13)
  • app/actions.tsx
  • app/api/elevation/route.ts
  • app/layout.tsx
  • components/header-search-button.tsx
  • components/map/elevation-heatmap-layer.tsx
  • components/map/map-results-container.tsx
  • components/mobile-icons-bar.tsx
  • components/resolution-carousel.tsx
  • components/search-related.tsx
  • lib/agents/resolution-search.tsx
  • lib/agents/tools/geospatial.tsx
  • lib/utils/elevation.ts
  • public/htmx-fallback.js
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-02-08T16:50:33.791Z
Learnt from: ngoiyaeric
Repo: QueueLab/QCX PR: 0
File: :0-0
Timestamp: 2026-02-08T16:50:33.791Z
Learning: The PR branch implements RAG (Retrieval-Augmented Generation) via `lib/actions/rag.ts` which uses Supabase RPC functions `generate_embedding()` and `hybrid_search()` to retrieve relevant context from past conversations. However, there's a bug on line 433 of `app/actions.tsx` where `currentSystemPrompt` is passed to the researcher instead of `augmentedSystemPrompt`, preventing the retrieved context from being used.

Applied to files:

  • app/actions.tsx
🧬 Code graph analysis (6)
lib/utils/elevation.ts (3)
components/map/geojson-layer.tsx (3)
  • map (16-97)
  • GeoJsonLayer (13-100)
  • map (24-80)
components/map/mapbox-map.tsx (1)
  • feature (79-165)
components/map/map-3d-types.ts (1)
  • Map3DElement (23-30)
app/api/elevation/route.ts (1)
app/api/embeddings/route.ts (2)
  • GET (126-276)
  • findFileForLocation (59-88)
components/search-related.tsx (4)
components/chat-messages.tsx (1)
  • ChatMessagesProps (7-9)
components/chat.tsx (1)
  • aiState (78-83)
components/sidebar/chat-history-client.tsx (1)
  • ChatHistoryClientProps (26-28)
components/chat-panel.tsx (1)
  • ChatPanelProps (18-23)
lib/agents/tools/geospatial.tsx (3)
mapbox_mcp/hooks.ts (5)
  • initClient (60-72)
  • isConnected (49-282)
  • composioClientRef (62-70)
  • composioClientRef (106-121)
  • setIsLoading (75-104)
mapbox_mcp/index.ts (1)
  • testComposioConnection (3-52)
mapbox_mcp/composio-mapbox.ts (1)
  • initializeComposioMapbox (96-115)
components/header-search-button.tsx (1)
components/chat.tsx (1)
  • aiState (78-83)
app/actions.tsx (3)
lib/types/index.ts (2)
  • AIMessage (59-77)
  • Chat (49-57)
components/map/map-results-container.tsx (1)
  • MapResultsContainer (19-32)
lib/auth/get-current-user.ts (1)
  • getCurrentUserIdOnServer (112-127)
🔇 Additional comments (5)
public/htmx-fallback.js (1)

1-15: LGTM — clean IIFE safety fallback.

The guarded stub pattern correctly avoids overwriting any already-defined handlers and the bracket-notation property names ("func validation:validate", etc.) are valid JavaScript.

lib/utils/elevation.ts (1)

42-44: Remove unused decodeMapboxTerrainRGB function from lib/utils/elevation.ts.

The function targets raster Terrain-RGB encoding, but the elevation API uses the vector mapbox.mapbox-terrain-v2 tileset and reads elevation directly from f.properties.ele (contour lines). The function is not imported or called anywhere in the codebase, making it dead code that could confuse developers about which encoding method is actually in use.

app/actions.tsx (3)

33-42: LGTM — Clean public type definitions.

The UIState and AIState types are well-structured and properly exported for use across the codebase.


474-483: No issues to address. The review comment references a concern about augmentedSystemPrompt and RAG infrastructure, but neither exists in this codebase. The researcher() function accepts a dynamicSystemPrompt parameter and is correctly called with currentSystemPrompt on line 476. There is no pre-existing bug here.

Likely an incorrect or invalid review comment.


551-559: saveChat receives an incomplete Chat object.

saveChat is called with { id: chatId, messages } cast via as any, missing fields like title, createdAt, and path from the Chat interface. While userId is passed as a separate argument, the incomplete object may cause persistence failures depending on saveChat's implementation.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/actions.tsx`:
- Around line 603-628: The 'resolution_search_result' branch currently calls
JSON.parse(content) without error handling; wrap the parse of content into a
try/catch (same pattern used in the 'tool' case) to catch JSON parse errors, log
or surface the error and return a fallback/empty response instead of throwing;
specifically surround the JSON.parse into a try block, assign
analysisResult/geoJson/image/mapboxImage/googleImage/elevationPoints inside it,
and in the catch log the error (or create an error component) and return an
appropriate object instead of rendering ResolutionCarousel and
MapResultsContainer.
- Around line 572-588: In getUIStateFromAIState's switch mapping, wrap the
JSON.parse calls in the 'related' and 'resolution_search_result' cases in a
try/catch so malformed JSON can't throw and break the whole mapping; for each
case (the 'related' case that assigns relatedQueries / relatedStream using
createStreamableValue and the 'resolution_search_result' case that parses
resolution queries) catch parse errors, optionally log or swallow them, and fall
back to a safe value (e.g., the original content string or an empty array)
before calling createStreamableValue and returning the component—this mirrors
the existing error-handling used in the 'tool' case and prevents uncaught
exceptions.
- Around line 78-90: The form keys are mismatched: app/actions.tsx reads
formData.get('mapboxImage') and formData.get('googleImage') while
HeaderSearchButton appends blobs as 'file_mapbox' and 'file_google', causing
mapbox/google images to be null; fix by making the keys consistent—either update
HeaderSearchButton to append the blobs under 'mapboxImage' and 'googleImage'
(preferred) or update app/actions.tsx to read 'file_mapbox' and 'file_google'
instead, and ensure ResolutionCarousel receives the same property names
(mapboxDataUrl/googleDataUrl) so the comparison slide and individual satellite
images render.
- Around line 134-156: Replace the server-side HTTP self-fetch of /api/elevation
with a direct call to a shared elevation function (e.g., create a
getElevationData/getElevationPoints utility) and call that from the server
action instead of using fetch with NEXT_PUBLIC_BASE_URL; if you must keep the
HTTP approach as a quick patch, stop using stringified JSON inline and build the
query with URLSearchParams or encodeURIComponent for
analysisResult.elevationData.bounds, analysisResult.elevationData.gridSize and
drawnFeatures[0].geometry, and avoid defaulting to 'http://localhost:3000' so
the server action does not rely on a client base URL. Ensure elevationPointsData
is assigned from the utility/encoded-response handling used.

In `@app/api/elevation/route.ts`:
- Around line 62-88: The current code creates elevationPromises for every point
and calls Promise.all(elevationPromises), which issues up to 441 concurrent
Mapbox tilequery requests; replace this with controlled concurrency/batching to
avoid rate limits and timeouts by processing points in chunks (e.g., BATCH_SIZE
= 50) or using a concurrency limiter (like p-limit) so only a limited number of
fetches run concurrently; specifically, change the logic around points,
elevationPromises and elevationData to iterate over points in slices
(points.slice(i, i + BATCH_SIZE)) or wrap the fetch logic in a limited-run
queue, await each batch (or limited-run promises) before continuing, and
accumulate results into elevationData instead of firing all requests at once.
- Around line 144-147: The POST handler's features.map callback accesses
feature.geometry.type without null-checking, which throws when geometry is
missing; update the mapping in the POST handler (the async callback passed to
features.map) to defensively check for feature and feature.geometry (e.g., use
optional chaining or explicit null checks) before reading .type and skip/return
null for malformed features so Promise.all won't reject on a TypeError.
- Line 32: The code parses geometryParam inline (const geometry = geometryParam
? JSON.parse(geometryParam) : null;) which will throw on invalid JSON and bubble
to the outer catch returning 500; wrap the JSON.parse for geometryParam in the
same guarded try/catch used for boundsParam so that invalid geometry JSON
returns a 400 with a clear error message. Locate geometryParam/geometry in the
route handler (route.ts) and change the assignment to parse inside a try block,
set geometry to null or the parsed object on success, and on JSON parse failure
call the same bad request response flow used for boundsParam.
- Line 34: The destructuring const [west, south, east, north] = bounds can
produce undefined values if bounds.length < 4; update the route handler to
validate bounds is an array of length 4 (in addition to current type checks)
before destructuring, and return a 400/appropriate error when the length is
invalid; reference the bounds variable and the destructuring expression (const
[west, south, east, north] = bounds) so you add the length check immediately
prior to that line and avoid proceeding to coordinate parsing and Mapbox fetches
when the check fails.
- Around line 58-86: The code does not guard against a missing Mapbox token (the
token variable used in the fetch URL), causing every request in
elevationPromises (points.map(...)) to silently fail; add an early check before
creating elevationPromises to validate token (from MAPBOX_ACCESS_TOKEN or
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN) and short-circuit the handler with an explicit
error/failed response (or throw) if absent so you don't perform the per-point
fetches and return a false-success payload; update the handler entrypoint that
consumes elevationPromises to rely on this guard and ensure the error is logged
and returned to the client instead of letting the per-point catch blocks hide
401 failures.

In `@app/layout.tsx`:
- Line 70: The Script instance used for the HTMX fallback stub (the <Script ...
/> in app layout) should use strategy="afterInteractive" and be moved out of the
manual <head> JSX into a sibling of the <body> element per Next.js canonical
pattern; update the Script component invocation that references
"/htmx-fallback.js" to change strategy to "afterInteractive" and relocate that
JSX so it is rendered alongside (not inside) the <body> element.

In `@components/map/elevation-heatmap-layer.tsx`:
- Line 25: The state for the dynamically imported mapbox module loses type
safety; update the useState declaration for the mapboxgl variable to a proper
Mapbox GL type (e.g., use a typeof import('mapbox-gl') or an explicit MapboxGL
type) so setMapboxgl/getMapboxgl are strongly typed. Add an import type for the
mapbox-gl module (import type mapboxgl from 'mapbox-gl' or use the
import('mapbox-gl') type inline) and change const [mapboxgl, setMapboxgl] =
useState<any>(null) to useState<typeof import('mapbox-gl') | null>(null) (or
useState<mapboxgl | null>(null)) so all usages of mapboxgl and setMapboxgl have
correct types.
- Around line 110-123: The map event handlers are registered but never removed:
hoist the handlers out of the inline callbacks by defining clickHandler,
mouseEnterHandler, and mouseLeaveHandler in the same useEffect scope where you
call map.on(pointsLayerId, ...), replace the anonymous mouseenter/mouseleave
arrow functions with mouseEnterHandler and mouseLeaveHandler, and then in the
useEffect cleanup call map.off('click', pointsLayerId, clickHandler),
map.off('mouseenter', pointsLayerId, mouseEnterHandler), and
map.off('mouseleave', pointsLayerId, mouseLeaveHandler) so the handlers are
properly unregistered when the component unmounts (ensure you still remove the
popup behavior within clickHandler as before).
- Around line 127-139: The cleanup currently removes layers/sources but doesn't
cancel a pending load listener, so if map.once('load', onMapLoad) was registered
and the component unmounts the onMapLoad callback can still run and add orphaned
layers; update the cleanup to also call map.off('load', onMapLoad) (or remove
the listener registered) to ensure the onMapLoad handler is deregistered when
the effect cleans up, keeping references to the same onMapLoad function and
leaving existing removal of pointsLayerId, heatmapLayerId and sourceId intact.

In `@components/map/map-results-container.tsx`:
- Around line 13-16: The prop type for elevationPoints currently uses any/any[]
which bypasses type safety; update the prop to reuse the typed shapes from
ElevationHeatmapLayer by importing/exporting its ElevationPoint interface and
the statistics type (e.g., ElevationPoint[] for points and the exported
statistics type) and replace the anonymous any types in the elevationPoints prop
declaration with those imported types so MapResultsContainer uses the exact
ElevationHeatmapLayer types.

In `@components/search-related.tsx`:
- Line 25: Replace the untyped useUIState usage with the exported UIState type:
change useUIState<any>() to useUIState<UIState>() for the declaration that
provides setMessages (symbol: setMessages) and update the callback parameter
type where setMessages is used (the callback at the location that updates
messages around line 38) to accept UIState instead of any; apply the same
pattern to the other files noted by replacing useUIState<any>() with
useUIState<UIState>() in mobile-icons-bar.tsx, resolution-carousel.tsx, and
header-search-button.tsx so all useUIState usages and their callbacks are
strongly typed to UIState.

In `@lib/agents/resolution-search.tsx`:
- Around line 34-35: Validate the "bounds" schema to ensure exactly four numeric
entries (west, south, east, north) instead of an open array, and constrain
"gridSize" to a positive integer greater than zero; update the zod schema in
resolution-search.tsx to use a fixed-length tuple or .min/.max length check for
bounds and use .int().positive() (and optionally a default) for gridSize so the
destructuring const [west, south, east, north] receives defined numbers and grid
generation cannot receive zero/negative values.

In `@lib/agents/tools/geospatial.tsx`:
- Around line 55-56: The local variable config and the MapboxConfig interface
are dead after the simplification: remove the unused const config and the
MapboxConfig type if mapboxAccessToken/version/name are not actually forwarded;
alternatively, if getConnectedMcpClient should pass mapboxAccessToken through,
update getConnectedMcpClient to construct and forward a config object (including
mapboxAccessToken) to the MCP client instead of using hardcoded literals —
locate the unused config declaration and the MapboxConfig interface and either
delete them or wire them into getConnectedMcpClient so the token/version/name
are passed to the MCP client constructor.

In `@lib/utils/elevation.ts`:
- Line 1: The file imports the entire `@turf/turf` namespace but never uses it;
replace the dead import by using turf.bbox in getBoundsFromFeatures (or remove
the import entirely). Update getBoundsFromFeatures to call
turf.bbox(featureCollectionOrArray) to compute [minX,minY,maxX,maxY] which
handles all GeoJSON types (including Multi* geometries) instead of manual
coordinate loops, leaving decodeMapboxTerrainRGB and getElevationColor
unchanged; if you prefer not to add the turf dependency, simply remove the
import line and keep the existing manual bounds logic.
- Around line 50-66: In getElevationColor, clamp the computed normalized value
to the [0,1] range (e.g., normalized = Math.min(1, Math.max(0, normalized)))
before branching so out-of-range elevations cannot produce invalid RGB math, and
optionally clamp each interpolant t = ... to [0,1] as well; update the
normalized/t calculations inside getElevationColor (and any usages of variable
t) so all color channel math operates on clamped values.

---

Outside diff comments:
In `@app/actions.tsx`:
- Around line 240-243: The finally block in processResolutionSearch fails to
finalize isCollapsed, mirroring the same bug from processEvents; update the
finally block inside processResolutionSearch to call isCollapsed.done(false) in
addition to isGenerating.done(false) and uiStream.done() so isCollapsed is
always cleaned up when the function completes or errors.
- Around line 456-522: The isCollapsed StreamableValue is only finalized in the
inquire branch; add isCollapsed.done(false) in the researcher/writer and error
paths so the client isn't left waiting: in the hasError branch (after
isGenerating.done(false) and before uiStream.done() / return) call
isCollapsed.done(false), and in the successful else flow call
isCollapsed.done(false) before isGenerating.done(false) and uiStream.done()
(i.e., finalize isCollapsed just prior to ending generation and closing uiStream
in the code that follows researcher/writer/querySuggestor).

In `@components/header-search-button.tsx`:
- Around line 119-132: The client is appending blobs as 'file_mapbox' and
'file_google' but the server (app/actions.tsx reading mapboxImage and
googleImage) expects string fields named 'mapboxImage' and 'googleImage'; fix
header-search-button.tsx by converting mapboxBlob and googleBlob to base64 data
URLs (use a blobToDataURL helper) and append those strings to formData under the
keys 'mapboxImage' and 'googleImage' (keep the existing blob keys if backward
compatibility is desired), ensuring you still append 'file' only if needed and
preserve other formData fields like action, timezone, and drawnFeatures.
- Around line 150-167: desktopButton and mobileButton are being disabled because
useMap() returns a Mapbox-typed `map` which is null when mapProvider ===
'google'; update the disabled checks in the `desktopButton` and `mobileButton`
JSX (where `disabled={isAnalyzing || !map || !actions}` is used) to account for
`mapProvider` so that a missing `map` only disables the button when the provider
is Mapbox (e.g., change the condition to require `map` only when `mapProvider
!== 'google'`, keeping the `isAnalyzing` and `!actions` checks unchanged) and
keep `handleResolutionSearch` as the click handler.

---

Duplicate comments:
In `@components/header-search-button.tsx`:
- Around line 27-28: The code weakens types by casting useActions<any>() to
HeaderActions and using useUIState<any>() for setMessages; replace the any casts
with precise types: call useActions<HeaderActions>() (or adjust HeaderActions to
match) and useUIState<YourUIStateType>() so setMessages gets the correct message
type, updating any related handlers in the HeaderActions and the component to
use the exact types instead of any.

In `@components/mobile-icons-bar.tsx`:
- Line 29: The state hook in mobile-icons-bar.tsx uses an unsafe any for
useUIState (const [, setMessages] = useUIState<any>()); replace the any with the
proper messages type (e.g., Message[] or the shared MessagesState type used in
search-related.tsx) by importing or reusing that type and updating the generic
passed to useUIState so setMessages has correct typings; ensure you update any
related code in the mobile-icons-bar component to match the new type signature
(useUIState<MessagesType> or equivalent).

In `@components/resolution-carousel.tsx`:
- Around line 28-29: The double cast is redundant: remove the unnecessary "as
any" on the actions declaration so it reads using only useActions<any>() (or
preferably give a concrete type to useActions instead of any); update the line
declaring actions (symbol: actions, call: useActions) to drop the extra cast and
keep setMessages/useUIState unchanged.

Comment on lines +78 to +90
const file = formData?.get('file') as File;
const mapboxImage = formData?.get('mapboxImage') as string;
const googleImage = formData?.get('googleImage') as string;

if (!file) {
throw new Error('No file provided for resolution search.');
isGenerating.done(false);
return { id: nanoid(), isGenerating: isGenerating.value, component: null, isCollapsed: isCollapsed.value };
}

const mapboxBuffer = file_mapbox ? await file_mapbox.arrayBuffer() : null;
const mapboxDataUrl = mapboxBuffer ? `data:${file_mapbox.type};base64,${Buffer.from(mapboxBuffer).toString('base64')}` : null;

const googleBuffer = file_google ? await file_google.arrayBuffer() : null;
const googleDataUrl = googleBuffer ? `data:${file_google.type};base64,${Buffer.from(googleBuffer).toString('base64')}` : null;

const buffer = await file.arrayBuffer();
const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`;
const mapboxDataUrl = mapboxImage || null;
const googleDataUrl = googleImage || null;
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for all formData.append operations to understand what keys are being used
rg -n "formData\.append|formData\.get" --type ts --type tsx -A 2 -B 2 | head -100

Repository: QueueLab/QCX

Length of output: 83


🏁 Script executed:

#!/bin/bash
# Search for formData operations in all TypeScript/TSX files
rg -n "formData\.(append|get)" -A 2 -B 2 | head -150

Repository: QueueLab/QCX

Length of output: 3664


🏁 Script executed:

#!/bin/bash
# Check the app/actions.tsx file around lines 78-90
cat -n app/actions.tsx | sed -n '70,100p'

Repository: QueueLab/QCX

Length of output: 1414


🏁 Script executed:

#!/bin/bash
# Search for any formData.append with 'mapboxImage' or 'googleImage' keys
rg -n "mapboxImage|googleImage" --type-add 'ts:*.ts' --type-add 'tsx:*.tsx' -B 2 -A 2

Repository: QueueLab/QCX

Length of output: 4613


🏁 Script executed:

#!/bin/bash
# Search for any place where 'mapboxImage' or 'googleImage' is appended to formData
rg -n "append.*mapboxImage|append.*googleImage"

Repository: QueueLab/QCX

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Look at how mapboxDataUrl and googleDataUrl are used after being set to null
cat -n app/actions.tsx | sed -n '88,260p'

Repository: QueueLab/QCX

Length of output: 7014


Form data key mismatch prevents mapbox/google images from displaying.

The server reads formData.get('mapboxImage') and formData.get('googleImage') (app/actions.tsx, lines 79-80), but HeaderSearchButton appends blobs under keys file_mapbox and file_google (header-search-button.tsx, lines 124-125). These values will always be null when submitted from that component. As a result, ResolutionCarousel receives null values and cannot display the comparison slide or individual mapbox/google satellite images—only the uploaded image is shown.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/actions.tsx` around lines 78 - 90, The form keys are mismatched:
app/actions.tsx reads formData.get('mapboxImage') and
formData.get('googleImage') while HeaderSearchButton appends blobs as
'file_mapbox' and 'file_google', causing mapbox/google images to be null; fix by
making the keys consistent—either update HeaderSearchButton to append the blobs
under 'mapboxImage' and 'googleImage' (preferred) or update app/actions.tsx to
read 'file_mapbox' and 'file_google' instead, and ensure ResolutionCarousel
receives the same property names (mapboxDataUrl/googleDataUrl) so the comparison
slide and individual satellite images render.

Comment on lines +134 to +156
// Handle elevation heat map if requested
let elevationPointsData = null;
if (analysisResult.elevationData?.requested && analysisResult.elevationData.bounds) {
try {
const elevationResponse = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/elevation?` +
`bounds=${JSON.stringify(analysisResult.elevationData.bounds)}&gridSize=${analysisResult.elevationData.gridSize || 20}${
drawnFeatures.length > 0 && drawnFeatures[0].geometry
? `&geometry=${JSON.stringify(drawnFeatures[0].geometry)}`
: ''
}`
);

if (elevationResponse.ok) {
const elevationData = await elevationResponse.json();
if (elevationData.success && elevationData.points.length > 0) {
elevationPointsData = elevationData;
}
}
} catch (error) {
console.error('Error fetching elevation data:', error);
}
}
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:

#!/bin/bash
# Check if elevation route logic can be imported directly instead of fetching via HTTP
echo "=== Elevation-related files ==="
fd "elevation" --type f \( -name "*.ts" -o -name "*.tsx" \) -E node_modules | head -20

echo "=== Elevation route handlers ==="
fd "route.ts" --type f -E node_modules | xargs rg -l "elevation" 2>/dev/null || echo "No elevation in route handlers found"

echo "=== Exportable elevation functions ==="
rg -n "export.*function.*elevation|export.*const.*elevation|export.*async.*function" --type ts --type tsx -g '!node_modules' | grep -i elevation

echo "=== API directory structure ==="
fd . "app/api" --type f -name "*.ts" | head -20

Repository: QueueLab/QCX

Length of output: 725


🏁 Script executed:

#!/bin/bash
echo "=== Content of elevation route handler ==="
cat -n "app/api/elevation/route.ts"

echo -e "\n=== Content of app/actions.tsx (lines 130-160) ==="
sed -n '130,160p' "app/actions.tsx" | cat -n

echo -e "\n=== Check if elevation logic exists as a separate utility ==="
find . -type f \( -name "*.ts" -o -name "*.tsx" \) -path "*/app/*" ! -path "*/node_modules/*" ! -path "*/.next/*" | xargs grep -l "elevation" 2>/dev/null

Repository: QueueLab/QCX

Length of output: 8442


Use encodeURIComponent() for query parameters, or refactor to avoid server-to-self HTTP calls.

The elevation data fetch has two issues:

  1. Self-fetch anti-pattern: Calling /api/elevation via HTTP from a server action is unreliable. NEXT_PUBLIC_BASE_URL won't resolve correctly in serverless/container environments, and localhost:3000 fails in production. Consider extracting the elevation logic to a shared utility function instead of going through HTTP.

  2. Unencoded query parameters: JSON.stringify() output contains unencoded characters ({, }, ", :) that should be percent-encoded in URLs. The POST handler in the same route file correctly uses URLSearchParams for this—apply the same pattern here using encodeURIComponent().

Immediate fix for URL encoding
-              `bounds=${JSON.stringify(analysisResult.elevationData.bounds)}&gridSize=${analysisResult.elevationData.gridSize || 20}${
+              `bounds=${encodeURIComponent(JSON.stringify(analysisResult.elevationData.bounds))}&gridSize=${analysisResult.elevationData.gridSize || 20}${
                 drawnFeatures.length > 0 && drawnFeatures[0].geometry
-                  ? `&geometry=${JSON.stringify(drawnFeatures[0].geometry)}`
+                  ? `&geometry=${encodeURIComponent(JSON.stringify(drawnFeatures[0].geometry))}`
                   : ''
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/actions.tsx` around lines 134 - 156, Replace the server-side HTTP
self-fetch of /api/elevation with a direct call to a shared elevation function
(e.g., create a getElevationData/getElevationPoints utility) and call that from
the server action instead of using fetch with NEXT_PUBLIC_BASE_URL; if you must
keep the HTTP approach as a quick patch, stop using stringified JSON inline and
build the query with URLSearchParams or encodeURIComponent for
analysisResult.elevationData.bounds, analysisResult.elevationData.gridSize and
drawnFeatures[0].geometry, and avoid defaulting to 'http://localhost:3000' so
the server action does not rely on a client base URL. Ensure elevationPointsData
is assigned from the utility/encoded-response handling used.

Comment on lines +572 to +588
switch (type) {
case 'response':
const botMessageStream = createStreamableValue(content as string)
botMessageStream.done(content as string)
return {
id,
component: <BotMessage content={botMessageStream.value} />
}
break
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: []
})
relatedQueries.done(JSON.parse(content as string))
return {
id,
component: (
<Section title="Related" separator={true}>
<SearchRelated relatedQueries={relatedQueries.value} />
</Section>
)
}
case 'followup':
return {
id,
component: (
<Section title="Follow-up" className="pb-8">
<FollowupPanel />
</Section>
)
}
case 'resolution_search_result': {
const analysisResult = JSON.parse(content as string);
const geoJson = analysisResult.geoJson as FeatureCollection;
const image = analysisResult.image as string;
const mapboxImage = analysisResult.mapboxImage as string;
const googleImage = analysisResult.googleImage as string;
case 'related':
const relatedQueries = JSON.parse(content as string)
const relatedStream = createStreamableValue(relatedQueries)
relatedStream.done(relatedQueries)
return {
id,
component: <SearchRelated relatedQueries={relatedStream.value} />,
isCollapsed: true
}
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 try/catch around JSON.parse for 'related' and 'resolution_search_result' cases.

Lines 581 and 604 call JSON.parse(content as string) without error handling. If content is malformed, this throws an uncaught exception that crashes the entire getUIStateFromAIState mapping. The 'tool' case on line 630 correctly wraps JSON.parse in try/catch — these cases should be consistent.

Proposed fix for 'related' case
       case 'related':
-          const relatedQueries = JSON.parse(content as string)
-          const relatedStream = createStreamableValue(relatedQueries)
-          relatedStream.done(relatedQueries)
-          return {
-            id,
-            component: <SearchRelated relatedQueries={relatedStream.value} />,
-            isCollapsed: true
-          }
+          try {
+            const relatedQueries = JSON.parse(content as string)
+            const relatedStream = createStreamableValue(relatedQueries)
+            relatedStream.done(relatedQueries)
+            return {
+              id,
+              component: <SearchRelated relatedQueries={relatedStream.value} />,
+              isCollapsed: true
+            }
+          } catch (error) {
+            console.error('Error parsing related content:', error)
+            return { id, component: null }
+          }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/actions.tsx` around lines 572 - 588, In getUIStateFromAIState's switch
mapping, wrap the JSON.parse calls in the 'related' and
'resolution_search_result' cases in a try/catch so malformed JSON can't throw
and break the whole mapping; for each case (the 'related' case that assigns
relatedQueries / relatedStream using createStreamableValue and the
'resolution_search_result' case that parses resolution queries) catch parse
errors, optionally log or swallow them, and fall back to a safe value (e.g., the
original content string or an empty array) before calling createStreamableValue
and returning the component—this mirrors the existing error-handling used in the
'tool' case and prevents uncaught exceptions.

Comment on lines +603 to +628
case 'resolution_search_result': {
const analysisResult = JSON.parse(content as string);
const geoJson = analysisResult.geoJson as FeatureCollection;
const image = analysisResult.image as string;
const mapboxImage = analysisResult.mapboxImage as string;
const googleImage = analysisResult.googleImage as string;
const elevationPoints = analysisResult.elevationPoints;

return {
id,
component: (
<>
<ResolutionCarousel
mapboxImage={mapboxImage}
googleImage={googleImage}
initialImage={image}
/>
{geoJson && (
<GeoJsonLayer id={id} data={geoJson} />
)}
</>
)
}
}
return {
id,
component: (
<>
<ResolutionCarousel
mapboxImage={mapboxImage}
googleImage={googleImage}
initialImage={image}
/>
<MapResultsContainer
id={id}
geoJson={geoJson}
elevationPoints={elevationPoints}
/>
</>
)
}
break
}
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

Same JSON.parse without try/catch in the 'resolution_search_result' case.

Line 604 parses content without error handling. Wrap it in try/catch for consistency with the 'tool' case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/actions.tsx` around lines 603 - 628, The 'resolution_search_result'
branch currently calls JSON.parse(content) without error handling; wrap the
parse of content into a try/catch (same pattern used in the 'tool' case) to
catch JSON parse errors, log or surface the error and return a fallback/empty
response instead of throwing; specifically surround the JSON.parse into a try
block, assign
analysisResult/geoJson/image/mapboxImage/googleImage/elevationPoints inside it,
and in the catch log the error (or create an error component) and return an
appropriate object instead of rendering ResolutionCarousel and
MapResultsContainer.

}

const gridSize = gridSizeParam ? parseInt(gridSizeParam) : 20;
const geometry = geometryParam ? JSON.parse(geometryParam) : null;
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

geometryParam JSON parse is unguarded — invalid JSON returns 500 instead of 400.

boundsParam has a dedicated try/catch returning a 400, but geometryParam is parsed inline on line 32 without a guard. An invalid JSON geometry string falls through to the outer catch and returns a generic 500 error.

🐛 Proposed fix
-    const geometry = geometryParam ? JSON.parse(geometryParam) : null;
+    let geometry = null;
+    if (geometryParam) {
+      try {
+        geometry = JSON.parse(geometryParam);
+      } catch {
+        return NextResponse.json({ error: 'Invalid geometry parameter' }, { status: 400 });
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/elevation/route.ts` at line 32, The code parses geometryParam inline
(const geometry = geometryParam ? JSON.parse(geometryParam) : null;) which will
throw on invalid JSON and bubble to the outer catch returning 500; wrap the
JSON.parse for geometryParam in the same guarded try/catch used for boundsParam
so that invalid geometry JSON returns a 400 with a clear error message. Locate
geometryParam/geometry in the route handler (route.ts) and change the assignment
to parse inside a try block, set geometry to null or the parsed object on
success, and on JSON parse failure call the same bad request response flow used
for boundsParam.

}) => {
const { submit } = useActions()
const [, setMessages] = useUIState<typeof AI>()
const [, setMessages] = useUIState<any>()
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

# First, check if UIState is actually exported from app/actions
rg -n "export.*UIState" --type ts --type tsx | head -20
echo "=== Checking imports from app/actions ==="
rg -n "from.*app/actions" --type ts --type tsx | head -30

Repository: QueueLab/QCX

Length of output: 170


🏁 Script executed:

# Check the actual implementation of search-related.tsx
fd -e tsx -e ts -path "*/search-related*"

Repository: QueueLab/QCX

Length of output: 224


🏁 Script executed:

# Check the other components mentioned in the review
fd -e tsx -e ts -path "*mobile-icons-bar*"
fd -e tsx -e ts -path "*resolution-carousel*"
fd -e tsx -e ts -path "*header-search-button*"

Repository: QueueLab/QCX

Length of output: 562


🏁 Script executed:

# Look at the structure of app/actions.tsx to understand the export situation
fd -e tsx -e ts -path "*app/actions*"

Repository: QueueLab/QCX

Length of output: 224


🏁 Script executed:

# Find TypeScript files and check for UIState export
find . -name "*.tsx" -o -name "*.ts" | grep -E "(app/actions|search-related|mobile-icons-bar|resolution-carousel|header-search-button)" | head -20

Repository: QueueLab/QCX

Length of output: 214


🏁 Script executed:

# Search for UIState export using correct rg syntax
rg "export.*UIState" -g "*.ts" -g "*.tsx"

Repository: QueueLab/QCX

Length of output: 229


🏁 Script executed:

# Check imports from app/actions
rg "from.*app/actions" -g "*.ts" -g "*.tsx" | head -30

Repository: QueueLab/QCX

Length of output: 402


🏁 Script executed:

# Find and display search-related.tsx
find . -name "search-related.tsx"

Repository: QueueLab/QCX

Length of output: 87


🏁 Script executed:

# Read search-related.tsx to see current implementation
cat -n ./components/search-related.tsx

Repository: QueueLab/QCX

Length of output: 2351


🏁 Script executed:

# Check mobile-icons-bar.tsx
cat -n ./components/mobile-icons-bar.tsx | head -50

Repository: QueueLab/QCX

Length of output: 2022


🏁 Script executed:

# Check resolution-carousel.tsx
cat -n ./components/resolution-carousel.tsx | head -50

Repository: QueueLab/QCX

Length of output: 1939


🏁 Script executed:

# Check header-search-button.tsx
cat -n ./components/header-search-button.tsx | head -50

Repository: QueueLab/QCX

Length of output: 2210


Replace any with the exported UIState type in useUIState declarations.

UIState is already exported from app/actions.tsx and used successfully in other components (e.g., chat-panel.tsx, chat-messages.tsx). Use useUIState<UIState>() instead of useUIState<any>() at line 25, and type the callback parameter at line 38. The same applies to mobile-icons-bar.tsx (line 29), resolution-carousel.tsx (line 29), and header-search-button.tsx (line 28).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/search-related.tsx` at line 25, Replace the untyped useUIState
usage with the exported UIState type: change useUIState<any>() to
useUIState<UIState>() for the declaration that provides setMessages (symbol:
setMessages) and update the callback parameter type where setMessages is used
(the callback at the location that updates messages around line 38) to accept
UIState instead of any; apply the same pattern to the other files noted by
replacing useUIState<any>() with useUIState<UIState>() in mobile-icons-bar.tsx,
resolution-carousel.tsx, and header-search-button.tsx so all useUIState usages
and their callbacks are strongly typed to UIState.

Comment on lines +34 to +35
bounds: z.array(z.number()).optional().describe('Bounding box for elevation query [west, south, east, north]'),
gridSize: z.number().optional().describe('Grid resolution for elevation sampling (default: 20)'),
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

bounds array is not validated for length; gridSize has no positivity constraint.

z.array(z.number()) accepts any number of elements. The API route destructures const [west, south, east, north] = bounds, so a wrong-length array will silently produce undefined values and broken coordinates. Similarly, z.number() allows 0 or negative gridSize, which would generate an empty or single-point grid.

♻️ Suggested fix
-    bounds: z.array(z.number()).optional().describe('Bounding box for elevation query [west, south, east, north]'),
-    gridSize: z.number().optional().describe('Grid resolution for elevation sampling (default: 20)'),
+    bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional().describe('Bounding box for elevation query [west, south, east, north]'),
+    gridSize: z.number().int().positive().optional().describe('Grid resolution for elevation sampling (default: 20)'),
📝 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
bounds: z.array(z.number()).optional().describe('Bounding box for elevation query [west, south, east, north]'),
gridSize: z.number().optional().describe('Grid resolution for elevation sampling (default: 20)'),
bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional().describe('Bounding box for elevation query [west, south, east, north]'),
gridSize: z.number().int().positive().optional().describe('Grid resolution for elevation sampling (default: 20)'),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/agents/resolution-search.tsx` around lines 34 - 35, Validate the "bounds"
schema to ensure exactly four numeric entries (west, south, east, north) instead
of an open array, and constrain "gridSize" to a positive integer greater than
zero; update the zod schema in resolution-search.tsx to use a fixed-length tuple
or .min/.max length check for bounds and use .int().positive() (and optionally a
default) for gridSize so the destructuring const [west, south, east, north]
receives defined numbers and grid generation cannot receive zero/negative
values.

Comment on lines +55 to +56
// Use fallback config (mapbox_mcp_config.json is optional and may be missing)
const config = { mapboxAccessToken, version: '1.0.0', name: 'mapbox-mcp-server' };
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

config is assigned but never referenced — dead variable after the simplification.

config.mapboxAccessToken, config.version, and config.name are not used anywhere in getConnectedMcpClient(). The MCP client is instantiated with hardcoded literals on line 93, and the Composio URL is built independently. If mapboxAccessToken genuinely needs to be forwarded to the MCP server, this should be verified; otherwise the variable (and the MapboxConfig interface on lines 30–34) can be removed.

♻️ Proposed fix
-  // Use fallback config (mapbox_mcp_config.json is optional and may be missing)
-  const config = { mapboxAccessToken, version: '1.0.0', name: 'mapbox-mcp-server' };
-
   // Build Composio MCP server URL
📝 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
// Use fallback config (mapbox_mcp_config.json is optional and may be missing)
const config = { mapboxAccessToken, version: '1.0.0', name: 'mapbox-mcp-server' };
// Build Composio MCP server URL
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/agents/tools/geospatial.tsx` around lines 55 - 56, The local variable
config and the MapboxConfig interface are dead after the simplification: remove
the unused const config and the MapboxConfig type if
mapboxAccessToken/version/name are not actually forwarded; alternatively, if
getConnectedMcpClient should pass mapboxAccessToken through, update
getConnectedMcpClient to construct and forward a config object (including
mapboxAccessToken) to the MCP client instead of using hardcoded literals —
locate the unused config declaration and the MapboxConfig interface and either
delete them or wire them into getConnectedMcpClient so the token/version/name
are passed to the MCP client constructor.

@@ -0,0 +1,67 @@
import * as turf from '@turf/turf';
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

turf is imported but never used — dead import.

None of the three exported functions (getBoundsFromFeatures, decodeMapboxTerrainRGB, getElevationColor) call any turf.* API, yet the full @turf/turf namespace is imported.

getBoundsFromFeatures could be simplified to use the already-available turf.bbox(), which handles all GeoJSON geometry types (including MultiPolygon, MultiLineString, MultiPoint that the manual implementation silently skips). Either use turf or remove the import.

♻️ Option A — use turf.bbox (removes manual coordinate loops)
 import * as turf from '@turf/turf';
 import type { Position } from 'geojson';
 
 export function getBoundsFromFeatures(features: Array<{ geometry: any }>): [number, number, number, number] | null {
   if (!features || features.length === 0) return null;
-  const allCoordinates: Position[] = [];
-  features.forEach(feature => {
-    if (feature.geometry.type === 'Polygon') {
-      feature.geometry.coordinates.forEach((ring: Position[]) => {
-        allCoordinates.push(...ring);
-      });
-    } else if (feature.geometry.type === 'LineString') {
-      allCoordinates.push(...feature.geometry.coordinates);
-    } else if (feature.geometry.type === 'Point') {
-      allCoordinates.push(feature.geometry.coordinates);
-    }
-  });
-  if (allCoordinates.length === 0) return null;
-  const lngs = allCoordinates.map(coord => coord[0]);
-  const lats = allCoordinates.map(coord => coord[1]);
-  return [
-    Math.min(...lngs),
-    Math.min(...lats),
-    Math.max(...lngs),
-    Math.max(...lats),
-  ];
+  try {
+    const fc = turf.featureCollection(features.map(f => ({ type: 'Feature', geometry: f.geometry, properties: {} })));
+    const bbox = turf.bbox(fc);
+    return bbox as [number, number, number, number];
+  } catch {
+    return null;
+  }
 }

Option B — remove the import if turf is not needed:

-import * as turf from '@turf/turf';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/utils/elevation.ts` at line 1, The file imports the entire `@turf/turf`
namespace but never uses it; replace the dead import by using turf.bbox in
getBoundsFromFeatures (or remove the import entirely). Update
getBoundsFromFeatures to call turf.bbox(featureCollectionOrArray) to compute
[minX,minY,maxX,maxY] which handles all GeoJSON types (including Multi*
geometries) instead of manual coordinate loops, leaving decodeMapboxTerrainRGB
and getElevationColor unchanged; if you prefer not to add the turf dependency,
simply remove the import line and keep the existing manual bounds logic.

Comment on lines +50 to +66
export function getElevationColor(elevation: number, min: number, max: number): string {
const normalized = (elevation - min) / (max - min || 1);

// Color scale: blue -> cyan -> green -> yellow -> red
if (normalized < 0.25) {
const t = normalized * 4;
return `rgb(${Math.round(33 + (103-33) * t)}, ${Math.round(102 + (169-102) * t)}, 172)`;
} else if (normalized < 0.5) {
const t = (normalized - 0.25) * 4;
return `rgb(${Math.round(103 + (209-103) * t)}, ${Math.round(169 + (229-169) * t)}, ${Math.round(207 + (240-207) * t)})`;
} else if (normalized < 0.75) {
const t = (normalized - 0.5) * 4;
return `rgb(${Math.round(209 + (253-209) * t)}, ${Math.round(229 + (219-229) * t)}, ${Math.round(240 + (199-240) * t)})`;
} else {
const t = (normalized - 0.75) * 4;
return `rgb(${Math.round(253 + (178-253) * t)}, ${Math.round(219 + (24-219) * t)}, ${Math.round(199 + (43-199) * t)})`;
}
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

getElevationColor does not clamp normalized — out-of-range elevations produce garbage color values.

If elevation < min or elevation > max, normalized is negative or greater than 1. In the last branch (line 64), t = (normalized - 0.75) * 4 can exceed 1, causing Math.round(253 + (178-253)*t) to go negative (e.g., t=1.25253 - 93.75 = 159, t=2253 - 150 = 103). While the API route should normally bound elevation within [min, max], defensive clamping prevents visual artifacts.

♻️ Proposed fix
 export function getElevationColor(elevation: number, min: number, max: number): string {
-  const normalized = (elevation - min) / (max - min || 1);
+  const normalized = Math.min(1, Math.max(0, (elevation - min) / (max - min || 1)));
📝 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
export function getElevationColor(elevation: number, min: number, max: number): string {
const normalized = (elevation - min) / (max - min || 1);
// Color scale: blue -> cyan -> green -> yellow -> red
if (normalized < 0.25) {
const t = normalized * 4;
return `rgb(${Math.round(33 + (103-33) * t)}, ${Math.round(102 + (169-102) * t)}, 172)`;
} else if (normalized < 0.5) {
const t = (normalized - 0.25) * 4;
return `rgb(${Math.round(103 + (209-103) * t)}, ${Math.round(169 + (229-169) * t)}, ${Math.round(207 + (240-207) * t)})`;
} else if (normalized < 0.75) {
const t = (normalized - 0.5) * 4;
return `rgb(${Math.round(209 + (253-209) * t)}, ${Math.round(229 + (219-229) * t)}, ${Math.round(240 + (199-240) * t)})`;
} else {
const t = (normalized - 0.75) * 4;
return `rgb(${Math.round(253 + (178-253) * t)}, ${Math.round(219 + (24-219) * t)}, ${Math.round(199 + (43-199) * t)})`;
}
export function getElevationColor(elevation: number, min: number, max: number): string {
const normalized = Math.min(1, Math.max(0, (elevation - min) / (max - min || 1)));
// Color scale: blue -> cyan -> green -> yellow -> red
if (normalized < 0.25) {
const t = normalized * 4;
return `rgb(${Math.round(33 + (103-33) * t)}, ${Math.round(102 + (169-102) * t)}, 172)`;
} else if (normalized < 0.5) {
const t = (normalized - 0.25) * 4;
return `rgb(${Math.round(103 + (209-103) * t)}, ${Math.round(169 + (229-169) * t)}, ${Math.round(207 + (240-207) * t)})`;
} else if (normalized < 0.75) {
const t = (normalized - 0.5) * 4;
return `rgb(${Math.round(209 + (253-209) * t)}, ${Math.round(229 + (219-229) * t)}, ${Math.round(240 + (199-240) * t)})`;
} else {
const t = (normalized - 0.75) * 4;
return `rgb(${Math.round(253 + (178-253) * t)}, ${Math.round(219 + (24-219) * t)}, ${Math.round(199 + (43-199) * t)})`;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/utils/elevation.ts` around lines 50 - 66, In getElevationColor, clamp the
computed normalized value to the [0,1] range (e.g., normalized = Math.min(1,
Math.max(0, normalized))) before branching so out-of-range elevations cannot
produce invalid RGB math, and optionally clamp each interpolant t = ... to [0,1]
as well; update the normalized/t calculations inside getElevationColor (and any
usages of variable t) so all color channel math operates on clamped values.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant