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
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ APPENDIX: How to apply the Apache License to your work.
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright 2024 Yoshiki Miura
Copyright 2025 Eric Ngoiya

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
57 changes: 1 addition & 56 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@

.mobile-chat-input-area {
height: auto;
padding: 10px;
padding: 4px 10px;
background-color: hsl(var(--background));
/* border-top: 1px solid hsl(var(--border)); */ /* Removed for cleaner separation */
border-bottom: 1px solid hsl(var(--border)); /* Added for separation from messages area below */
Expand All @@ -199,61 +199,6 @@
align-items: center;
}

.mobile-chat-input {
/* position: relative; */ /* No longer fixed to bottom */
/* bottom: 0; */
/* left: 0; */ /* Handled by parent flex */
/* right: 0; */ /* Handled by parent flex */
width: 100%; /* Ensure it takes full width of its container */
padding: 10px;
background-color: hsl(var(--background));
/* border-top: 1px solid hsl(var(--border)); */ /* Removed to avoid double border */
/* z-index: 30; */ /* No longer needed */
}

.mobile-chat-input input {
width: 100%;
padding: 8px;
border: 1px solid hsl(var(--input));
border-radius: var(--radius);
background-color: hsl(var(--input));
color: hsl(var(--foreground));
box-sizing: border-box;
}

.mobile-icons-bar-content .icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
cursor: pointer;
}

.mobile-icons-bar-content .icon-button:hover {
background-color: hsl(var(--secondary-foreground));
color: hsl(var(--secondary));
}

.mobile-chat-input .icon-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: transparent;
border: none;
cursor: pointer;
}

.mobile-chat-input .icon-button.paperclip {
right: 40px;
}

.mobile-chat-input .icon-button.arrow-right {
right: 10px;
}
}

/* Added for MapboxDraw controls */
Expand Down
37 changes: 30 additions & 7 deletions components/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useState, useRef, ChangeEvent, forwardRef, useImperativeHandle, useCallback } from 'react'
import type { AI, UIState } from '@/app/actions'
import { useUIState, useActions, readStreamableValue } from 'ai/rsc'
import { toast } from 'sonner'
// Removed import of useGeospatialToolMcp as it's no longer used/available
import { cn } from '@/lib/utils'
import { UserMessage } from './user-message'
Expand Down Expand Up @@ -69,13 +70,35 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
const file = e.target.files?.[0]
if (file) {
if (file.size > 10 * 1024 * 1024) {
alert('File size must be less than 10MB')
toast.error('File size must be less than 10MB')
return
}
setSelectedFile(file)
}
}

const handlePaste = (e: React.ClipboardEvent) => {
const pastedText = e.clipboardData.getData('text')
if (pastedText.length > 500) {
e.preventDefault()
if (pastedText.length > 10 * 1024 * 1024) {
toast.error('Pasted text exceeds 10MB limit')
return
}
Comment on lines +84 to +87
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

Bug: Size check compares character count against byte threshold.

pastedText.length returns the number of characters (UTF-16 code units), not bytes. For multi-byte Unicode text, the actual byte size can be significantly larger than the character count. This check should validate byte size to be consistent with handleFileChange.

🐛 Proposed fix
   const handlePaste = (e: React.ClipboardEvent) => {
     const pastedText = e.clipboardData.getData('text')
     if (pastedText.length > 1000) {
       e.preventDefault()
-      if (pastedText.length > 10 * 1024 * 1024) {
+      const byteSize = new TextEncoder().encode(pastedText).length
+      if (byteSize > 10 * 1024 * 1024) {
         toast.error('Pasted text exceeds 10MB limit')
         return
       }
📝 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
if (pastedText.length > 10 * 1024 * 1024) {
toast.error('Pasted text exceeds 10MB limit')
return
}
const byteSize = new TextEncoder().encode(pastedText).length
if (byteSize > 10 * 1024 * 1024) {
toast.error('Pasted text exceeds 10MB limit')
return
}
🤖 Prompt for AI Agents
In `@components/chat-panel.tsx` around lines 84 - 87, The pasted-text size check
uses pastedText.length (UTF-16 code units) instead of byte length; update the
validation in the paste handling code to compute the actual byte size (e.g., via
TextEncoder().encode(pastedText).length or new Blob([pastedText]).size) and
compare that byte length to 10 * 1024 * 1024 so it matches the behavior in
handleFileChange; modify the conditional that currently references
pastedText.length to use the computed byteSize and keep the same toast.error and
return flow if it exceeds the limit.

if (selectedFile) {
toast.error(
'Please remove the current attachment to convert large paste to file'
)
return
}
const file = new File([pastedText], 'pasted-text.txt', {
type: 'text/plain'
})
setSelectedFile(file)
setInput('')
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

setInput('') clears any pre-typed text.

If a user types some context before pasting large text, this call erases their typed content. Per PR discussion, e.preventDefault() already stops the pasted text from being inserted, so clearing input may not be necessary.

Consider preserving existing input or showing a success toast so users understand why their input changed.

♻️ Option A: Remove setInput to preserve typed text
       setSelectedFile(file)
-      setInput('')
+      toast.success('Large text converted to file attachment')
♻️ Option B: Add ref guard to prevent race condition (per PR discussion)

If you need to clear input to prevent race conditions with onChange, use a ref to guard against the change event:

+  const isConvertingPaste = useRef(false)
+
   const handlePaste = (e: React.ClipboardEvent) => {
     const pastedText = e.clipboardData.getData('text')
     if (pastedText.length > 1000) {
       e.preventDefault()
       // ... size and attachment checks ...
+      isConvertingPaste.current = true
       const file = new File([pastedText], 'pasted-text.txt', {
         type: 'text/plain'
       })
       setSelectedFile(file)
       setInput('')
+      toast.success('Large text converted to file attachment')
+      // Reset flag after state updates
+      setTimeout(() => { isConvertingPaste.current = false }, 0)
     }
   }

Then guard the onChange:

   onChange={e => {
+    if (isConvertingPaste.current) return
     setInput(e.target.value)
     debouncedGetSuggestions(e.target.value)
   }}
📝 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
setInput('')
setSelectedFile(file)
toast.success('Large text converted to file attachment')
🤖 Prompt for AI Agents
In `@components/chat-panel.tsx` at line 98, The call to setInput('') in the paste
handler is clearing any pre-typed user text; remove that setInput('') so the
existing input is preserved (locate the onPaste / paste handler in
components/chat-panel.tsx and delete the setInput('') statement), or if you must
clear to avoid a race with onChange implement the ref-based guard discussed in
the PR: add a boolean ref (e.g., skipOnChangeRef) and set it around the paste
flow, then check skipOnChangeRef.current inside the onChange handler to ignore
the spurious update instead of wiping the input.

}
}
Comment on lines 80 to 100
Copy link

Choose a reason for hiding this comment

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

The paste threshold (5000) is a magic number here. It should be extracted into a named constant (ideally colocated with related upload/validation constants) so it’s easier to discover, adjust, and test consistently across the codebase.

Suggestion

Extract the threshold into a constant (and consider reusing it in tests).

const LARGE_PASTE_CHAR_THRESHOLD = 5000

const handlePaste = (e: React.ClipboardEvent) => {
  const pastedText = e.clipboardData.getData('text')
  if (pastedText.length > LARGE_PASTE_CHAR_THRESHOLD) {
    // ...
  }
}

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

Comment on lines 80 to 100
Copy link

Choose a reason for hiding this comment

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

handlePaste always reads text from the clipboard. In some browsers/contexts, a paste may primarily contain files/HTML, and forcing getData('text') + large-text prevention could accidentally interfere with non-text pastes. Consider guarding so this logic only runs when there is meaningful plain-text content and otherwise lets the default paste behavior proceed.

Suggestion

Add conservative guards so you only intercept large plain-text pastes and do not block other clipboard payloads.

const handlePaste = (e: React.ClipboardEvent) => {
  const text = e.clipboardData.getData('text/plain')
  if (!text) return

  if (text.length > LARGE_PASTE_CHAR_THRESHOLD) {
    e.preventDefault()
    if (selectedFile) {
      toast.error('Please remove the current attachment to convert large paste to file')
      return
    }

    setSelectedFile(new File([text], 'pasted-text.txt', { type: 'text/plain' }))
    setInput('')
  }
}

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

Comment on lines 80 to 100
Copy link

Choose a reason for hiding this comment

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

Converting pasted text into a File bypasses the existing 10MB check in handleFileChange. A user could paste extremely large text and end up with a huge in-memory File (and later potentially a failed upload/server rejection). You should apply the same size limit (and ideally share the constant) when creating the file from pasted content.

Suggestion

Reuse the same max-size validation for pasted-text files before calling setSelectedFile.

const MAX_FILE_BYTES = 10 * 1024 * 1024

// ... inside large paste branch
const file = new File([text], 'pasted-text.txt', { type: 'text/plain' })
if (file.size > MAX_FILE_BYTES) {
  toast.error('File size must be less than 10MB')
  return
}
setSelectedFile(file)
setInput('')

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

Comment on lines 80 to 100
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

Consider adding a success toast when paste is converted to file.

When large text is converted to a file, the user sees their input cleared and a file attachment appear. Adding a success toast would make this behavior more discoverable and less confusing.

Additionally, consider wrapping handlePaste in useCallback for consistency with debouncedGetSuggestions and to avoid unnecessary re-renders if the Textarea memoizes props.

♻️ Proposed enhancement
-  const handlePaste = (e: React.ClipboardEvent) => {
+  const handlePaste = useCallback((e: React.ClipboardEvent) => {
     const pastedText = e.clipboardData.getData('text')
     if (pastedText.length > 5000) {
       e.preventDefault()
       if (selectedFile) {
         toast.error(
           'Please remove the current attachment to convert large paste to file'
         )
         return
       }
       const file = new File([pastedText], 'pasted-text.txt', {
         type: 'text/plain'
       })
       setSelectedFile(file)
       setInput('')
+      toast.success('Large text converted to file attachment')
     }
-  }
+  }, [selectedFile, setInput])
🤖 Prompt for AI Agents
In `@components/chat-panel.tsx` around lines 80 - 96, Add a success toast when
large pasted text is converted to a File: inside handlePaste, after creating the
File and calling setSelectedFile(file) and setInput(''), call toast.success with
a brief message like "Pasted text attached as file" so the user sees the
conversion. Also wrap handlePaste in React.useCallback (same pattern as
debouncedGetSuggestions) and include its dependencies (selectedFile,
setSelectedFile, setInput, toast) to avoid unnecessary re-renders and keep prop
stability for any memoized Textarea component.


const handleAttachmentClick = () => {
fileInputRef.current?.click()
}
Expand Down Expand Up @@ -199,13 +222,12 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
onSubmit={handleSubmit}
className={cn(
'max-w-full w-full',
isMobile ? 'px-2 pb-2 pt-1 h-full flex flex-col justify-center' : ''
isMobile ? 'px-2 pb-1 pt-0 h-full flex flex-col justify-center' : ''
)}
>
<div
className={cn(
'relative flex items-start w-full',
isMobile && 'mobile-chat-input' // Apply mobile chat input styling
'relative flex items-start w-full'
)}
>
<input type="hidden" name="mapProvider" value={mapProvider} />
Expand Down Expand Up @@ -241,15 +263,16 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
value={input}
data-testid="chat-input"
className={cn(
'resize-none w-full min-h-12 rounded-fill border border-input pl-14 pr-12 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',
'resize-none w-full rounded-fill border border-input pr-12 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',
isMobile
? 'mobile-chat-input input bg-background'
: 'bg-muted'
? 'bg-background min-h-10 pl-4 pt-2 pb-1'
: 'bg-muted min-h-12 pl-14 pt-3 pb-1'
)}
onChange={e => {
setInput(e.target.value)
debouncedGetSuggestions(e.target.value)
}}
onPaste={handlePaste}
onKeyDown={e => {
if (
e.key === 'Enter' &&
Expand Down
2 changes: 1 addition & 1 deletion components/header-search-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useActions, useUIState } from 'ai/rsc'
import { AI } from '@/app/actions'
import { nanoid } from 'nanoid'
import { UserMessage } from './user-message'
import { toast } from 'react-toastify'
import { toast } from 'sonner'
import { useSettingsStore } from '@/lib/store/settings'
import { useMapData } from './map/map-data-context'

Expand Down
4 changes: 2 additions & 2 deletions components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const Header = () => {

<div id="header-search-portal" />

<a href="https://buy.stripe.com/3cIaEX3tRcur9EM7tbasg00" target="_blank" rel="noopener noreferrer">
<a href="https://buy.stripe.com/14A3cv7K72TR3go14Nasg02" target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="icon">
<TentTree className="h-[1.2rem] w-[1.2rem]" />
</Button>
Expand All @@ -67,7 +67,7 @@ export const Header = () => {
{/* Mobile menu buttons */}
<div className="flex md:hidden gap-2">

<a href="https://buy.stripe.com/3cIaEX3tRcur9EM7tbasg00" target="_blank" rel="noopener noreferrer">
<a href="https://buy.stripe.com/14A3cv7K72TR3go14Nasg02" target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="icon">
<TentTree className="h-[1.2rem] w-[1.2rem]" />
</Button>
Expand Down
2 changes: 1 addition & 1 deletion components/mobile-icons-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const MobileIconsBar: React.FC<MobileIconsBarProps> = ({ onAttachmentClic
<Button variant="ghost" size="icon" data-testid="mobile-search-button">
<Search className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
</Button>
<a href="https://buy.stripe.com/3cIaEX3tRcur9EM7tbasg00" target="_blank" rel="noopener noreferrer">
<a href="https://buy.stripe.com/14A3cv7K72TR3go14Nasg02" target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="icon">
<TentTree className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
</Button>
Expand Down
11 changes: 0 additions & 11 deletions server.log

This file was deleted.

82 changes: 82 additions & 0 deletions tests/paste.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test';

test.describe('Paste to File Conversion', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});

test('converts large pasted text to file', async ({ page }) => {
const chatInput = page.getByTestId('chat-input');

// Create a large text string (> 500 chars)
const largeText = 'A'.repeat(501);

// Simulate paste
await chatInput.focus();
await page.evaluate((text) => {
const dt = new DataTransfer();
dt.setData('text/plain', text);
const event = new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true
});
document.activeElement?.dispatchEvent(event);
}, largeText);

// Check if attachment exists
const attachment = page.getByText('pasted-text.txt');
await expect(attachment).toBeVisible();

// Check if input is empty
await expect(chatInput).toHaveValue('');
});

test('does not convert small pasted text', async ({ page }) => {
const chatInput = page.getByTestId('chat-input');

const smallText = 'Small snippet';

// Simulate paste
await chatInput.focus();
await page.evaluate((text) => {
const dt = new DataTransfer();
dt.setData('text/plain', text);
const event = new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true
});
document.activeElement?.dispatchEvent(event);
}, smallText);

// Check that attachment does NOT exist
const attachment = page.getByText('pasted-text.txt');
await expect(attachment).not.toBeVisible();
});

test('shows error when pasting while file already attached', async ({ page }) => {
const chatInput = page.getByTestId('chat-input');

const largeText1 = 'A'.repeat(501);
await chatInput.focus();
await page.evaluate((text) => {
const dt = new DataTransfer();
dt.setData('text/plain', text);
const event = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
document.activeElement?.dispatchEvent(event);
}, largeText1);

await expect(page.getByText('pasted-text.txt')).toBeVisible();

const largeText2 = 'B'.repeat(501);
await page.evaluate((text) => {
const dt = new DataTransfer();
dt.setData('text/plain', text);
const event = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
document.activeElement?.dispatchEvent(event);
}, largeText2);
Comment on lines +72 to +78
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

Missing focus before second paste may cause test flakiness.

After the first paste creates an attachment, the focus might be lost. The second paste (lines 73-78) dispatches the event to document.activeElement, which may no longer be the chat input.

🛡️ Proposed fix
     await expect(page.getByText('pasted-text.txt')).toBeVisible();

     const largeText2 = 'B'.repeat(501);
+    await chatInput.focus();
     await page.evaluate((text) => {
       const dt = new DataTransfer();
       dt.setData('text/plain', text);
📝 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 largeText2 = 'B'.repeat(501);
await page.evaluate((text) => {
const dt = new DataTransfer();
dt.setData('text/plain', text);
const event = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
document.activeElement?.dispatchEvent(event);
}, largeText2);
const largeText2 = 'B'.repeat(501);
await chatInput.focus();
await page.evaluate((text) => {
const dt = new DataTransfer();
dt.setData('text/plain', text);
const event = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
document.activeElement?.dispatchEvent(event);
}, largeText2);
🤖 Prompt for AI Agents
In `@tests/paste.spec.ts` around lines 72 - 78, The second paste dispatch uses
document.activeElement which may not be the chat input after the first paste;
before creating/dispatching the ClipboardEvent for largeText2, explicitly focus
the chat input element used earlier (e.g., call page.focus(selector) or inside
page.evaluate call document.querySelector('<chat-input-selector>').focus()) so
the paste event targets the correct element instead of relying on
document.activeElement; update the block that constructs the
DataTransfer/ClipboardEvent (the page.evaluate invocation that uses largeText2)
to focus the intended input element first.


await expect(page.getByText('Please remove the current attachment')).toBeVisible();
});
});