Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions components/download-report-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
'use client'

import React, { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { FileText, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
import { useMap } from './map/map-context'
import { useMapData } from './map/map-data-context'
import { generatePDFReport } from '@/lib/utils/report-generator'
import { AI } from '@/app/actions'
import { toast } from 'sonner'
import { ReportTemplate } from './report-template'
import { createPortal } from 'react-dom'

export const DownloadReportButton = () => {
const [aiState] = useAIState<typeof AI>()
const { map } = useMap()
const { mapData } = useMapData()
const [isGenerating, setIsGenerating] = useState(false)
const [showTemplate, setShowTemplate] = useState(false)
const [mapSnapshot, setMapSnapshot] = useState<string | undefined>()
const [reportTitle, setReportTitle] = useState('QCX Analysis Report')
const [isMounted, setIsMounted] = useState(false)

useEffect(() => {
setIsMounted(true)
}, [])

const handleDownload = async () => {
if (!aiState || aiState.messages.length === 0) {
toast.error('No conversation to export')
return
}

setIsGenerating(true)
const toastId = toast.loading('Preparing report generation...')

try {
let snapshot: string | undefined
if (map) {
try {
// Use JPEG to keep the data URL smaller and potentially avoid context loss
snapshot = map.getCanvas().toDataURL('image/jpeg', 0.6)
setMapSnapshot(snapshot)
} catch (e) {
console.warn('Failed to capture map snapshot', e)
}
}

// Extract title more robustly
let chatTitle = 'Untitled Chat'
if (aiState.messages.length > 0) {
const firstMessage = aiState.messages[0]
const content = typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[]).map(p => p.type === 'text' ? p.text : '').join(' ')
: ''
Comment on lines +54 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Unsafe as any[] cast; consider narrowing the type.

The cast discards type information and could mask runtime issues if the AI SDK changes the content structure. A type guard or explicit interface would improve safety.

♻️ Proposed type-safe alternative
+type ContentPart = { type: string; text?: string };
+
 const content = typeof firstMessage.content === 'string'
   ? firstMessage.content
   : Array.isArray(firstMessage.content)
-    ? (firstMessage.content as any[]).map(p => p.type === 'text' ? p.text : '').join(' ')
+    ? (firstMessage.content as ContentPart[])
+        .filter((p): p is ContentPart & { text: string } => p.type === 'text' && typeof p.text === 'string')
+        .map(p => p.text)
+        .join(' ')
     : ''
📝 Committable suggestion

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

Suggested change
const content = typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[]).map(p => p.type === 'text' ? p.text : '').join(' ')
: ''
type ContentPart = { type: string; text?: string };
const content = typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as ContentPart[])
.filter((p): p is ContentPart & { text: string } => p.type === 'text' && typeof p.text === 'string')
.map(p => p.text)
.join(' ')
: ''
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/download-report-button.tsx` around lines 50 - 54, The code uses an
unsafe cast "(firstMessage.content as any[])" which discards type info; change
it to a type-safe check by adding a narrow type guard or explicit interface for
message content and use it in the conditional that computes "content" (the block
referencing firstMessage.content). Ensure you verify
Array.isArray(firstMessage.content) and that each item satisfies the shape
(e.g., has "type" and "text" properties) before mapping, or create a typed
predicate function (e.g., isTextSegment(item)) and use it to filter/map so you
avoid "any" casts and runtime surprises if the SDK shape changes.


try {
const parsed = JSON.parse(content)
chatTitle = parsed.input || content
} catch (e) {
chatTitle = content
}
}
const finalTitle = (chatTitle || 'QCX Analysis Report').substring(0, 50)
setReportTitle(finalTitle)

// Step 1: Render template in portal
setShowTemplate(true)

// Step 2: Wait for DOM and React to synchronize
// Using a longer timeout to ensure large images and complex content are rendered
toast.loading('Rendering report elements...', { id: toastId })
await new Promise(resolve => setTimeout(resolve, 1500))

// Step 3: Capture and Generate
toast.loading('Capturing styled report...', { id: toastId })
await generatePDFReport('report-template', finalTitle)
Comment on lines +70 to +80
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fixed 1500ms timeout remains fragile for ensuring DOM readiness.

While increased from the previous 500ms, using setTimeout to await React rendering and paint completion is inherently unreliable. On slow devices, large conversations, or image-heavy reports, the template may not be fully painted before generatePDFReport captures the DOM, resulting in partial or blank PDFs.

Consider using a double requestAnimationFrame pattern to ensure the browser has committed and painted the DOM:

🛡️ Proposed fix using double rAF
       // Step 1: Render template in portal
       setShowTemplate(true)
 
-      // Step 2: Wait for DOM and React to synchronize
-      // Using a longer timeout to ensure large images and complex content are rendered
-      toast.loading('Rendering report elements...', { id: toastId })
-      await new Promise(resolve => setTimeout(resolve, 1500))
+      // Step 2: Wait for React commit + browser paint
+      toast.loading('Rendering report elements...', { id: toastId })
+      await new Promise<void>(resolve => {
+        requestAnimationFrame(() => {
+          requestAnimationFrame(() => resolve())
+        })
+      })
+
+      // Step 2b: Wait for images in the template to decode
+      const templateEl = document.getElementById('report-template')
+      if (templateEl) {
+        const imgs = Array.from(templateEl.getElementsByTagName('img'))
+        await Promise.all(
+          imgs.map(img => img.decode?.().catch(() => {}))
+        )
+      }
 
       // Step 3: Capture and Generate
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/download-report-button.tsx` around lines 70 - 80, The fixed 1500ms
setTimeout is unreliable; replace the artificial delay before calling
generatePDFReport('report-template', finalTitle) with a DOM paint
synchronization using requestAnimationFrame twice (or a small loop of rAFs)
after setShowTemplate(true), and additionally await any image/resource readiness
inside the template (e.g., wait for all images within the 'report-template'
container to fire their load/error events) before calling toast.loading and
generatePDFReport; keep toast.loading(toastId) calls but move them to just
before the final capture step so the sequence is: setShowTemplate(true) -> await
double rAF (and images loaded) -> toast.loading('Capturing styled report...', {
id: toastId }) -> generatePDFReport('report-template', finalTitle).


toast.success('Report generated successfully', { id: toastId })
} catch (error) {
console.error('Failed to generate report:', error)
toast.error(`Report generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`, { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined)
}
}

if (!isMounted) return null

return (
<>
<Button
variant="ghost"
size="icon"
onClick={handleDownload}
disabled={isGenerating || !aiState || aiState.messages.length === 0}
title="Download PDF Report"
className="relative"
>
{isGenerating ? (
<Loader2 className="h-[1.2rem] w-[1.2rem] animate-spin" />
) : (
<FileText className="h-[1.2rem] w-[1.2rem]" />
)}
<span className="sr-only">Download Report</span>
</Button>

{/* Hidden container for PDF rendering - ensure it's in the DOM with appropriate styles */}
{showTemplate && createPortal(
<div
id="pdf-render-container"
style={{
position: 'fixed',
left: '-10000px',
top: 0,
width: '800px',
zIndex: -9999,
backgroundColor: 'white',
color: 'black',
visibility: 'visible',
pointerEvents: 'none'
}}
>
<ReportTemplate
messages={aiState.messages}
drawnFeatures={mapData?.drawnFeatures}
mapSnapshot={mapSnapshot}
chatTitle={reportTitle}
/>
</div>,
document.body
)}
</>
)
}
163 changes: 163 additions & 0 deletions components/report-template.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import React from 'react'
import { AIMessage } from '@/lib/types'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'

export interface ReportTemplateProps {
messages: AIMessage[]
drawnFeatures?: Array<{
id: string
type: 'Polygon' | 'LineString'
measurement: string
geometry: any
}>
mapSnapshot?: string
chatTitle: string
}

export const ReportTemplate: React.FC<ReportTemplateProps> = ({
messages,
drawnFeatures,
mapSnapshot,
chatTitle
}) => {
const filteredMessages = messages.filter(m =>
m.type === 'input' ||
m.type === 'input_related' ||
m.type === 'response' ||
m.type === 'resolution_search_result'
)

const renderMessageContent = (content: any): string => {
if (typeof content === 'string') {
return content
}
if (Array.isArray(content)) {
return content
.map(part => {
if (typeof part === 'string') return part
if (part && typeof part === 'object' && part.type === 'text') return part.text
return ''
})
.join('\n')
}
return ''
}

return (
<div id="report-template" className="p-8 bg-white text-black font-sans max-w-4xl mx-auto border border-gray-200">
<header className="mb-8 border-b-2 border-[#1a1a1a] pb-4">
<h1 className="text-3xl font-bold mb-2">{chatTitle}</h1>
<p className="text-gray-600">Generated on: {new Date().toLocaleString()}</p>
</header>

{mapSnapshot && (
<section className="mb-10">
<h2 className="text-xl font-semibold mb-4 border-l-4 border-blue-600 pl-2">Live Map View</h2>
<div className="border rounded-lg overflow-hidden shadow-sm bg-gray-100 min-h-[200px] flex items-center justify-center">
<img src={mapSnapshot} alt="Map Snapshot" className="w-full h-auto block" crossOrigin="anonymous" />
</div>
</section>
)}

<section className="mb-10">
<h2 className="text-xl font-semibold mb-6 border-l-4 border-blue-600 pl-2">Conversation History</h2>
<div className="space-y-8">
{filteredMessages.map((message, index) => {
const contentString = renderMessageContent(message.content)

if (message.type === 'input' || message.type === 'input_related') {
let content = ''
try {
const json = JSON.parse(contentString)
content = message.type === 'input' ? (json.input || contentString) : (json.related_query || contentString)
} catch (e) {
content = contentString
}
return (
<div key={index} className="bg-gray-50 p-4 rounded-lg border-l-4 border-blue-400">
<p className="text-sm font-bold text-blue-600 mb-1">User Question</p>
<p className="text-gray-800 italic">{content}</p>
</div>
Comment on lines +69 to +81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

1. Image message report crash 🐞 Bug ≡ Correctness

ReportTemplate assumes message.content is a string for input/input_related messages; when it
is actually an array of content parts (text/image), the catch-path assigns the array to content
and then renders it, which throws at runtime and prevents PDF generation. This is triggered by
resolution search and any chat input that includes an image attachment, since those messages are
stored with array content.
Agent Prompt
### Issue description
`ReportTemplate` assumes `AIMessage.content` is always a string for user input messages. In this app, user messages (including resolution search) can store `content` as an array of `{type:'text'|'image', ...}` parts. The current code falls back to rendering that array directly, which causes React to throw `Objects are not valid as a React child`, breaking PDF generation.

### Issue Context
- `app/actions.tsx` stores user messages with `content: CoreMessage['content']`, which can be a string **or** an array of parts (especially for image uploads and resolution search).
- `ReportTemplate` should render a safe textual representation (and optionally images) for array content.

### Fix Focus Areas
- components/report-template.tsx[50-73]
- app/actions.tsx[355-418]

### Implementation notes
- Add a helper like `renderMessageContent(content: CoreMessage['content'])`:
  - If `typeof content === 'string'`: return the string.
  - If `Array.isArray(content)`: 
    - Extract and join text parts (`part.type==='text'`).
    - Optionally render image parts (`part.type==='image'`) as `<img src={part.image} ... />` when `part.image` is a data URL.
    - Never pass raw objects/arrays directly into JSX.
- Apply this for `input`/`input_related` messages (and consider other message types too, for consistency).

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

)
} else if (message.type === 'response') {
return (
<div key={index} className="prose prose-sm max-w-none border-b border-gray-100 pb-4">
<p className="text-sm font-bold text-green-600 mb-1">AI Response</p>
<div className="text-gray-800">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{contentString}
</ReactMarkdown>
</div>
</div>
)
} else if (message.type === 'resolution_search_result') {
try {
const result = JSON.parse(contentString)
return (
<div key={index} className="space-y-4 bg-purple-50 p-4 rounded-lg">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
{result.summary && (
<div className="text-gray-800 mb-4">
{result.summary}
</div>
)}
<div className="grid grid-cols-2 gap-4">
{result.mapboxImage && (
<div className="space-y-1">
<p className="text-[10px] text-gray-500 font-semibold uppercase">Mapbox View</p>
<img src={result.mapboxImage} alt="Mapbox View" className="rounded border border-purple-200 w-full block" crossOrigin="anonymous" />
</div>
)}
{result.googleImage && (
<div className="space-y-1">
<p className="text-[10px] text-gray-500 font-semibold uppercase">Google Satellite</p>
<img src={result.googleImage} alt="Google Satellite" className="rounded border border-purple-200 w-full block" crossOrigin="anonymous" />
</div>
)}
</div>
</div>
)
} catch (e) {
return null
}
}
return null
})}
</div>
</section>

{drawnFeatures && drawnFeatures.length > 0 && (
<section className="mt-10 border-t-2 border-gray-100 pt-6">
<h2 className="text-xl font-semibold mb-4 border-l-4 border-orange-500 pl-2">Appendix: Drawn Features & Measurements</h2>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th className="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Measurement</th>
<th className="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Geometry</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{drawnFeatures.map((feature) => (
<tr key={feature.id}>
<td className="px-4 py-2 whitespace-nowrap text-gray-900">{feature.type}</td>
<td className="px-4 py-2 whitespace-nowrap text-gray-900">{feature.measurement}</td>
<td className="px-4 py-2 text-gray-500 break-all font-mono text-[10px]">
{JSON.stringify(feature.geometry.coordinates).substring(0, 100)}...
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}

<footer className="mt-12 text-center text-gray-400 text-xs border-t pt-4">
<p>© {new Date().getFullYear()} QCX - Planet Computer Analysis Report</p>
</footer>
</div>
)
}
12 changes: 8 additions & 4 deletions components/settings/settings-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SettingsSkeleton } from "@/components/settings/components/settings-skel
import { useProfileToggle, ProfileToggleEnum } from "@/components/profile-toggle-context"
import { Button } from "@/components/ui/button"
import { Minus } from "lucide-react"
import { DownloadReportButton } from "@/components/download-report-button"

export default function SettingsView() {
const { toggleProfileSection, activeView } = useProfileToggle();
Expand All @@ -22,10 +23,13 @@ export default function SettingsView() {
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground">Manage your planetary copilot preferences and user access</p>
</div>
<Button variant="ghost" size="icon" onClick={handleClose}>
<Minus className="h-6 w-6" />
<span className="sr-only">Close settings</span>
</Button>
<div className="flex items-center gap-2">
<DownloadReportButton />
<Button variant="ghost" size="icon" onClick={handleClose}>
<Minus className="h-6 w-6" />
<span className="sr-only">Close settings</span>
</Button>
</div>
</div>
<Suspense fallback={<SettingsSkeleton />}>
<Settings initialTab={initialTab} />
Expand Down
Loading