Skip to content
Merged
96 changes: 60 additions & 36 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { BotMessage } from '@/components/message'
import { SearchSection } from '@/components/search-section'
import SearchRelated from '@/components/search-related'
import { GeoJsonLayer } from '@/components/map/geojson-layer'
import { ResolutionCarousel } from '@/components/resolution-carousel'
import { ResolutionImage } from '@/components/resolution-image'
import { CopilotDisplay } from '@/components/copilot-display'
import RetrieveSection from '@/components/retrieve-section'
Expand Down Expand Up @@ -50,18 +51,29 @@ async function submit(formData?: FormData, skip?: boolean) {
}

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

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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

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

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

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

Also applies to: 67-74

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

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

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Validate latitude/longitude before forming location.

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

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


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

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 messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
message =>
(message: any) =>
message.role !== 'tool' &&
message.type !== 'followup' &&
message.type !== 'related' &&
Expand Down Expand Up @@ -89,7 +101,7 @@ async function submit(formData?: FormData, skip?: boolean) {

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

let fullSummary = '';
for await (const partialObject of streamResult.partialObjectStream) {
Expand All @@ -113,7 +125,7 @@ async function submit(formData?: FormData, skip?: boolean) {

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

const sanitizedMessages: CoreMessage[] = messages.map(m => {
const sanitizedMessages: CoreMessage[] = messages.map((m: any) => {
if (Array.isArray(m.content)) {
return {
...m,
Expand All @@ -124,7 +136,7 @@ async function submit(formData?: FormData, skip?: boolean) {
})

const currentMessages = aiState.get().messages;
const sanitizedHistory = currentMessages.map(m => {
const sanitizedHistory = currentMessages.map((m: any) => {
if (m.role === "user" && Array.isArray(m.content)) {
return {
...m,
Expand Down Expand Up @@ -159,7 +171,9 @@ async function submit(formData?: FormData, skip?: boolean) {
role: 'assistant',
content: JSON.stringify({
...analysisResult,
image: dataUrl
image: dataUrl,
mapboxImage: mapboxDataUrl,
googleImage: googleDataUrl
}),
type: 'resolution_search_result'
},
Expand Down Expand Up @@ -190,7 +204,11 @@ async function submit(formData?: FormData, skip?: boolean) {

uiStream.update(
<Section title="response">
<ResolutionImage src={dataUrl} />
<ResolutionCarousel
mapboxImage={mapboxDataUrl || undefined}
googleImage={googleDataUrl || undefined}
initialImage={dataUrl}
/>
<BotMessage content={summaryStream.value} />
</Section>
);
Expand All @@ -203,43 +221,20 @@ async function submit(formData?: FormData, skip?: boolean) {
};
}

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'
).map(m => {
if (Array.isArray(m.content)) {
return {
...m,
content: m.content.filter((part: any) =>
part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:"))
)
} as any
}
return m
})

const groupeId = nanoid()
const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true'
const maxMessages = useSpecificAPI ? 5 : 10
messages.splice(0, Math.max(messages.length - maxMessages, 0))

const file = !skip ? (formData?.get('file') as File) : undefined
const userInput = skip
? `{"action": "skip"}`
: ((formData?.get('related_query') as string) ||
(formData?.get('input') as string))

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

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

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

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

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

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


const content = JSON.stringify(Object.fromEntries(formData!));
const type = 'input';
const groupeId = nanoid();

aiState.update({
...aiState.get(),
Expand Down Expand Up @@ -299,10 +294,9 @@ async function submit(formData?: FormData, skip?: boolean) {
id: nanoid(),
isGenerating: isGenerating.value,
component: uiStream.value,
isCollapsed: isCollapsed.value,
isCollapsed: isCollapsed.value
};
}
const file = !skip ? (formData?.get('file') as File) : undefined

if (!userInput && !file) {
isGenerating.done(false)
Expand All @@ -314,6 +308,30 @@ async function submit(formData?: FormData, skip?: boolean) {
}
}

const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
(message: any) =>
message.role !== 'tool' &&
message.type !== 'followup' &&
message.type !== 'related' &&
message.type !== 'end' &&
message.type !== 'resolution_search_result'
).map((m: any) => {
if (Array.isArray(m.content)) {
return {
...m,
content: m.content.filter((part: any) =>
part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:"))
)
} as any
}
return m
})

const groupeId = nanoid()
const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true'
const maxMessages = useSpecificAPI ? 5 : 10
messages.splice(0, Math.max(messages.length - maxMessages, 0))

const messageParts: {
type: 'text' | 'image'
text?: string
Expand Down Expand Up @@ -725,12 +743,18 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
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;

return {
id,
component: (
<>
{image && <ResolutionImage src={image} />}
<ResolutionCarousel
mapboxImage={mapboxImage}
googleImage={googleImage}
initialImage={image}
/>
{geoJson && (
<GeoJsonLayer id={id} data={geoJson} />
)}
Expand Down
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 91 additions & 0 deletions components/compare-slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use client'

import React, { useState, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'

interface CompareSliderProps {
leftImage: string
rightImage: string
className?: string
}

export function CompareSlider({ leftImage, rightImage, className }: CompareSliderProps) {
const [sliderPosition, setSliderPosition] = useState(50)
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)

useEffect(() => {
if (!containerRef.current) return

const observer = new ResizeObserver((entries) => {
for (let entry of entries) {
setContainerWidth(entry.contentRect.width)
}
})

observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])

const handleMove = (event: React.MouseEvent | React.TouchEvent) => {
if (!containerRef.current) return

const containerRect = containerRef.current.getBoundingClientRect()
const x = 'touches' in event ? event.touches[0].clientX : (event as React.MouseEvent).clientX
const relativeX = x - containerRect.left
const position = Math.max(0, Math.min(100, (relativeX / containerRect.width) * 100))

setSliderPosition(position)
}

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

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add ARIA + keyboard support for the interactive slider container.

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

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

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

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

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

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

(lint/a11y/noStaticElementInteractions)

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

{/* Right Image (Google Satellite) */}
<img
src={rightImage}
alt="Google Satellite"
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
/>

{/* Left Image (Mapbox) */}
<div
className="absolute inset-0 overflow-hidden"
style={{ width: `${sliderPosition}%` }}
>
<div style={{ width: containerWidth }} className="h-full relative">
<img
src={leftImage}
alt="Mapbox"
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
/>
</div>
</div>

{/* Slider Handle */}
<div
className="absolute inset-y-0 w-1 bg-white shadow-[0_0_10px_rgba(0,0,0,0.5)] pointer-events-none"
style={{ left: `${sliderPosition}%` }}
>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 bg-white rounded-full border-2 border-primary flex items-center justify-center shadow-xl">
<div className="flex gap-1">
<div className="w-1 h-4 bg-primary/40 rounded-full" />
<div className="w-1 h-4 bg-primary/40 rounded-full" />
</div>
</div>
</div>

{/* Labels */}
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/50 text-white text-[10px] rounded pointer-events-none">
MAPBOX
</div>
<div className="absolute bottom-2 right-2 px-2 py-1 bg-black/50 text-white text-[10px] rounded pointer-events-none">
GOOGLE SATELLITE
</div>
</div>
)
}
Loading