From 9a54a27129b8980155ad1d18bd0abb2de75db9e7 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:30:45 +0000 Subject: [PATCH 1/4] feat: automatically convert large pasted text into file attachments This change implements a UX improvement where pasted text exceeding 5,000 characters is automatically converted into a `.txt` file attachment named `pasted-text.txt`. Key changes: - Added `onPaste` handler to the chat input `Textarea`. - Implemented character threshold (5,000) for auto-conversion. - Added collision handling: if a file is already attached, the large paste is prevented and a toast notification is shown. - Updated file size validation to use `sonner` toasts instead of browser alerts for consistency. - Ensured `e.preventDefault()` is called for large pastes to prevent UI clogging. - Verified functionality with targeted Playwright tests and frontend screenshots. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- components/chat-panel.tsx | 22 +++++++++++++++++++++- server.log | 11 ----------- 2 files changed, 21 insertions(+), 12 deletions(-) delete mode 100644 server.log diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 2a8c559e..fd600cc9 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -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' @@ -69,13 +70,31 @@ export const ChatPanel = forwardRef(({ 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 > 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('') + } + } + const handleAttachmentClick = () => { fileInputRef.current?.click() } @@ -249,6 +268,7 @@ export const ChatPanel = forwardRef(({ messages, i setInput(e.target.value) debouncedGetSuggestions(e.target.value) }} + onPaste={handlePaste} onKeyDown={e => { if ( e.key === 'Enter' && diff --git a/server.log b/server.log deleted file mode 100644 index 45044476..00000000 --- a/server.log +++ /dev/null @@ -1,11 +0,0 @@ -$ next dev --turbo - ⚠ Port 3000 is in use, using available port 3003 instead. - ▲ Next.js 15.3.6 (Turbopack) - - Local: http://localhost:3003 - - Network: http://192.168.0.2:3003 - - Environments: .env.local, .env - - ✓ Starting... - ○ Compiling middleware ... - ✓ Compiled middleware in 648ms - ✓ Ready in 2.5s From d5d090d460eb7a7a92006c7d2f3e41800f8e7db6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:36:15 +0000 Subject: [PATCH 2/4] feat: final implementation of large paste conversion with collision handling and input clearing This commit ensures: - Large pastes (>5000 chars) are converted to a file attachment. - Chat input is cleared upon conversion to allow for follow-up text. - Paste is prevented (preventDefault) even on collision to keep UI clean. - All validation uses sonner toasts for consistent UI. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> From 5b0855e46f6abdd2a81f1736dfd54af139bd755f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:50:58 +0000 Subject: [PATCH 3/4] feat: lower large paste threshold and add 10MB limit check - Lowered the threshold for automatic file conversion from 5,000 to 1,000 characters to better catch long inputs. - Added a 10MB limit check for pasted content to ensure consistency with file upload limits. - Ensured input is cleared and default behavior is prevented on all large pastes. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- components/chat-panel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index fd600cc9..481bbc2f 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -79,8 +79,12 @@ export const ChatPanel = forwardRef(({ messages, i const handlePaste = (e: React.ClipboardEvent) => { const pastedText = e.clipboardData.getData('text') - if (pastedText.length > 5000) { + if (pastedText.length > 1000) { e.preventDefault() + if (pastedText.length > 10 * 1024 * 1024) { + toast.error('Pasted text exceeds 10MB limit') + return + } if (selectedFile) { toast.error( 'Please remove the current attachment to convert large paste to file' From 88731f36097766ce4a1be2a491f382e397a19aa3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:17:44 +0000 Subject: [PATCH 4/4] feat: add automatic conversion of large pasted text to file attachments - Intercept paste events in `ChatPanel` and convert text > 500 chars to `pasted-text.txt`. - Clear the textarea immediately upon conversion. - Unify toast notifications by switching from `react-toastify` to `sonner` in `ChatPanel` and `HeaderSearchButton`. - Add E2E tests for paste-to-file conversion logic. - Fix regression in Resolution Search by ensuring consistent notification library usage. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- components/chat-panel.tsx | 2 +- components/header-search-button.tsx | 2 +- tests/paste.spec.ts | 82 +++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 tests/paste.spec.ts diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 481bbc2f..42da9290 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -79,7 +79,7 @@ export const ChatPanel = forwardRef(({ messages, i const handlePaste = (e: React.ClipboardEvent) => { const pastedText = e.clipboardData.getData('text') - if (pastedText.length > 1000) { + if (pastedText.length > 500) { e.preventDefault() if (pastedText.length > 10 * 1024 * 1024) { toast.error('Pasted text exceeds 10MB limit') diff --git a/components/header-search-button.tsx b/components/header-search-button.tsx index 92cb1c65..0feccdb5 100644 --- a/components/header-search-button.tsx +++ b/components/header-search-button.tsx @@ -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' diff --git a/tests/paste.spec.ts b/tests/paste.spec.ts new file mode 100644 index 00000000..935b16c5 --- /dev/null +++ b/tests/paste.spec.ts @@ -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); + + await expect(page.getByText('Please remove the current attachment')).toBeVisible(); + }); +});