diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx index 3de3c8c..c3da3af 100644 --- a/apps/web/src/components/ChatInput.tsx +++ b/apps/web/src/components/ChatInput.tsx @@ -1,16 +1,26 @@ -import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'; +import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react'; import { Send } from 'lucide-react'; import { toast } from 'sonner'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; -import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments'; +import { + flattenToMessage, + inferLanguage, + looksBinary, + MAX_FILE_SIZE_BYTES, + PASTE_INLINE_MAX_LINES, + type Attachment, +} from '@/lib/attachments'; import { AttachmentChip } from '@/components/AttachmentChip'; import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal'; import { FileMentionPopover } from '@/components/FileMentionPopover'; +import { DropOverlay } from '@/components/DropOverlay'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; import { useViewport } from '@/hooks/useViewport'; +const MAX_ATTACHMENTS = 10; + interface Props { disabled?: boolean; projectId: string; @@ -24,6 +34,9 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) { const [busy, setBusy] = useState(false); const [attachments, setAttachments] = useState([]); const [previewAttachment, setPreviewAttachment] = useState(null); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const dropRootRef = useRef(null); + const pasteCounterRef = useRef(0); const [mentionState, setMentionState] = useState<{ open: boolean; query: string; @@ -35,8 +48,8 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) { function addAttachment(a: Attachment) { setAttachments(prev => { - if (prev.length >= 10) { - toast.error('Max 10 attachments per message'); + if (prev.length >= MAX_ATTACHMENTS) { + toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`); return prev; } return [...prev, a]; @@ -185,6 +198,162 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) { const closeMention = useCallback(() => setMentionState(null), []); + // ---- Drag & drop (F1 + F3 + F4) ---------------------------------------- + // The drop zone is the outer ChatInput container (ref'd as dropRootRef). + // onDragLeave only clears the highlight when the cursor leaves the + // container, not when it crosses into a child element. + + async function processDroppedFile(file: File) { + // Size gate + if (file.size > MAX_FILE_SIZE_BYTES) { + const mb = (file.size / (1024 * 1024)).toFixed(1); + toast.error(`File ${file.name} is too large (${mb} MB). Limit is 5 MB.`); + return; + } + // Read once as ArrayBuffer so we can do byte-level binary detection + // before deciding whether to decode as text. + let buf: ArrayBuffer; + try { + buf = await file.arrayBuffer(); + } catch (err) { + toast.error(`Failed to read ${file.name}: ${err instanceof Error ? err.message : String(err)}`); + return; + } + if (looksBinary(buf)) { + toast.error(`${file.name} appears to be binary.`); + return; + } + const text = new TextDecoder('utf-8', { fatal: false }).decode(buf); + addAttachment({ + id: crypto.randomUUID(), + kind: 'file', + filename: file.name, + language: inferLanguage(file.name), + content: text, + source: 'drop', + }); + } + + function isFolderItem(item: DataTransferItem | undefined): boolean { + if (!item) return false; + // webkitGetAsEntry is non-standard but supported in Chromium + Safari. + // If unavailable, we conservatively treat the entry as a file. + const entry = + typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null; + if (entry && entry.isDirectory) return true; + // Heuristic fallback: folders dragged from Finder have type === '' and + // a 0-byte File. The empty-type alone isn't reliable for files (some + // plaintext drops also lack a type), so we only flag when the entry + // explicitly says directory. + return false; + } + + async function handleDroppedItems(dt: DataTransfer) { + // Snapshot items first because reading files inside the loop can + // detach the DataTransfer between awaits. + const itemsArray: { file: File | null; isFolder: boolean }[] = []; + if (dt.items && dt.items.length > 0) { + for (let i = 0; i < dt.items.length; i++) { + const it = dt.items[i]; + if (!it || it.kind !== 'file') continue; + const folder = isFolderItem(it); + const file = folder ? null : it.getAsFile(); + itemsArray.push({ file, isFolder: folder }); + } + } else { + for (let i = 0; i < dt.files.length; i++) { + const f = dt.files[i]; + if (f) itemsArray.push({ file: f, isFolder: false }); + } + } + + let remainingSlots = MAX_ATTACHMENTS - attachments.length; + let folderRejected = false; + for (const { file, isFolder } of itemsArray) { + if (isFolder) { + if (!folderRejected) { + toast.error('Folders are not supported'); + folderRejected = true; + } + continue; + } + if (!file) continue; + if (remainingSlots <= 0) { + toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`); + return; + } + await processDroppedFile(file); + remainingSlots -= 1; + } + } + + function onDragEnter(e: DragEvent) { + if (disabled || busy) return; + e.preventDefault(); + setIsDraggingOver(true); + } + + function onDragOver(e: DragEvent) { + if (disabled || busy) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + } + + function onDragLeave(e: DragEvent) { + // Only clear when the cursor actually leaves the root container. + // relatedTarget is the element being entered; if it's inside the root, + // ignore — we're just crossing into a child. + const root = dropRootRef.current; + if (!root) return; + const related = e.relatedTarget as Node | null; + if (related && root.contains(related)) return; + setIsDraggingOver(false); + } + + function onDrop(e: DragEvent) { + e.preventDefault(); + setIsDraggingOver(false); + if (disabled || busy) return; + void handleDroppedItems(e.dataTransfer); + } + // ---- end Drag & drop ----------------------------------------------------- + + // ---- Paste-as-attachment (F2) ------------------------------------------- + // Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than + // inline content. Image pastes are rejected with a toast. If both text and + // image are present (e.g. screenshot tool that sets both), prefer text. + + function onPaste(e: React.ClipboardEvent) { + const cd = e.clipboardData; + if (!cd) return; + const text = cd.getData('text/plain'); + const hasImage = Array.from(cd.items ?? []).some((it) => + it.type.startsWith('image/'), + ); + if (text) { + const lineCount = text.split('\n').length; + if (lineCount > PASTE_INLINE_MAX_LINES) { + e.preventDefault(); + pasteCounterRef.current += 1; + addAttachment({ + id: crypto.randomUUID(), + kind: 'paste', + filename: `pasted-${pasteCounterRef.current}.txt`, + language: 'plaintext', + content: text, + source: 'paste', + }); + } + // <= threshold: let default paste insert inline. + return; + } + if (hasImage) { + e.preventDefault(); + toast.error('Image paste is not supported. Drop a file or paste text.'); + } + } + // ---- end Paste-as-attachment -------------------------------------------- + function onKeyDown(e: KeyboardEvent) { if (mentionState?.open) return; // IME safety: never act on Enter while an IME composition is in flight @@ -228,7 +397,16 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) { } return ( -
+
+
{attachments.length > 0 && (
@@ -248,6 +426,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) { value={value} onChange={handleChange} onKeyDown={onKeyDown} + onPaste={onPaste} placeholder={ isMobile ? 'Ask about this project. Tap send to submit.' diff --git a/apps/web/src/components/DropOverlay.tsx b/apps/web/src/components/DropOverlay.tsx new file mode 100644 index 0000000..5c3a4ae --- /dev/null +++ b/apps/web/src/components/DropOverlay.tsx @@ -0,0 +1,18 @@ +interface Props { + visible: boolean; +} + +// Visual cue layered over the ChatInput while a drag is in progress. +// Pointer-events: none so the underlying drop handler still receives the +// drop event. Renders nothing when not visible (cheap and out of layout). +export function DropOverlay({ visible }: Props) { + if (!visible) return null; + return ( + + ); +} diff --git a/apps/web/src/lib/attachments.ts b/apps/web/src/lib/attachments.ts index f8b7468..bf8aeda 100644 --- a/apps/web/src/lib/attachments.ts +++ b/apps/web/src/lib/attachments.ts @@ -8,6 +8,34 @@ export type Attachment = { source: '@' | 'line-select' | 'drop' | 'paste'; }; +// v1.7: caps shared between drag-drop and paste-as-attachment so both paths +// reject the same way. Match the existing 10-attachment cap in +// ChatInput.addAttachment. +export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB +export const PASTE_INLINE_MAX_LINES = 8; + +// First-8KB null-byte scan. Returns true if the content looks binary. +// Accepts a string (post-decode), an ArrayBuffer (pre-decode), or a Uint8Array. +// For binary files like PNG, scanning bytes is more reliable than scanning +// post-UTF-8-decode strings because invalid sequences may be replaced rather +// than preserved. +export function looksBinary(content: string | ArrayBuffer | Uint8Array): boolean { + const SCAN_BYTES = 8192; + if (typeof content === 'string') { + const max = Math.min(content.length, SCAN_BYTES); + for (let i = 0; i < max; i++) { + if (content.charCodeAt(i) === 0) return true; + } + return false; + } + const bytes = content instanceof Uint8Array ? content : new Uint8Array(content); + const max = Math.min(bytes.length, SCAN_BYTES); + for (let i = 0; i < max; i++) { + if (bytes[i] === 0) return true; + } + return false; +} + export const LANG_MAP: Record = { ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', mjs: 'javascript', cjs: 'javascript',