Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
DATABASE_URL="postgresql://user:password@host:port/db"
ENABLE_AUTH=false
OPENAI_API_KEY=sk-dummy
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=pk.dummy
MAPBOX_ACCESS_TOKEN=pk.dummy
Comment on lines 1 to +5
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if .env is gitignored
echo "=== .gitignore contents for .env ==="
grep -n '\.env' .gitignore || echo ".env is NOT in .gitignore"

# Check if .env.example or .env.local already exists
echo ""
echo "=== Existing env files ==="
fd -H -d 1 '\.env'

# Check if .env is actually tracked in git
echo ""
echo "=== Is .env tracked in git? ==="
git ls-files | grep -E '^\.env$' && echo ".env IS tracked in git" || echo ".env is NOT tracked in git"

Repository: QueueLab/QCX

Length of output: 283


Remove .env from version control—it is currently tracked in git.

.env is committed to the repository and not protected by .gitignore (which only excludes .env*.local files). This means secrets are permanently stored in git history. Even if sk-dummy and other values are currently placeholders, this pattern enables accidental commits of real credentials.

Rename this file to .env.example, add .env to .gitignore, and update documentation to instruct developers to copy .env.example to .env locally. Additionally, the ENABLE_AUTH=false setting should not ship to any environment that reaches production.

🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 1-1: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 4-4: [UnorderedKey] The NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN key should go before the OPENAI_API_KEY key

(UnorderedKey)


[warning] 5-5: [UnorderedKey] The MAPBOX_ACCESS_TOKEN key should go before the NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN key

(UnorderedKey)

🤖 Prompt for AI Agents
In @.env around lines 1 - 5, Remove the tracked .env file from git and replace
it with a template: rename the committed file to .env.example (preserving keys
like DATABASE_URL, OPENAI_API_KEY, NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN,
MAPBOX_ACCESS_TOKEN, and ENABLE_AUTH) and commit that; add .env to .gitignore
and run git rm --cached .env to stop tracking the real file; update project
docs/readme to instruct developers to copy .env.example -> .env and populate
secrets locally; and remove or avoid shipping ENABLE_AUTH=false in any
production config (ensure production uses asecure setting or omits this key).

159 changes: 128 additions & 31 deletions components/followup-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,164 @@
'use client'

import { useState } from 'react'
import { useState, useRef, ChangeEvent } from 'react'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { useActions, useUIState } from 'ai/rsc'
import type { AI } from '@/app/actions'
import { UserMessage } from './user-message'
import { ArrowRight } from 'lucide-react'
import { ArrowRight, Paperclip, X } from 'lucide-react'
import { useMapData } from './map/map-data-context'
import { nanoid } from '@/lib/utils'
import { nanoid, cn } from '@/lib/utils'
import Textarea from 'react-textarea-autosize'

export function FollowupPanel() {
const [input, setInput] = useState('')
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const { submit } = useActions()
const [, setMessages] = useUIState<typeof AI>()
const { mapData } = useMapData()
const fileInputRef = useRef<HTMLInputElement>(null)

const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (file.size > 10 * 1024 * 1024) {
alert('File size must be less than 10MB')
return
}
setSelectedFile(file)
}
Comment on lines +21 to +29

Choose a reason for hiding this comment

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

Using alert() for validation is a poor UX and hard to test/standardize. It also blocks the main thread and doesn’t match typical app notification patterns.

Given this component already uses data-testid hooks, a non-blocking, in-app error message (or shared toast system) would be more consistent and testable.

Suggestion

Replace alert() with an inline error state or the app’s toast/notification mechanism.

Example:

  • Add const [attachmentError, setAttachmentError] = useState<string | null>(null)
  • Set it when validation fails, render it below the input, and clear it on successful selection.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit implementing an inline error message.

}
Comment on lines +21 to +30
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 input is not reset when validation fails, preventing re-selection of the same file.

When a file exceeds 10MB and the early return fires (line 26), fileInputRef.current.value is not cleared. If the user then tries to select the same file again (after perhaps compressing it—unlikely, but also for a different file with the same name), the browser's onChange won't fire because the input's value hasn't changed.

Proposed fix
  const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (file) {
      if (file.size > 10 * 1024 * 1024) {
        alert('File size must be less than 10MB')
+       if (fileInputRef.current) {
+         fileInputRef.current.value = ''
+       }
        return
      }
      setSelectedFile(file)
    }
  }
📝 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 handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (file.size > 10 * 1024 * 1024) {
alert('File size must be less than 10MB')
return
}
setSelectedFile(file)
}
}
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (file.size > 10 * 1024 * 1024) {
alert('File size must be less than 10MB')
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
return
}
setSelectedFile(file)
}
}
🤖 Prompt for AI Agents
In `@components/followup-panel.tsx` around lines 21 - 30, The file input handler
handleFileChange fails to clear the input when validation rejects a file, so the
same filename won't retrigger onChange; update handleFileChange to, on
validation failure (file.size > 10 * 1024 * 1024), check fileInputRef.current
exists and set fileInputRef.current.value = '' (and optionally
setSelectedFile(null)) before returning, ensuring the DOM input is reset so
selecting the same file later will fire onChange.


const handleAttachmentClick = () => {
fileInputRef.current?.click()
}

const clearAttachment = () => {
setSelectedFile(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData()
formData.append("input", input)

if (!input.trim() && !selectedFile) {
return
}

const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = []
if (input) {
content.push({ type: 'text', text: input })
}
if (selectedFile && selectedFile.type.startsWith('image/')) {
content.push({
type: 'image',
image: URL.createObjectURL(selectedFile)
})
}
Comment on lines +50 to +59
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

Text files are accepted but silently dropped from the user message content.

The accept attribute on line 96 allows text/plain, but the content construction on line 54 only adds an entry when selectedFile.type.startsWith('image/'). A user who attaches a .txt file will see the file preview and the file is sent in FormData, but the UserMessage will show no indication of the attachment—creating a confusing UX disconnect.

Either remove text/plain from the accept attribute (if only images are intended), or add handling for text file content.

Also applies to: 91-96

🤖 Prompt for AI Agents
In `@components/followup-panel.tsx` around lines 50 - 59, The UI currently accepts
text/plain files but the content builder only handles images and input text, so
selected .txt files are silently dropped; either remove 'text/plain' from the
file input accept attribute (so only images can be chosen) or add handling in
the content construction: when selectedFile exists and selectedFile.type ===
'text/plain' (or !type.startsWith('image/')), read the file contents (e.g.,
FileReader or file.text()) and push a { type: 'text', text: <file contents> }
entry into the content array alongside the existing image handling; update
references to selectedFile, content, and the file input accept attribute
accordingly.

Comment on lines +54 to +59
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

Memory leak: URL.createObjectURL is never revoked.

Each call to URL.createObjectURL allocates a blob URL that persists until the document is unloaded or explicitly revoked. Since clearAttachment (called on submit) doesn't call URL.revokeObjectURL, repeated attach/submit cycles will leak memory.

Also, this blob URL is ephemeral and only valid in the current browsing context—it won't survive a page refresh or be meaningful to the server. Is this intentional for a local preview only?

Proposed fix

Track the object URL and revoke it on cleanup:

+ const [previewUrl, setPreviewUrl] = useState<string | null>(null)

  const clearAttachment = () => {
+   if (previewUrl) {
+     URL.revokeObjectURL(previewUrl)
+     setPreviewUrl(null)
+   }
    setSelectedFile(null)
    if (fileInputRef.current) {
      fileInputRef.current.value = ''
    }
  }

Then generate the URL when the file is selected (in handleFileChange) and use previewUrl where URL.createObjectURL(selectedFile) is currently called.

🤖 Prompt for AI Agents
In `@components/followup-panel.tsx` around lines 54 - 59, selectedFile's image
preview is created with URL.createObjectURL inline and never revoked, causing a
memory leak; instead, generate the blob URL when the file is selected (in
handleFileChange), store it in a state variable (e.g., previewUrl), and use
previewUrl where URL.createObjectURL(selectedFile) is currently used (inside the
content push). Ensure you revoke any existing previewUrl before creating a new
one, call URL.revokeObjectURL(previewUrl) in clearAttachment, and also revoke in
a useEffect cleanup on unmount to avoid leaks.

Comment on lines +50 to +59

Choose a reason for hiding this comment

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

URL.createObjectURL(selectedFile) is created during submission and never revoked. Over time (especially with repeated attachments) this can leak memory. Even though the preview is “optimistic”, the object URL should be cleaned up once it’s no longer needed.

Also, note that sending an image field containing an object URL is only meaningful for local rendering; it should not be relied upon for any server-side processing.

Suggestion

Track and revoke the object URL.

Example:

  • Store a previewUrl in state when selecting the file:
    • const [previewUrl, setPreviewUrl] = useState<string | null>(null)
    • On file select: const url = URL.createObjectURL(file); setPreviewUrl(url)
  • Use previewUrl in content.
  • Revoke when clearing/changing/unmounting:
    • In clearAttachment: if (previewUrl) URL.revokeObjectURL(previewUrl)
    • Or via useEffect(() => () => { if (previewUrl) URL.revokeObjectURL(previewUrl) }, [previewUrl])

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


const userMessage = {
id: nanoid(),
isGenerating: false,
component: <UserMessage content={input} />
component: <UserMessage content={content.length > 0 ? content : input} />
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

content can be an empty array when a non-image file is attached with no text.

If input is empty and selectedFile is a text file (not image/*), then content stays [], and the fallback content.length > 0 ? content : input evaluates to input which is "". The UserMessage would render with an empty string as content.

Proposed fix

Guard submission against this state, or always include a text entry even if empty (if the server expects it):

-    component: <UserMessage content={content.length > 0 ? content : input} />
+    component: <UserMessage content={content.length > 0 ? content : input || '[Attachment]'} />

Though the better fix is to address the text/plain acceptance issue noted above.

📝 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
component: <UserMessage content={content.length > 0 ? content : input} />
component: <UserMessage content={content.length > 0 ? content : input || '[Attachment]'} />
🤖 Prompt for AI Agents
In `@components/followup-panel.tsx` at line 64, The rendering can pass an empty
string to UserMessage because content can be [] when a non-image file is
attached and input is "", so update the guard around the UserMessage prop
(component: <UserMessage ...>) to ensure you never pass an empty value: check
content.length first, then check input for non-empty, and if both are empty but
selectedFile exists and selectedFile.type startsWith('text/') supply a text
fallback (or read the file's text into content) so UserMessage always receives
meaningful text; adjust the logic that populates content/input accordingly in
the FollowupPanel component to prevent rendering an empty string.

}

Comment on lines +46 to 66

Choose a reason for hiding this comment

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

The optimistic UserMessage uses content.length > 0 ? content : input, but content is only populated when input is truthy (not trimmed) and when the attachment is an image. This can lead to odd cases:

  • If input is just whitespace, it’s included as a text item (if (input)) but the form blocks empty submissions using !input.trim(). That means the optimistic message could contain whitespace text even though the intent is “empty text”.
  • If a non-image file is attached, content may end up containing only text (or nothing) even though a file is being sent.

This is a correctness/UI consistency issue in the optimistic rendering logic.

Suggestion

Align the optimistic content construction with the submission guard:

  • Use trimmed text:
    • const trimmed = input.trim()
    • if (trimmed) content.push({ type: 'text', text: trimmed })
  • Decide how to represent non-image attachments (see earlier comment) or prevent them.
  • Then set UserMessage content unconditionally to content (no fallback to raw input), since content would always represent what’s being submitted.

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

// Include drawn features in the form data
const formData = new FormData()
formData.append('input', input)
if (selectedFile) {
formData.append('file', selectedFile)
}
Comment on lines +21 to +71

Choose a reason for hiding this comment

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

handleFileChange allows selecting text/plain (per accept) but the submit path only treats image/* as content; non-image files will be uploaded in FormData but won’t be represented in the optimistic UserMessage content. That creates a mismatch between what the user sees locally and what the backend receives.

Related: accept includes text/plain yet there’s no UI affordance indicating that text files are supported (and no rendering path for them).

Suggestion

Make attachment handling consistent: either (a) restrict to images only in both accept and FormData append, or (b) support non-image attachments end-to-end (optimistic UI + server contract).

Option A (images only):

  • Change accept to images: accept="image/png,image/jpeg,image/webp"
  • In handleFileChange, reject non-images with a user-facing message.
  • In submit, append file only if it’s an image.

Option B (support text/plain):

  • Add a { type: 'file'; fileName: string } (or similar) entry to content so the UserMessage can render it.
  • Keep the FormData append as-is.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit implementing the chosen option.

formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || []))

setInput('')
clearAttachment()

const responseMessage = await submit(formData)
setMessages(currentMessages => [
...currentMessages,
userMessage,
responseMessage
])

setInput('')
}

return (
<form
onSubmit={handleSubmit}
className="relative flex items-center space-x-1"
>
<Input
type="text"
name="input"
placeholder="Explore"
value={input}
className="pr-14 h-12"
onChange={e => setInput(e.target.value)}
/>
<Button
type="submit"
size={'icon'}
disabled={input.length === 0}
variant={'ghost'}
className="absolute right-1"
<div className="flex flex-col w-full">
<form
onSubmit={handleSubmit}
className="relative flex items-start w-full"
>
<ArrowRight size={20} />
</Button>
</form>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
accept="text/plain,image/png,image/jpeg,image/webp"
/>
<Button
type="button"
variant={'ghost'}
size={'icon'}
className="absolute left-3 top-1/2 transform -translate-y-1/2"
onClick={handleAttachmentClick}
data-testid="followup-attachment-button"
>
<Paperclip size={20} />
</Button>
<Textarea
name="input"
rows={1}
maxRows={5}
tabIndex={0}
placeholder="Explore"
spellCheck={false}
value={input}
data-testid="followup-input"
className={cn(
'resize-none w-full min-h-12 rounded-fill border border-input pl-14 pr-14 pt-3 pb-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-muted'
)}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.nativeEvent.isComposing
) {
if (input.trim().length === 0 && !selectedFile) {
e.preventDefault()
return
}
Comment on lines +127 to +130
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

Inconsistent empty-input checks: input.trim().length vs input.length.

Line 127 uses input.trim().length === 0 (correct—whitespace-only input is empty), but line 142 uses input.length === 0 for the submit button's disabled prop. This means typing only spaces enables the submit button visually, but pressing Enter won't submit. Use the same trimmed check for both.

Proposed fix
-          disabled={input.length === 0 && !selectedFile}
+          disabled={input.trim().length === 0 && !selectedFile}

Also applies to: 142-142

🤖 Prompt for AI Agents
In `@components/followup-panel.tsx` around lines 127 - 130, The submit disabled
state and the Enter-key empty-input check are inconsistent: change the submit
button's disabled prop to use the same trimmed-empty check as the key handler
(use input.trim().length === 0) so whitespace-only input is treated as empty;
ensure any other empty checks in this component (e.g., the onKeyDown/Enter
handler that references selectedFile and input) use input.trim() as well so both
visual disabled state and actual submit behavior match.

e.preventDefault()
const form = e.currentTarget.form
if (form) {
form.requestSubmit()
}
}
}}
/>
<Button
type="submit"
size={'icon'}
disabled={input.length === 0 && !selectedFile}
variant={'ghost'}
data-testid="followup-submit"
className="absolute right-3 top-1/2 transform -translate-y-1/2"
>
<ArrowRight size={20} />
</Button>
</form>
{selectedFile && (
<div className="w-full mt-2">
<div className="flex items-center justify-between p-2 bg-muted rounded-lg">
<span className="text-sm text-muted-foreground truncate max-w-xs">
{selectedFile.name}
</span>
<Button variant="ghost" size="icon" onClick={clearAttachment} data-testid="followup-clear-attachment-button">
<X size={16} />
</Button>
</div>
</div>
)}
</div>
)
Comment on lines 85 to 163
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

Significant code duplication with ChatPanel.

Per the PR objectives and the relevant snippets, ChatPanel already implements the same file attachment pattern (hidden file input, handleAttachmentClick, handleFileChange, clearAttachment, FormData construction). Consider extracting the shared attachment logic (state, refs, handlers, validation, preview UI) into a custom hook (e.g., useFileAttachment) and a shared AttachmentPreview component to keep both panels in sync.

🤖 Prompt for AI Agents
In `@components/followup-panel.tsx` around lines 85 - 163, This duplicates the
file-attachment logic present in ChatPanel; extract the shared state and
handlers (selectedFile, fileInputRef, handleAttachmentClick, handleFileChange,
clearAttachment, and any validation/FormData construction) into a reusable hook
(e.g., useFileAttachment) and move the UI for the selected-file preview into a
shared AttachmentPreview component; update FollowupPanel and ChatPanel to
consume useFileAttachment (provide callbacks for submit) and render
AttachmentPreview instead of keeping their own local refs/state/JSX so both
panels share the same attachment behavior and validation.

}