From 9c6067dcd9f30c7c269d09d2c4c44cdddd597d07 Mon Sep 17 00:00:00 2001 From: Akhileshrangani4 Date: Tue, 29 Oct 2024 01:37:46 -0400 Subject: [PATCH] feat: enhance AI Chat with context management, file integration, image support, and improved code handling - Added context tabs system for managing multiple types of context (files, code snippets, images) - Added preview functionality for context items - Added ability to expand/collapse context previews - Added file selection popup/dropdown - Added file search functionality - Added image upload button - Added image paste support - Added image preview in context tabs - Added automatic code detection on paste - Added line number tracking for code snippets - Added source file name preservation - Added line range display for code contexts - Added model selection dropdown (Claude 3.5 Sonnet/Claude 3) - Added Ctrl+Enter for sending with full context - Added Backspace to remove last context tab when input is empty - Added smart code detection on paste --- .../components/editor/AIChat/ChatInput.tsx | 286 ++++++++++++++++-- .../components/editor/AIChat/ChatMessage.tsx | 77 +++-- .../components/editor/AIChat/ContextTabs.tsx | 167 ++++++++++ frontend/components/editor/AIChat/index.tsx | 201 ++++++++++-- frontend/components/editor/index.tsx | 17 ++ frontend/components/ui/tab-preview.tsx | 0 6 files changed, 665 insertions(+), 83 deletions(-) create mode 100644 frontend/components/editor/AIChat/ContextTabs.tsx create mode 100644 frontend/components/ui/tab-preview.tsx diff --git a/frontend/components/editor/AIChat/ChatInput.tsx b/frontend/components/editor/AIChat/ChatInput.tsx index 380b6a4..499591b 100644 --- a/frontend/components/editor/AIChat/ChatInput.tsx +++ b/frontend/components/editor/AIChat/ChatInput.tsx @@ -1,12 +1,26 @@ -import { Send, StopCircle } from "lucide-react" +import { Send, StopCircle, AtSign, Image as ImageIcon } from "lucide-react" import { Button } from "../../ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select" +import { useRef, useEffect, useState } from "react" +import * as monaco from 'monaco-editor' +import { TFile, TFolder } from "@/lib/types" interface ChatInputProps { input: string setInput: (input: string) => void isGenerating: boolean - handleSend: () => void + handleSend: (useFullContext?: boolean) => void handleStopGeneration: () => void + onImageUpload: (file: File) => void + onFileMention: (fileName: string) => void + addContextTab: (type: string, title: string, content: string, lineRange?: { start: number, end: number }) => void + activeFileName?: string + editorRef: React.MutableRefObject + lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null> + contextTabs: { id: string; type: string; title: string; content: string; lineRange?: { start: number; end: number } }[] + onRemoveTab: (id: string) => void + textareaRef: React.RefObject + files: (TFile | TFolder)[] } export default function ChatInput({ @@ -15,37 +29,249 @@ export default function ChatInput({ isGenerating, handleSend, handleStopGeneration, + onImageUpload, + onFileMention, + addContextTab, + activeFileName, + editorRef, + lastCopiedRangeRef, + contextTabs, + onRemoveTab, + textareaRef, + files, }: ChatInputProps) { + // Auto-resize textarea as content changes + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto' + textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px' + } + }, [input]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + if (e.ctrlKey) { + e.preventDefault() + handleSend(true) // Send with full context + } else if (!e.shiftKey && !isGenerating) { + e.preventDefault() + handleSend(false) + } + } else if (e.key === "Backspace" && input === "" && contextTabs.length > 0) { + e.preventDefault() + // Remove the last context tab + const lastTab = contextTabs[contextTabs.length - 1] + onRemoveTab(lastTab.id) + } + } + + const handlePaste = async (e: React.ClipboardEvent) => { + // Handle image paste + const items = Array.from(e.clipboardData.items); + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + + const file = item.getAsFile(); + if (!file) continue; + + try { + const reader = new FileReader(); + reader.onload = () => { + const base64String = reader.result as string; + addContextTab( + "image", + `Image ${new Date().toLocaleTimeString()}`, + base64String + ); + }; + reader.readAsDataURL(file); + } catch (error) { + console.error('Error processing pasted image:', error); + } + return; + } + } + + const text = e.clipboardData.getData('text'); + + // Helper function to detect if text looks like code + const looksLikeCode = (text: string): boolean => { + const codeIndicators = [ + /^import\s+/m, // import statements + /^function\s+/m, // function declarations + /^class\s+/m, // class declarations + /^const\s+/m, // const declarations + /^let\s+/m, // let declarations + /^var\s+/m, // var declarations + /[{}\[\]();]/, // common code syntax + /^\s*\/\//m, // comments + /^\s*\/\*/m, // multi-line comments + /=>/, // arrow functions + /^export\s+/m, // export statements + ]; + + return codeIndicators.some(pattern => pattern.test(text)); + }; + + // If text doesn't contain newlines or doesn't look like code, let it paste normally + if (!text || !text.includes('\n') || !looksLikeCode(text)) { + return; + } + + e.preventDefault(); + const editor = editorRef.current; + const currentSelection = editor?.getSelection(); + const lines = text.split('\n'); + + // If selection exists in editor, use file name and line numbers + if (currentSelection && !currentSelection.isEmpty()) { + addContextTab( + "code", + `${activeFileName} (${currentSelection.startLineNumber}-${currentSelection.endLineNumber})`, + text, + { start: currentSelection.startLineNumber, end: currentSelection.endLineNumber } + ); + return; + } + + // If we have stored line range from a copy operation in the editor + if (lastCopiedRangeRef.current) { + const range = lastCopiedRangeRef.current; + addContextTab( + "code", + `${activeFileName} (${range.startLine}-${range.endLine})`, + text, + { start: range.startLine, end: range.endLine } + ); + return; + } + + // For code pasted from outside the editor + addContextTab( + "code", + `Pasted Code (1-${lines.length})`, + text, + { start: 1, end: lines.length } + ); + }; + + const handleImageUpload = () => { + const input = document.createElement('input') + input.type = 'file' + input.accept = 'image/*' + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (file) onImageUpload(file) + } + input.click() + } + + const handleMentionClick = () => { + if (textareaRef.current) { + const cursorPosition = textareaRef.current.selectionStart + const newValue = input.slice(0, cursorPosition) + '@' + input.slice(cursorPosition) + setInput(newValue) + // Focus and move cursor after the @ + textareaRef.current.focus() + const newPosition = cursorPosition + 1 + textareaRef.current.setSelectionRange(newPosition, newPosition) + } + } + + // Handle @ mentions in input + useEffect(() => { + const match = input.match(/@(\w+)$/) + if (match) { + const fileName = match[1] + const allFiles = getAllFiles(files) + const file = allFiles.find(file => file.name === fileName) + if (file) { + onFileMention(file.name) + } + } + }, [input, onFileMention, files]) + + // Add this helper function to flatten the file tree + const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => { + return items.reduce((acc: TFile[], item) => { + if (item.type === "file") { + acc.push(item) + } else { + acc.push(...getAllFiles(item.children)) + } + return acc + }, []) + } + return ( -
- setInput(e.target.value)} - onKeyPress={(e) => e.key === "Enter" && !isGenerating && handleSend()} - className="flex-grow p-2 border rounded-lg min-w-0 bg-input" - placeholder="Type your message..." - disabled={isGenerating} - /> - {isGenerating ? ( - - ) : ( -