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
26 changes: 25 additions & 1 deletion 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.

}
}

const handleAttachmentClick = () => {
fileInputRef.current?.click()
}
Expand Down Expand Up @@ -249,6 +272,7 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
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
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();
});
});