diff --git a/LICENSE b/LICENSE index 8a29bc5a..3d85d05f 100644 --- a/LICENSE +++ b/LICENSE @@ -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. diff --git a/app/globals.css b/app/globals.css index 22f95cba..5fd22aef 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 */ @@ -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 */ diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index c45844d3..42da9290 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,35 @@ 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 > 500) { + 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' + ) + return + } + const file = new File([pastedText], 'pasted-text.txt', { + type: 'text/plain' + }) + setSelectedFile(file) + setInput('') + } + } + const handleAttachmentClick = () => { fileInputRef.current?.click() } @@ -199,13 +222,12 @@ export const ChatPanel = forwardRef(({ 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' : '' )} >
@@ -241,15 +263,16 @@ export const ChatPanel = forwardRef(({ 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' && 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/components/header.tsx b/components/header.tsx index 644ba8c0..94a23801 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -53,7 +53,7 @@ export const Header = () => {
- + @@ -67,7 +67,7 @@ export const Header = () => { {/* Mobile menu buttons */}
- + diff --git a/components/mobile-icons-bar.tsx b/components/mobile-icons-bar.tsx index bde08487..d0db2cfa 100644 --- a/components/mobile-icons-bar.tsx +++ b/components/mobile-icons-bar.tsx @@ -48,7 +48,7 @@ export const MobileIconsBar: React.FC = ({ onAttachmentClic - + 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 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(); + }); +});