Skip to content
Closed
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
88 changes: 64 additions & 24 deletions components/download-report-button.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client'

import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { FileText, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
Expand All@@ -22,11 +22,39 @@ export const DownloadReportButton = () => {
const [reportTitle, setReportTitle] = useState('QCX Analysis Report')
const [isMounted, setIsMounted] = useState(false)

// Holds the callback that runs the PDF generation once the portal is in the DOM.
const pendingGenerateRef = useRef<(() => void) | null>(null)

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

const handleDownload = async () => {
// When showTemplate flips to true, React has committed the portal to the DOM
// but the browser hasn't necessarily painted yet. A double requestAnimationFrame
// ensures we wait for the next paint before calling html2canvas so that the
// element is actually in the DOM and fully laid out.
useEffect(() => {
if (!showTemplate) return
const callback = pendingGenerateRef.current
if (!callback) return

let raf1: number
let raf2: number

raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
callback()
pendingGenerateRef.current = null
})
})

return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
}
}, [showTemplate])

const handleDownload = useCallback(async () => {
if (!aiState || aiState.messages.length === 0) {
toast.error('No conversation to export')
return
Expand All@@ -38,48 +66,60 @@ export const DownloadReportButton = () => {
try {
let snapshot: string | undefined
if (map) {
// Use a smaller snapshot if possible, or just the current canvas
snapshot = map.getCanvas().toDataURL('image/jpeg', 0.5)
setMapSnapshot(snapshot)
}

// Extract title
// Derive a title from the first message's text content.
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(' ')
const rawContent =
typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[])
.map((p: any) => (p.type === 'text' ? p.text : ''))
.join(' ')
: ''

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

setShowTemplate(true)

// Short delay for React portal to mount
await new Promise(resolve => setTimeout(resolve, 800))

await generatePDFReport('report-template', finalTitle)
// Store the PDF generation work in a ref so the useEffect can run it
// after React has committed the portal and the browser has painted.
pendingGenerateRef.current = async () => {
try {
await generatePDFReport('report-template', finalTitle)
toast.success('Report generated successfully', { id: toastId })
} catch (error) {
console.error('Failed to generate report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined)
}
}

toast.success('Report generated successfully', { id: toastId })
// Trigger the portal render; the useEffect above will fire once React
// commits this state change and two animation frames have elapsed.
setShowTemplate(true)
} catch (error) {
console.error('Failed to generate report:', error)
console.error('Failed to prepare report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined) // Clear snapshot memory
setMapSnapshot(undefined)
}
}
}, [aiState, map])

if (!isMounted) return null

Expand All@@ -104,7 +144,7 @@ export const DownloadReportButton = () => {
{showTemplate && createPortal(
<div
style={{
position: 'absolute',
position: 'fixed',
left: '-9999px',
top: 0,
width: '800px',
Expand Down
51 changes: 43 additions & 8 deletions components/report-template.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,27 @@ export interface ReportTemplateProps {
chatTitle: string
}

/**
* Normalises a message's content field to a plain string.
* The AI SDK can return content as a string or as an array of content-part
* objects like { type: 'text', text: '...' } | { type: 'image', ... }.
* Passing an object directly to React causes the "Objects are not valid as a
* React child" error, so we always extract the text before rendering.
*/
function getContentString(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((part: any) => {
if (typeof part === 'string') return part
if (part && typeof part === 'object' && part.type === 'text') return part.text ?? ''
return ''
})
.join(' ')
}
return ''
}

export const ReportTemplate: React.FC<ReportTemplateProps> = ({
messages,
drawnFeatures,
Expand DownExpand Up@@ -49,12 +70,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
<div className="space-y-8">
{filteredMessages.map((message, index) => {
if (message.type === 'input' || message.type === 'input_related') {
let content = ''
const rawContent = getContentString(message.content)
let content = rawContent
try {
const json = JSON.parse(message.content as string)
content = message.type === 'input' ? json.input : json.related_query
const json = JSON.parse(rawContent)
content = message.type === 'input'
? (json.input ?? rawContent)
: (json.related_query ?? rawContent)
} catch (e) {
content = message.content as string
// rawContent is already a plain string
}
return (
<div key={index} className="bg-gray-50 p-4 rounded-lg border-l-4 border-blue-500">
Expand All@@ -63,17 +87,19 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} else if (message.type === 'response') {
const rawContent = getContentString(message.content)
return (
<div key={index} className="prose prose-sm max-w-none">
<p className="text-sm font-bold text-green-600 mb-1">AI Response</p>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content as string}
{rawContent}
</ReactMarkdown>
</div>
)
} else if (message.type === 'resolution_search_result') {
const rawContent = getContentString(message.content)
try {
const result = JSON.parse(message.content as string)
const result = JSON.parse(rawContent)
return (
<div key={index} className="space-y-4">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
Expand All@@ -99,6 +125,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} catch (e) {
// If content is not JSON, show it as plain text
if (rawContent) {
return (
<div key={index} className="bg-purple-50 p-4 rounded-lg text-gray-800">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
<p>{rawContent}</p>
</div>
)
}
return null
}
}
Expand All@@ -120,11 +155,11 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{drawnFeatures.map((feature, i) => (
{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>
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
📝 CodeRabbit Chat: Implement requested code changes by coderabbitai[bot] · Pull Request #649 · QueueLab/QCX · GitHub
Skip to content
Closed
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
88 changes: 64 additions & 24 deletions components/download-report-button.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client'

import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { FileText, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
Expand All@@ -22,11 +22,39 @@ export const DownloadReportButton = () => {
const [reportTitle, setReportTitle] = useState('QCX Analysis Report')
const [isMounted, setIsMounted] = useState(false)

// Holds the callback that runs the PDF generation once the portal is in the DOM.
const pendingGenerateRef = useRef<(() => void) | null>(null)

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

const handleDownload = async () => {
// When showTemplate flips to true, React has committed the portal to the DOM
// but the browser hasn't necessarily painted yet. A double requestAnimationFrame
// ensures we wait for the next paint before calling html2canvas so that the
// element is actually in the DOM and fully laid out.
useEffect(() => {
if (!showTemplate) return
const callback = pendingGenerateRef.current
if (!callback) return

let raf1: number
let raf2: number

raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
callback()
pendingGenerateRef.current = null
})
})

return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
}
}, [showTemplate])

const handleDownload = useCallback(async () => {
if (!aiState || aiState.messages.length === 0) {
toast.error('No conversation to export')
return
Expand All@@ -38,48 +66,60 @@ export const DownloadReportButton = () => {
try {
let snapshot: string | undefined
if (map) {
// Use a smaller snapshot if possible, or just the current canvas
snapshot = map.getCanvas().toDataURL('image/jpeg', 0.5)
setMapSnapshot(snapshot)
}

// Extract title
// Derive a title from the first message's text content.
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(' ')
const rawContent =
typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[])
.map((p: any) => (p.type === 'text' ? p.text : ''))
.join(' ')
: ''

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

setShowTemplate(true)

// Short delay for React portal to mount
await new Promise(resolve => setTimeout(resolve, 800))

await generatePDFReport('report-template', finalTitle)
// Store the PDF generation work in a ref so the useEffect can run it
// after React has committed the portal and the browser has painted.
pendingGenerateRef.current = async () => {
try {
await generatePDFReport('report-template', finalTitle)
toast.success('Report generated successfully', { id: toastId })
} catch (error) {
console.error('Failed to generate report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined)
}
}

toast.success('Report generated successfully', { id: toastId })
// Trigger the portal render; the useEffect above will fire once React
// commits this state change and two animation frames have elapsed.
setShowTemplate(true)
} catch (error) {
console.error('Failed to generate report:', error)
console.error('Failed to prepare report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined) // Clear snapshot memory
setMapSnapshot(undefined)
}
}
}, [aiState, map])

if (!isMounted) return null

Expand All@@ -104,7 +144,7 @@ export const DownloadReportButton = () => {
{showTemplate && createPortal(
<div
style={{
position: 'absolute',
position: 'fixed',
left: '-9999px',
top: 0,
width: '800px',
Expand Down
51 changes: 43 additions & 8 deletions components/report-template.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,27 @@ export interface ReportTemplateProps {
chatTitle: string
}

/**
* Normalises a message's content field to a plain string.
* The AI SDK can return content as a string or as an array of content-part
* objects like { type: 'text', text: '...' } | { type: 'image', ... }.
* Passing an object directly to React causes the "Objects are not valid as a
* React child" error, so we always extract the text before rendering.
*/
function getContentString(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((part: any) => {
if (typeof part === 'string') return part
if (part && typeof part === 'object' && part.type === 'text') return part.text ?? ''
return ''
})
.join(' ')
}
return ''
}

export const ReportTemplate: React.FC<ReportTemplateProps> = ({
messages,
drawnFeatures,
Expand DownExpand Up@@ -49,12 +70,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
<div className="space-y-8">
{filteredMessages.map((message, index) => {
if (message.type === 'input' || message.type === 'input_related') {
let content = ''
const rawContent = getContentString(message.content)
let content = rawContent
try {
const json = JSON.parse(message.content as string)
content = message.type === 'input' ? json.input : json.related_query
const json = JSON.parse(rawContent)
content = message.type === 'input'
? (json.input ?? rawContent)
: (json.related_query ?? rawContent)
} catch (e) {
content = message.content as string
// rawContent is already a plain string
}
return (
<div key={index} className="bg-gray-50 p-4 rounded-lg border-l-4 border-blue-500">
Expand All@@ -63,17 +87,19 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} else if (message.type === 'response') {
const rawContent = getContentString(message.content)
return (
<div key={index} className="prose prose-sm max-w-none">
<p className="text-sm font-bold text-green-600 mb-1">AI Response</p>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content as string}
{rawContent}
</ReactMarkdown>
</div>
)
} else if (message.type === 'resolution_search_result') {
const rawContent = getContentString(message.content)
try {
const result = JSON.parse(message.content as string)
const result = JSON.parse(rawContent)
return (
<div key={index} className="space-y-4">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
Expand All@@ -99,6 +125,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} catch (e) {
// If content is not JSON, show it as plain text
if (rawContent) {
return (
<div key={index} className="bg-purple-50 p-4 rounded-lg text-gray-800">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
<p>{rawContent}</p>
</div>
)
}
return null
}
}
Expand All@@ -120,11 +155,11 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{drawnFeatures.map((feature, i) => (
{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>
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' 📝 CodeRabbit Chat: Implement requested code changes by coderabbitai[bot] · Pull Request #649 · QueueLab/QCX · GitHub
Skip to content
Closed
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
88 changes: 64 additions & 24 deletions components/download-report-button.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client'

import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { FileText, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
Expand All@@ -22,11 +22,39 @@ export const DownloadReportButton = () => {
const [reportTitle, setReportTitle] = useState('QCX Analysis Report')
const [isMounted, setIsMounted] = useState(false)

// Holds the callback that runs the PDF generation once the portal is in the DOM.
const pendingGenerateRef = useRef<(() => void) | null>(null)

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

const handleDownload = async () => {
// When showTemplate flips to true, React has committed the portal to the DOM
// but the browser hasn't necessarily painted yet. A double requestAnimationFrame
// ensures we wait for the next paint before calling html2canvas so that the
// element is actually in the DOM and fully laid out.
useEffect(() => {
if (!showTemplate) return
const callback = pendingGenerateRef.current
if (!callback) return

let raf1: number
let raf2: number

raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
callback()
pendingGenerateRef.current = null
})
})

return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
}
}, [showTemplate])

const handleDownload = useCallback(async () => {
if (!aiState || aiState.messages.length === 0) {
toast.error('No conversation to export')
return
Expand All@@ -38,48 +66,60 @@ export const DownloadReportButton = () => {
try {
let snapshot: string | undefined
if (map) {
// Use a smaller snapshot if possible, or just the current canvas
snapshot = map.getCanvas().toDataURL('image/jpeg', 0.5)
setMapSnapshot(snapshot)
}

// Extract title
// Derive a title from the first message's text content.
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(' ')
const rawContent =
typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[])
.map((p: any) => (p.type === 'text' ? p.text : ''))
.join(' ')
: ''

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

setShowTemplate(true)

// Short delay for React portal to mount
await new Promise(resolve => setTimeout(resolve, 800))

await generatePDFReport('report-template', finalTitle)
// Store the PDF generation work in a ref so the useEffect can run it
// after React has committed the portal and the browser has painted.
pendingGenerateRef.current = async () => {
try {
await generatePDFReport('report-template', finalTitle)
toast.success('Report generated successfully', { id: toastId })
} catch (error) {
console.error('Failed to generate report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined)
}
}

toast.success('Report generated successfully', { id: toastId })
// Trigger the portal render; the useEffect above will fire once React
// commits this state change and two animation frames have elapsed.
setShowTemplate(true)
} catch (error) {
console.error('Failed to generate report:', error)
console.error('Failed to prepare report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined) // Clear snapshot memory
setMapSnapshot(undefined)
}
}
}, [aiState, map])

if (!isMounted) return null

Expand All@@ -104,7 +144,7 @@ export const DownloadReportButton = () => {
{showTemplate && createPortal(
<div
style={{
position: 'absolute',
position: 'fixed',
left: '-9999px',
top: 0,
width: '800px',
Expand Down
51 changes: 43 additions & 8 deletions components/report-template.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,27 @@ export interface ReportTemplateProps {
chatTitle: string
}

/**
* Normalises a message's content field to a plain string.
* The AI SDK can return content as a string or as an array of content-part
* objects like { type: 'text', text: '...' } | { type: 'image', ... }.
* Passing an object directly to React causes the "Objects are not valid as a
* React child" error, so we always extract the text before rendering.
*/
function getContentString(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((part: any) => {
if (typeof part === 'string') return part
if (part && typeof part === 'object' && part.type === 'text') return part.text ?? ''
return ''
})
.join(' ')
}
return ''
}

export const ReportTemplate: React.FC<ReportTemplateProps> = ({
messages,
drawnFeatures,
Expand DownExpand Up@@ -49,12 +70,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
<div className="space-y-8">
{filteredMessages.map((message, index) => {
if (message.type === 'input' || message.type === 'input_related') {
let content = ''
const rawContent = getContentString(message.content)
let content = rawContent
try {
const json = JSON.parse(message.content as string)
content = message.type === 'input' ? json.input : json.related_query
const json = JSON.parse(rawContent)
content = message.type === 'input'
? (json.input ?? rawContent)
: (json.related_query ?? rawContent)
} catch (e) {
content = message.content as string
// rawContent is already a plain string
}
return (
<div key={index} className="bg-gray-50 p-4 rounded-lg border-l-4 border-blue-500">
Expand All@@ -63,17 +87,19 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} else if (message.type === 'response') {
const rawContent = getContentString(message.content)
return (
<div key={index} className="prose prose-sm max-w-none">
<p className="text-sm font-bold text-green-600 mb-1">AI Response</p>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content as string}
{rawContent}
</ReactMarkdown>
</div>
)
} else if (message.type === 'resolution_search_result') {
const rawContent = getContentString(message.content)
try {
const result = JSON.parse(message.content as string)
const result = JSON.parse(rawContent)
return (
<div key={index} className="space-y-4">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
Expand All@@ -99,6 +125,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} catch (e) {
// If content is not JSON, show it as plain text
if (rawContent) {
return (
<div key={index} className="bg-purple-50 p-4 rounded-lg text-gray-800">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
<p>{rawContent}</p>
</div>
)
}
return null
}
}
Expand All@@ -120,11 +155,11 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{drawnFeatures.map((feature, i) => (
{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>
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' 📝 CodeRabbit Chat: Implement requested code changes by coderabbitai[bot] · Pull Request #649 · QueueLab/QCX · GitHub
Skip to content
Closed
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
88 changes: 64 additions & 24 deletions components/download-report-button.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client'

import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { FileText, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
Expand All@@ -22,11 +22,39 @@ export const DownloadReportButton = () => {
const [reportTitle, setReportTitle] = useState('QCX Analysis Report')
const [isMounted, setIsMounted] = useState(false)

// Holds the callback that runs the PDF generation once the portal is in the DOM.
const pendingGenerateRef = useRef<(() => void) | null>(null)

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

const handleDownload = async () => {
// When showTemplate flips to true, React has committed the portal to the DOM
// but the browser hasn't necessarily painted yet. A double requestAnimationFrame
// ensures we wait for the next paint before calling html2canvas so that the
// element is actually in the DOM and fully laid out.
useEffect(() => {
if (!showTemplate) return
const callback = pendingGenerateRef.current
if (!callback) return

let raf1: number
let raf2: number

raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
callback()
pendingGenerateRef.current = null
})
})

return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
}
}, [showTemplate])

const handleDownload = useCallback(async () => {
if (!aiState || aiState.messages.length === 0) {
toast.error('No conversation to export')
return
Expand All@@ -38,48 +66,60 @@ export const DownloadReportButton = () => {
try {
let snapshot: string | undefined
if (map) {
// Use a smaller snapshot if possible, or just the current canvas
snapshot = map.getCanvas().toDataURL('image/jpeg', 0.5)
setMapSnapshot(snapshot)
}

// Extract title
// Derive a title from the first message's text content.
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(' ')
const rawContent =
typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[])
.map((p: any) => (p.type === 'text' ? p.text : ''))
.join(' ')
: ''

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

setShowTemplate(true)

// Short delay for React portal to mount
await new Promise(resolve => setTimeout(resolve, 800))

await generatePDFReport('report-template', finalTitle)
// Store the PDF generation work in a ref so the useEffect can run it
// after React has committed the portal and the browser has painted.
pendingGenerateRef.current = async () => {
try {
await generatePDFReport('report-template', finalTitle)
toast.success('Report generated successfully', { id: toastId })
} catch (error) {
console.error('Failed to generate report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined)
}
}

toast.success('Report generated successfully', { id: toastId })
// Trigger the portal render; the useEffect above will fire once React
// commits this state change and two animation frames have elapsed.
setShowTemplate(true)
} catch (error) {
console.error('Failed to generate report:', error)
console.error('Failed to prepare report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined) // Clear snapshot memory
setMapSnapshot(undefined)
}
}
}, [aiState, map])

if (!isMounted) return null

Expand All@@ -104,7 +144,7 @@ export const DownloadReportButton = () => {
{showTemplate && createPortal(
<div
style={{
position: 'absolute',
position: 'fixed',
left: '-9999px',
top: 0,
width: '800px',
Expand Down
51 changes: 43 additions & 8 deletions components/report-template.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,27 @@ export interface ReportTemplateProps {
chatTitle: string
}

/**
* Normalises a message's content field to a plain string.
* The AI SDK can return content as a string or as an array of content-part
* objects like { type: 'text', text: '...' } | { type: 'image', ... }.
* Passing an object directly to React causes the "Objects are not valid as a
* React child" error, so we always extract the text before rendering.
*/
function getContentString(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((part: any) => {
if (typeof part === 'string') return part
if (part && typeof part === 'object' && part.type === 'text') return part.text ?? ''
return ''
})
.join(' ')
}
return ''
}

export const ReportTemplate: React.FC<ReportTemplateProps> = ({
messages,
drawnFeatures,
Expand DownExpand Up@@ -49,12 +70,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
<div className="space-y-8">
{filteredMessages.map((message, index) => {
if (message.type === 'input' || message.type === 'input_related') {
let content = ''
const rawContent = getContentString(message.content)
let content = rawContent
try {
const json = JSON.parse(message.content as string)
content = message.type === 'input' ? json.input : json.related_query
const json = JSON.parse(rawContent)
content = message.type === 'input'
? (json.input ?? rawContent)
: (json.related_query ?? rawContent)
} catch (e) {
content = message.content as string
// rawContent is already a plain string
}
return (
<div key={index} className="bg-gray-50 p-4 rounded-lg border-l-4 border-blue-500">
Expand All@@ -63,17 +87,19 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} else if (message.type === 'response') {
const rawContent = getContentString(message.content)
return (
<div key={index} className="prose prose-sm max-w-none">
<p className="text-sm font-bold text-green-600 mb-1">AI Response</p>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content as string}
{rawContent}
</ReactMarkdown>
</div>
)
} else if (message.type === 'resolution_search_result') {
const rawContent = getContentString(message.content)
try {
const result = JSON.parse(message.content as string)
const result = JSON.parse(rawContent)
return (
<div key={index} className="space-y-4">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
Expand All@@ -99,6 +125,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} catch (e) {
// If content is not JSON, show it as plain text
if (rawContent) {
return (
<div key={index} className="bg-purple-50 p-4 rounded-lg text-gray-800">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
<p>{rawContent}</p>
</div>
)
}
return null
}
}
Expand All@@ -120,11 +155,11 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{drawnFeatures.map((feature, i) => (
{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>
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' 📝 CodeRabbit Chat: Implement requested code changes by coderabbitai[bot] · Pull Request #649 · QueueLab/QCX · GitHub
Skip to content
Closed
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
88 changes: 64 additions & 24 deletions components/download-report-button.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client'

import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { FileText, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
Expand All@@ -22,11 +22,39 @@ export const DownloadReportButton = () => {
const [reportTitle, setReportTitle] = useState('QCX Analysis Report')
const [isMounted, setIsMounted] = useState(false)

// Holds the callback that runs the PDF generation once the portal is in the DOM.
const pendingGenerateRef = useRef<(() => void) | null>(null)

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

const handleDownload = async () => {
// When showTemplate flips to true, React has committed the portal to the DOM
// but the browser hasn't necessarily painted yet. A double requestAnimationFrame
// ensures we wait for the next paint before calling html2canvas so that the
// element is actually in the DOM and fully laid out.
useEffect(() => {
if (!showTemplate) return
const callback = pendingGenerateRef.current
if (!callback) return

let raf1: number
let raf2: number

raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
callback()
pendingGenerateRef.current = null
})
})

return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
}
}, [showTemplate])

const handleDownload = useCallback(async () => {
if (!aiState || aiState.messages.length === 0) {
toast.error('No conversation to export')
return
Expand All@@ -38,48 +66,60 @@ export const DownloadReportButton = () => {
try {
let snapshot: string | undefined
if (map) {
// Use a smaller snapshot if possible, or just the current canvas
snapshot = map.getCanvas().toDataURL('image/jpeg', 0.5)
setMapSnapshot(snapshot)
}

// Extract title
// Derive a title from the first message's text content.
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(' ')
const rawContent =
typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[])
.map((p: any) => (p.type === 'text' ? p.text : ''))
.join(' ')
: ''

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

setShowTemplate(true)

// Short delay for React portal to mount
await new Promise(resolve => setTimeout(resolve, 800))

await generatePDFReport('report-template', finalTitle)
// Store the PDF generation work in a ref so the useEffect can run it
// after React has committed the portal and the browser has painted.
pendingGenerateRef.current = async () => {
try {
await generatePDFReport('report-template', finalTitle)
toast.success('Report generated successfully', { id: toastId })
} catch (error) {
console.error('Failed to generate report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined)
}
}

toast.success('Report generated successfully', { id: toastId })
// Trigger the portal render; the useEffect above will fire once React
// commits this state change and two animation frames have elapsed.
setShowTemplate(true)
} catch (error) {
console.error('Failed to generate report:', error)
console.error('Failed to prepare report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined) // Clear snapshot memory
setMapSnapshot(undefined)
}
}
}, [aiState, map])

if (!isMounted) return null

Expand All@@ -104,7 +144,7 @@ export const DownloadReportButton = () => {
{showTemplate && createPortal(
<div
style={{
position: 'absolute',
position: 'fixed',
left: '-9999px',
top: 0,
width: '800px',
Expand Down
51 changes: 43 additions & 8 deletions components/report-template.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,27 @@ export interface ReportTemplateProps {
chatTitle: string
}

/**
* Normalises a message's content field to a plain string.
* The AI SDK can return content as a string or as an array of content-part
* objects like { type: 'text', text: '...' } | { type: 'image', ... }.
* Passing an object directly to React causes the "Objects are not valid as a
* React child" error, so we always extract the text before rendering.
*/
function getContentString(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((part: any) => {
if (typeof part === 'string') return part
if (part && typeof part === 'object' && part.type === 'text') return part.text ?? ''
return ''
})
.join(' ')
}
return ''
}

export const ReportTemplate: React.FC<ReportTemplateProps> = ({
messages,
drawnFeatures,
Expand DownExpand Up@@ -49,12 +70,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
<div className="space-y-8">
{filteredMessages.map((message, index) => {
if (message.type === 'input' || message.type === 'input_related') {
let content = ''
const rawContent = getContentString(message.content)
let content = rawContent
try {
const json = JSON.parse(message.content as string)
content = message.type === 'input' ? json.input : json.related_query
const json = JSON.parse(rawContent)
content = message.type === 'input'
? (json.input ?? rawContent)
: (json.related_query ?? rawContent)
} catch (e) {
content = message.content as string
// rawContent is already a plain string
}
return (
<div key={index} className="bg-gray-50 p-4 rounded-lg border-l-4 border-blue-500">
Expand All@@ -63,17 +87,19 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} else if (message.type === 'response') {
const rawContent = getContentString(message.content)
return (
<div key={index} className="prose prose-sm max-w-none">
<p className="text-sm font-bold text-green-600 mb-1">AI Response</p>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content as string}
{rawContent}
</ReactMarkdown>
</div>
)
} else if (message.type === 'resolution_search_result') {
const rawContent = getContentString(message.content)
try {
const result = JSON.parse(message.content as string)
const result = JSON.parse(rawContent)
return (
<div key={index} className="space-y-4">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
Expand All@@ -99,6 +125,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} catch (e) {
// If content is not JSON, show it as plain text
if (rawContent) {
return (
<div key={index} className="bg-purple-50 p-4 rounded-lg text-gray-800">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
<p>{rawContent}</p>
</div>
)
}
return null
}
}
Expand All@@ -120,11 +155,11 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{drawnFeatures.map((feature, i) => (
{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>
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' 📝 CodeRabbit Chat: Implement requested code changes by coderabbitai[bot] · Pull Request #649 · QueueLab/QCX · GitHub
Skip to content
Closed
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
88 changes: 64 additions & 24 deletions components/download-report-button.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client'

import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { FileText, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
Expand All@@ -22,11 +22,39 @@ export const DownloadReportButton = () => {
const [reportTitle, setReportTitle] = useState('QCX Analysis Report')
const [isMounted, setIsMounted] = useState(false)

// Holds the callback that runs the PDF generation once the portal is in the DOM.
const pendingGenerateRef = useRef<(() => void) | null>(null)

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

const handleDownload = async () => {
// When showTemplate flips to true, React has committed the portal to the DOM
// but the browser hasn't necessarily painted yet. A double requestAnimationFrame
// ensures we wait for the next paint before calling html2canvas so that the
// element is actually in the DOM and fully laid out.
useEffect(() => {
if (!showTemplate) return
const callback = pendingGenerateRef.current
if (!callback) return

let raf1: number
let raf2: number

raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
callback()
pendingGenerateRef.current = null
})
})

return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
}
}, [showTemplate])

const handleDownload = useCallback(async () => {
if (!aiState || aiState.messages.length === 0) {
toast.error('No conversation to export')
return
Expand All@@ -38,48 +66,60 @@ export const DownloadReportButton = () => {
try {
let snapshot: string | undefined
if (map) {
// Use a smaller snapshot if possible, or just the current canvas
snapshot = map.getCanvas().toDataURL('image/jpeg', 0.5)
setMapSnapshot(snapshot)
}

// Extract title
// Derive a title from the first message's text content.
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(' ')
const rawContent =
typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[])
.map((p: any) => (p.type === 'text' ? p.text : ''))
.join(' ')
: ''

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

setShowTemplate(true)

// Short delay for React portal to mount
await new Promise(resolve => setTimeout(resolve, 800))

await generatePDFReport('report-template', finalTitle)
// Store the PDF generation work in a ref so the useEffect can run it
// after React has committed the portal and the browser has painted.
pendingGenerateRef.current = async () => {
try {
await generatePDFReport('report-template', finalTitle)
toast.success('Report generated successfully', { id: toastId })
} catch (error) {
console.error('Failed to generate report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined)
}
}

toast.success('Report generated successfully', { id: toastId })
// Trigger the portal render; the useEffect above will fire once React
// commits this state change and two animation frames have elapsed.
setShowTemplate(true)
} catch (error) {
console.error('Failed to generate report:', error)
console.error('Failed to prepare report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined) // Clear snapshot memory
setMapSnapshot(undefined)
}
}
}, [aiState, map])

if (!isMounted) return null

Expand All@@ -104,7 +144,7 @@ export const DownloadReportButton = () => {
{showTemplate && createPortal(
<div
style={{
position: 'absolute',
position: 'fixed',
left: '-9999px',
top: 0,
width: '800px',
Expand Down
51 changes: 43 additions & 8 deletions components/report-template.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,27 @@ export interface ReportTemplateProps {
chatTitle: string
}

/**
* Normalises a message's content field to a plain string.
* The AI SDK can return content as a string or as an array of content-part
* objects like { type: 'text', text: '...' } | { type: 'image', ... }.
* Passing an object directly to React causes the "Objects are not valid as a
* React child" error, so we always extract the text before rendering.
*/
function getContentString(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((part: any) => {
if (typeof part === 'string') return part
if (part && typeof part === 'object' && part.type === 'text') return part.text ?? ''
return ''
})
.join(' ')
}
return ''
}

export const ReportTemplate: React.FC<ReportTemplateProps> = ({
messages,
drawnFeatures,
Expand DownExpand Up@@ -49,12 +70,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
<div className="space-y-8">
{filteredMessages.map((message, index) => {
if (message.type === 'input' || message.type === 'input_related') {
let content = ''
const rawContent = getContentString(message.content)
let content = rawContent
try {
const json = JSON.parse(message.content as string)
content = message.type === 'input' ? json.input : json.related_query
const json = JSON.parse(rawContent)
content = message.type === 'input'
? (json.input ?? rawContent)
: (json.related_query ?? rawContent)
} catch (e) {
content = message.content as string
// rawContent is already a plain string
}
return (
<div key={index} className="bg-gray-50 p-4 rounded-lg border-l-4 border-blue-500">
Expand All@@ -63,17 +87,19 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} else if (message.type === 'response') {
const rawContent = getContentString(message.content)
return (
<div key={index} className="prose prose-sm max-w-none">
<p className="text-sm font-bold text-green-600 mb-1">AI Response</p>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content as string}
{rawContent}
</ReactMarkdown>
</div>
)
} else if (message.type === 'resolution_search_result') {
const rawContent = getContentString(message.content)
try {
const result = JSON.parse(message.content as string)
const result = JSON.parse(rawContent)
return (
<div key={index} className="space-y-4">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
Expand All@@ -99,6 +125,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} catch (e) {
// If content is not JSON, show it as plain text
if (rawContent) {
return (
<div key={index} className="bg-purple-50 p-4 rounded-lg text-gray-800">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
<p>{rawContent}</p>
</div>
)
}
return null
}
}
Expand All@@ -120,11 +155,11 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{drawnFeatures.map((feature, i) => (
{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>
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); 📝 CodeRabbit Chat: Implement requested code changes by coderabbitai[bot] · Pull Request #649 · QueueLab/QCX · GitHub
Skip to content
Closed
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
88 changes: 64 additions & 24 deletions components/download-report-button.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
'use client'

import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { FileText, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
Expand All@@ -22,11 +22,39 @@ export const DownloadReportButton = () => {
const [reportTitle, setReportTitle] = useState('QCX Analysis Report')
const [isMounted, setIsMounted] = useState(false)

// Holds the callback that runs the PDF generation once the portal is in the DOM.
const pendingGenerateRef = useRef<(() => void) | null>(null)

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

const handleDownload = async () => {
// When showTemplate flips to true, React has committed the portal to the DOM
// but the browser hasn't necessarily painted yet. A double requestAnimationFrame
// ensures we wait for the next paint before calling html2canvas so that the
// element is actually in the DOM and fully laid out.
useEffect(() => {
if (!showTemplate) return
const callback = pendingGenerateRef.current
if (!callback) return

let raf1: number
let raf2: number

raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
callback()
pendingGenerateRef.current = null
})
})

return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
}
}, [showTemplate])

const handleDownload = useCallback(async () => {
if (!aiState || aiState.messages.length === 0) {
toast.error('No conversation to export')
return
Expand All@@ -38,48 +66,60 @@ export const DownloadReportButton = () => {
try {
let snapshot: string | undefined
if (map) {
// Use a smaller snapshot if possible, or just the current canvas
snapshot = map.getCanvas().toDataURL('image/jpeg', 0.5)
setMapSnapshot(snapshot)
}

// Extract title
// Derive a title from the first message's text content.
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(' ')
const rawContent =
typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[])
.map((p: any) => (p.type === 'text' ? p.text : ''))
.join(' ')
: ''

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

setShowTemplate(true)

// Short delay for React portal to mount
await new Promise(resolve => setTimeout(resolve, 800))

await generatePDFReport('report-template', finalTitle)
// Store the PDF generation work in a ref so the useEffect can run it
// after React has committed the portal and the browser has painted.
pendingGenerateRef.current = async () => {
try {
await generatePDFReport('report-template', finalTitle)
toast.success('Report generated successfully', { id: toastId })
} catch (error) {
console.error('Failed to generate report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined)
}
}

toast.success('Report generated successfully', { id: toastId })
// Trigger the portal render; the useEffect above will fire once React
// commits this state change and two animation frames have elapsed.
setShowTemplate(true)
} catch (error) {
console.error('Failed to generate report:', error)
console.error('Failed to prepare report:', error)
toast.error('Failed to generate report', { id: toastId })
} finally {
setIsGenerating(false)
setShowTemplate(false)
setMapSnapshot(undefined) // Clear snapshot memory
setMapSnapshot(undefined)
}
}
}, [aiState, map])

if (!isMounted) return null

Expand All@@ -104,7 +144,7 @@ export const DownloadReportButton = () => {
{showTemplate && createPortal(
<div
style={{
position: 'absolute',
position: 'fixed',
left: '-9999px',
top: 0,
width: '800px',
Expand Down
51 changes: 43 additions & 8 deletions components/report-template.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,27 @@ export interface ReportTemplateProps {
chatTitle: string
}

/**
* Normalises a message's content field to a plain string.
* The AI SDK can return content as a string or as an array of content-part
* objects like { type: 'text', text: '...' } | { type: 'image', ... }.
* Passing an object directly to React causes the "Objects are not valid as a
* React child" error, so we always extract the text before rendering.
*/
function getContentString(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((part: any) => {
if (typeof part === 'string') return part
if (part && typeof part === 'object' && part.type === 'text') return part.text ?? ''
return ''
})
.join(' ')
}
return ''
}

export const ReportTemplate: React.FC<ReportTemplateProps> = ({
messages,
drawnFeatures,
Expand DownExpand Up@@ -49,12 +70,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
<div className="space-y-8">
{filteredMessages.map((message, index) => {
if (message.type === 'input' || message.type === 'input_related') {
let content = ''
const rawContent = getContentString(message.content)
let content = rawContent
try {
const json = JSON.parse(message.content as string)
content = message.type === 'input' ? json.input : json.related_query
const json = JSON.parse(rawContent)
content = message.type === 'input'
? (json.input ?? rawContent)
: (json.related_query ?? rawContent)
} catch (e) {
content = message.content as string
// rawContent is already a plain string
}
return (
<div key={index} className="bg-gray-50 p-4 rounded-lg border-l-4 border-blue-500">
Expand All@@ -63,17 +87,19 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} else if (message.type === 'response') {
const rawContent = getContentString(message.content)
return (
<div key={index} className="prose prose-sm max-w-none">
<p className="text-sm font-bold text-green-600 mb-1">AI Response</p>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content as string}
{rawContent}
</ReactMarkdown>
</div>
)
} else if (message.type === 'resolution_search_result') {
const rawContent = getContentString(message.content)
try {
const result = JSON.parse(message.content as string)
const result = JSON.parse(rawContent)
return (
<div key={index} className="space-y-4">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
Expand All@@ -99,6 +125,15 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</div>
)
} catch (e) {
// If content is not JSON, show it as plain text
if (rawContent) {
return (
<div key={index} className="bg-purple-50 p-4 rounded-lg text-gray-800">
<p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p>
<p>{rawContent}</p>
</div>
)
}
return null
}
}
Expand All@@ -120,11 +155,11 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{drawnFeatures.map((feature, i) => (
{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>
Expand Down