feat: multi-file context, context tabs

- added context tabs
- added multifile context including file and image uploads to the context along with all the files from the project
- added file/image previews on input
- added code paste from the editor and file lines recognition
- added image paste from clipboard and preview
This commit is contained in:
Akhileshrangani4 2024-11-04 14:21:13 -05:00
parent 9c6067dcd9
commit c6c01101f1
8 changed files with 414 additions and 346 deletions

View File

@ -1,27 +1,10 @@
import { Send, StopCircle, AtSign, Image as ImageIcon } from "lucide-react" import { Send, StopCircle, Image as ImageIcon, Paperclip } from "lucide-react"
import { Button } from "../../ui/button" import { Button } from "../../ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select" import { useEffect } from "react"
import { useRef, useEffect, useState } from "react"
import * as monaco from 'monaco-editor'
import { TFile, TFolder } from "@/lib/types" import { TFile, TFolder } from "@/lib/types"
import { ALLOWED_FILE_TYPES } from "./types"
interface ChatInputProps { import { looksLikeCode } from "./lib/chatUtils"
input: string import { ChatInputProps } from "./types"
setInput: (input: string) => void
isGenerating: boolean
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<monaco.editor.IStandaloneCodeEditor | undefined>
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<HTMLTextAreaElement>
files: (TFile | TFolder)[]
}
export default function ChatInput({ export default function ChatInput({
input, input,
@ -30,7 +13,6 @@ export default function ChatInput({
handleSend, handleSend,
handleStopGeneration, handleStopGeneration,
onImageUpload, onImageUpload,
onFileMention,
addContextTab, addContextTab,
activeFileName, activeFileName,
editorRef, editorRef,
@ -38,8 +20,8 @@ export default function ChatInput({
contextTabs, contextTabs,
onRemoveTab, onRemoveTab,
textareaRef, textareaRef,
files,
}: ChatInputProps) { }: ChatInputProps) {
// Auto-resize textarea as content changes // Auto-resize textarea as content changes
useEffect(() => { useEffect(() => {
if (textareaRef.current) { if (textareaRef.current) {
@ -48,6 +30,7 @@ export default function ChatInput({
} }
}, [input]) }, [input])
// Handle keyboard events for sending messages
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter") {
if (e.ctrlKey) { if (e.ctrlKey) {
@ -65,6 +48,7 @@ export default function ChatInput({
} }
} }
// Handle paste events for image and code
const handlePaste = async (e: React.ClipboardEvent) => { const handlePaste = async (e: React.ClipboardEvent) => {
// Handle image paste // Handle image paste
const items = Array.from(e.clipboardData.items); const items = Array.from(e.clipboardData.items);
@ -76,12 +60,17 @@ export default function ChatInput({
if (!file) continue; if (!file) continue;
try { try {
// Convert image to base64 string for context tab title and timestamp
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const base64String = reader.result as string; const base64String = reader.result as string;
addContextTab( addContextTab(
"image", "image",
`Image ${new Date().toLocaleTimeString()}`, `Image ${new Date().toLocaleTimeString('en-US', {
hour12: true,
hour: '2-digit',
minute: '2-digit'
}).replace(/(\d{2}):(\d{2})/, '$1:$2')}`,
base64String base64String
); );
}; };
@ -93,27 +82,9 @@ export default function ChatInput({
} }
} }
// Get text from clipboard
const text = e.clipboardData.getData('text'); 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 doesn't contain newlines or doesn't look like code, let it paste normally
if (!text || !text.includes('\n') || !looksLikeCode(text)) { if (!text || !text.includes('\n') || !looksLikeCode(text)) {
return; return;
@ -124,6 +95,8 @@ export default function ChatInput({
const currentSelection = editor?.getSelection(); const currentSelection = editor?.getSelection();
const lines = text.split('\n'); const lines = text.split('\n');
// TODO: FIX THIS: even when i paste the outside code, it shows the active file name,it works when no tabs are open, just does not work when the tab is open
// If selection exists in editor, use file name and line numbers // If selection exists in editor, use file name and line numbers
if (currentSelection && !currentSelection.isEmpty()) { if (currentSelection && !currentSelection.isEmpty()) {
addContextTab( addContextTab(
@ -156,6 +129,7 @@ export default function ChatInput({
); );
}; };
// Handle image upload from local machine via input
const handleImageUpload = () => { const handleImageUpload = () => {
const input = document.createElement('input') const input = document.createElement('input')
input.type = 'file' input.type = 'file'
@ -167,32 +141,7 @@ export default function ChatInput({
input.click() input.click()
} }
const handleMentionClick = () => { // Helper function to flatten the file tree
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[] => { const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
return items.reduce((acc: TFile[], item) => { return items.reduce((acc: TFile[], item) => {
if (item.type === "file") { if (item.type === "file") {
@ -204,6 +153,29 @@ export default function ChatInput({
}, []) }, [])
} }
// Handle file upload from local machine via input
const handleFileUpload = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.txt,.md,.csv,.json,.js,.ts,.html,.css,.pdf'
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
if (!(file.type in ALLOWED_FILE_TYPES)) {
alert('Unsupported file type. Please upload text, code, or PDF files.')
return
}
const reader = new FileReader()
reader.onload = () => {
addContextTab("file", file.name, reader.result as string)
}
reader.readAsText(file)
}
}
input.click()
}
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex space-x-2 min-w-0"> <div className="flex space-x-2 min-w-0">
@ -218,6 +190,7 @@ export default function ChatInput({
disabled={isGenerating} disabled={isGenerating}
rows={1} rows={1}
/> />
{/* Render stop generation button */}
{isGenerating ? ( {isGenerating ? (
<Button <Button
onClick={handleStopGeneration} onClick={handleStopGeneration}
@ -238,28 +211,18 @@ export default function ChatInput({
</Button> </Button>
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-2 w-full"> <div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-2 px-2 text-sm text-muted-foreground min-w-auto max-w-auto"> {/* Render file upload button */}
<Select defaultValue="claude-3.5-sonnet">
<SelectTrigger className="h-6 w-full border-none truncate">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude-3.5-sonnet">claude-3.5-sonnet</SelectItem>
<SelectItem value="claude-3">claude-3</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 px-2 sm:px-3" className="h-6 px-2 sm:px-3"
onClick={handleMentionClick} onClick={handleFileUpload}
> >
<AtSign className="h-3 w-3 sm:mr-1" /> <Paperclip className="h-3 w-3 sm:mr-1" />
<span className="hidden sm:inline">mention</span> <span className="hidden sm:inline">File</span>
</Button> </Button>
{/* Render image upload button */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -271,7 +234,6 @@ export default function ChatInput({
</Button> </Button>
</div> </div>
</div> </div>
</div>
) )
} }

View File

@ -1,24 +1,12 @@
import { Check, ChevronDown, ChevronUp, Copy, CornerUpLeft } from "lucide-react" import { Check, Copy, CornerUpLeft } from "lucide-react"
import React, { useState } from "react" import React, { useState } from "react"
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
import remarkGfm from "remark-gfm" import remarkGfm from "remark-gfm"
import { Button } from "../../ui/button" import { Button } from "../../ui/button"
import { copyToClipboard, stringifyContent } from "./lib/chatUtils" import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
import ContextTabs from "./ContextTabs" import ContextTabs from "./ContextTabs"
import { Socket } from "socket.io-client" import { createMarkdownComponents } from './lib/markdownComponents'
import { MessageProps } from "./types"
interface MessageProps {
message: {
role: "user" | "assistant"
content: string
context?: string
}
setContext: (context: string | null) => void
setIsContextExpanded: (isExpanded: boolean) => void
socket: Socket | null
}
export default function ChatMessage({ export default function ChatMessage({
message, message,
@ -26,11 +14,16 @@ export default function ChatMessage({
setIsContextExpanded, setIsContextExpanded,
socket, socket,
}: MessageProps) { }: MessageProps) {
// State for expanded message index
const [expandedMessageIndex, setExpandedMessageIndex] = useState< const [expandedMessageIndex, setExpandedMessageIndex] = useState<
number | null number | null
>(null) >(null)
// State for copied text
const [copiedText, setCopiedText] = useState<string | null>(null) const [copiedText, setCopiedText] = useState<string | null>(null)
// Render copy button for text content
const renderCopyButton = (text: any) => ( const renderCopyButton = (text: any) => (
<Button <Button
onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)} onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)}
@ -46,12 +39,36 @@ export default function ChatMessage({
</Button> </Button>
) )
// Set context for code when asking about code
const askAboutCode = (code: any) => { const askAboutCode = (code: any) => {
const contextString = stringifyContent(code) const contextString = stringifyContent(code)
setContext(`Regarding this code:\n${contextString}`) const newContext = `Regarding this code:\n${contextString}`
// Format timestamp to match chat message format (HH:MM PM)
const timestamp = new Date().toLocaleTimeString('en-US', {
hour12: true,
hour: '2-digit',
minute: '2-digit',
})
// Instead of replacing context, append to it
if (message.role === "assistant") {
// For assistant messages, create a new context tab with the response content and timestamp
setContext(newContext, `AI Response (${timestamp})`, {
start: 1,
end: contextString.split('\n').length
})
} else {
// For user messages, create a new context tab with the selected content and timestamp
setContext(newContext, `User Chat (${timestamp})`, {
start: 1,
end: contextString.split('\n').length
})
}
setIsContextExpanded(false) setIsContextExpanded(false)
} }
// Render markdown elements for code and text
const renderMarkdownElement = (props: any) => { const renderMarkdownElement = (props: any) => {
const { node, children } = props const { node, children } = props
const content = stringifyContent(children) const content = stringifyContent(children)
@ -69,6 +86,7 @@ export default function ChatMessage({
<CornerUpLeft className="w-4 h-4" /> <CornerUpLeft className="w-4 h-4" />
</Button> </Button>
</div> </div>
{/* Render markdown element */}
{React.createElement( {React.createElement(
node.tagName, node.tagName,
{ {
@ -83,6 +101,13 @@ export default function ChatMessage({
) )
} }
// Create markdown components
const components = createMarkdownComponents(
renderCopyButton,
renderMarkdownElement,
askAboutCode
)
return ( return (
<div className="text-left relative"> <div className="text-left relative">
<div <div
@ -92,6 +117,7 @@ export default function ChatMessage({
: "bg-transparent text-white" : "bg-transparent text-white"
} max-w-full`} } max-w-full`}
> >
{/* Render context tabs */}
{message.role === "user" && message.context && ( {message.role === "user" && message.context && (
<div className="mb-2 bg-input rounded-lg"> <div className="mb-2 bg-input rounded-lg">
<ContextTabs <ContextTabs
@ -111,6 +137,7 @@ export default function ChatMessage({
message.context.replace(/^Regarding this code:\n/, "") message.context.replace(/^Regarding this code:\n/, "")
)} )}
</div> </div>
{/* Render code textarea */}
{(() => { {(() => {
const code = message.context.replace( const code = message.context.replace(
/^Regarding this code:\n/, /^Regarding this code:\n/,
@ -124,7 +151,10 @@ export default function ChatMessage({
value={code} value={code}
onChange={(e) => { onChange={(e) => {
const updatedContext = `Regarding this code:\n${e.target.value}` const updatedContext = `Regarding this code:\n${e.target.value}`
setContext(updatedContext) setContext(updatedContext, "Selected Content", {
start: 1,
end: e.target.value.split('\n').length
})
}} }}
className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded" className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded"
rows={code.split("\n").length} rows={code.split("\n").length}
@ -141,8 +171,9 @@ export default function ChatMessage({
)} )}
</div> </div>
)} )}
{/* Render copy and ask about code buttons */}
{message.role === "user" && ( {message.role === "user" && (
<div className="absolute top-0 right-0 flex opacity-0 group-hover:opacity-30 transition-opacity"> <div className="absolute top-0 right-0 p-1 flex opacity-40">
{renderCopyButton(message.content)} {renderCopyButton(message.content)}
<Button <Button
onClick={() => askAboutCode(message.content)} onClick={() => askAboutCode(message.content)}
@ -154,67 +185,11 @@ export default function ChatMessage({
</Button> </Button>
</div> </div>
)} )}
{/* Render markdown content */}
{message.role === "assistant" ? ( {message.role === "assistant" ? (
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={components}
code({ node, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "")
return match ? (
<div className="relative border border-input rounded-md my-4">
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl">
{match[1]}
</div>
<div className="absolute top-0 right-0 flex">
{renderCopyButton(children)}
<Button
onClick={() => askAboutCode(children)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
<div className="pt-6">
<SyntaxHighlighter
style={vscDarkPlus as any}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
padding: "0.5rem",
fontSize: "0.875rem",
}}
>
{stringifyContent(children)}
</SyntaxHighlighter>
</div>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
)
},
p: renderMarkdownElement,
h1: renderMarkdownElement,
h2: renderMarkdownElement,
h3: renderMarkdownElement,
h4: renderMarkdownElement,
h5: renderMarkdownElement,
h6: renderMarkdownElement,
ul: (props) => (
<ul className="list-disc pl-6 mb-4 space-y-2">
{props.children}
</ul>
),
ol: (props) => (
<ol className="list-decimal pl-6 mb-4 space-y-2">
{props.children}
</ol>
),
}}
> >
{message.content} {message.content}
</ReactMarkdown> </ReactMarkdown>
@ -226,6 +201,7 @@ export default function ChatMessage({
) )
} }
// Parse context to tabs for context tabs component
function parseContextToTabs(context: string) { function parseContextToTabs(context: string) {
const sections = context.split(/(?=File |Code from )/) const sections = context.split(/(?=File |Code from )/)
return sections.map((section, index) => { return sections.map((section, index) => {
@ -236,6 +212,7 @@ function parseContextToTabs(context: string) {
// Remove code block markers for display // Remove code block markers for display
content = content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '') content = content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '')
// Determine if the context is a file or code
const isFile = titleLine.startsWith('File ') const isFile = titleLine.startsWith('File ')
const name = titleLine.replace(/^(File |Code from )/, '').replace(':', '') const name = titleLine.replace(/^(File |Code from )/, '').replace(':', '')

View File

@ -1,60 +0,0 @@
import { ChevronDown, ChevronUp, X } from "lucide-react"
interface ContextDisplayProps {
context: string | null
isContextExpanded: boolean
setIsContextExpanded: (isExpanded: boolean) => void
setContext: (context: string | null) => void
}
export default function ContextDisplay({
context,
isContextExpanded,
setIsContextExpanded,
setContext,
}: ContextDisplayProps) {
if (!context) return null
return (
<div className="mb-2 bg-input p-2 rounded-lg">
<div className="flex justify-between items-center">
<div
className="flex-grow cursor-pointer"
onClick={() => setIsContextExpanded(!isContextExpanded)}
>
<span className="text-sm text-gray-300">Context</span>
</div>
<div className="flex items-center">
{isContextExpanded ? (
<ChevronUp
size={16}
className="cursor-pointer"
onClick={() => setIsContextExpanded(false)}
/>
) : (
<ChevronDown
size={16}
className="cursor-pointer"
onClick={() => setIsContextExpanded(true)}
/>
)}
<X
size={16}
className="ml-2 cursor-pointer text-gray-400 hover:text-gray-200"
onClick={() => setContext(null)}
/>
</div>
</div>
{isContextExpanded && (
<textarea
value={context.replace(/^Regarding this code:\n/, "")}
onChange={(e) =>
setContext(`Regarding this code:\n${e.target.value}`)
}
className="w-full mt-2 p-2 bg-#1e1e1e text-white rounded"
rows={5}
/>
)}
</div>
)
}

View File

@ -1,4 +1,4 @@
import { Plus, X, ChevronDown, ChevronUp, Image as ImageIcon, FileText } from "lucide-react" import { Plus, X, Image as ImageIcon, FileText } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { Button } from "../../ui/button" import { Button } from "../../ui/button"
import { TFile, TFolder } from "@/lib/types" import { TFile, TFolder } from "@/lib/types"
@ -8,44 +8,28 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Socket } from "socket.io-client" import { ContextTab } from "./types"
import { ContextTabsProps } from "./types"
interface ContextTab {
id: string
type: "file" | "code" | "image"
name: string
content: string
sourceFile?: string
lineRange?: {
start: number
end: number
}
}
interface ContextTabsProps {
activeFileName: string
onAddFile: () => void
contextTabs: ContextTab[]
onRemoveTab: (id: string) => void
isExpanded: boolean
onToggleExpand: () => void
files?: (TFile | TFolder)[]
onFileSelect?: (file: TFile) => void
socket: Socket | null
}
export default function ContextTabs({ export default function ContextTabs({
onAddFile,
contextTabs, contextTabs,
onRemoveTab, onRemoveTab,
className, className,
files = [], files = [],
onFileSelect, onFileSelect,
}: ContextTabsProps & { className?: string }) { }: ContextTabsProps & { className?: string }) {
// State for preview tab
const [previewTab, setPreviewTab] = useState<ContextTab | null>(null) const [previewTab, setPreviewTab] = useState<ContextTab | null>(null)
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
// Allow preview for images and code selections from editor
const togglePreview = (tab: ContextTab) => { const togglePreview = (tab: ContextTab) => {
if (!tab.lineRange && tab.type !== "image") {
return;
}
// Toggle preview for images and code selections from editor
if (previewTab?.id === tab.id) { if (previewTab?.id === tab.id) {
setPreviewTab(null) setPreviewTab(null)
} else { } else {
@ -53,6 +37,7 @@ export default function ContextTabs({
} }
} }
// Remove tab from context when clicking on X
const handleRemoveTab = (id: string) => { const handleRemoveTab = (id: string) => {
if (previewTab?.id === id) { if (previewTab?.id === id) {
setPreviewTab(null) setPreviewTab(null)
@ -60,6 +45,7 @@ export default function ContextTabs({
onRemoveTab(id) onRemoveTab(id)
} }
// Get all files from the file tree to search for context
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => { const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
return items.reduce((acc: TFile[], item) => { return items.reduce((acc: TFile[], item) => {
if (item.type === "file") { if (item.type === "file") {
@ -71,6 +57,7 @@ export default function ContextTabs({
}, []) }, [])
} }
// Get all files from the file tree to search for context when adding context
const allFiles = getAllFiles(files) const allFiles = getAllFiles(files)
const filteredFiles = allFiles.filter(file => const filteredFiles = allFiles.filter(file =>
file.name.toLowerCase().includes(searchQuery.toLowerCase()) file.name.toLowerCase().includes(searchQuery.toLowerCase())
@ -80,6 +67,7 @@ export default function ContextTabs({
<div className={`border-none ${className || ''}`}> <div className={`border-none ${className || ''}`}>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-1 overflow-hidden mb-2 flex-wrap"> <div className="flex items-center gap-1 overflow-hidden mb-2 flex-wrap">
{/* Add context tab button */}
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@ -90,13 +78,16 @@ export default function ContextTabs({
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
{/* Add context tab popover */}
<PopoverContent className="w-64 p-2"> <PopoverContent className="w-64 p-2">
<div className="flex gap-2 mb-2">
<Input <Input
placeholder="Search files..." placeholder="Search files..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="mb-2" className="flex-1"
/> />
</div>
<div className="max-h-[200px] overflow-y-auto"> <div className="max-h-[200px] overflow-y-auto">
{filteredFiles.map((file) => ( {filteredFiles.map((file) => (
<Button <Button
@ -112,11 +103,13 @@ export default function ContextTabs({
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{/* Add context tab button */}
{contextTabs.length === 0 && ( {contextTabs.length === 0 && (
<div className="flex items-center gap-1 px-2 rounded"> <div className="flex items-center gap-1 px-2 rounded">
<span className="text-sm text-muted-foreground">Add Context</span> <span className="text-sm text-muted-foreground">Add Context</span>
</div> </div>
)} )}
{/* Render context tabs */}
{contextTabs.map((tab) => ( {contextTabs.map((tab) => (
<div <div
key={tab.id} key={tab.id}
@ -143,18 +136,24 @@ export default function ContextTabs({
{/* Preview Section */} {/* Preview Section */}
{previewTab && ( {previewTab && (
<div className="p-2 bg-input rounded-md max-h-[200px] overflow-auto mb-2"> <div className="p-2 bg-input rounded-md max-h-[200px] overflow-auto mb-2">
{previewTab.lineRange && (
<div className="text-xs text-muted-foreground mt-1">
Lines {previewTab.lineRange.start}-{previewTab.lineRange.end}
</div>
)}
{previewTab.type === "image" ? ( {previewTab.type === "image" ? (
<img <img
src={previewTab.content} src={previewTab.content}
alt={previewTab.name} alt={previewTab.name}
className="max-w-full h-auto" className="max-w-full h-auto"
/> />
) : ( ) : previewTab.lineRange && (
<>
<div className="text-xs text-muted-foreground mt-1">
Lines {previewTab.lineRange.start}-{previewTab.lineRange.end}
</div>
<pre className="text-xs font-mono whitespace-pre-wrap">
{previewTab.content}
</pre>
</>
)}
{/* Render file context tab */}
{previewTab.type === "file" && (
<pre className="text-xs font-mono whitespace-pre-wrap"> <pre className="text-xs font-mono whitespace-pre-wrap">
{previewTab.content} {previewTab.content}
</pre> </pre>

View File

@ -6,32 +6,9 @@ import ChatMessage from "./ChatMessage"
import ContextTabs from "./ContextTabs" import ContextTabs from "./ContextTabs"
import { handleSend, handleStopGeneration } from "./lib/chatUtils" import { handleSend, handleStopGeneration } from "./lib/chatUtils"
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import * as monaco from 'monaco-editor' import { TFile } from "@/lib/types"
import { TFile, TFolder } from "@/lib/types"
import { useSocket } from "@/context/SocketContext" import { useSocket } from "@/context/SocketContext"
import { Message, ContextTab, AIChatProps } from './types'
interface Message {
role: "user" | "assistant"
content: string
context?: string
}
interface ContextTab {
id: string
type: "file" | "code" | "image"
name: string
content: string
lineRange?: { start: number; end: number }
}
interface AIChatProps {
activeFileContent: string
activeFileName: string
onClose: () => void
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null>
files: (TFile | TFolder)[]
}
export default function AIChat({ export default function AIChat({
activeFileContent, activeFileContent,
@ -41,21 +18,32 @@ export default function AIChat({
lastCopiedRangeRef, lastCopiedRangeRef,
files, files,
}: AIChatProps) { }: AIChatProps) {
// Initialize socket and messages
const { socket } = useSocket() const { socket } = useSocket()
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
// Initialize input and state for generating messages
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
// Initialize chat container ref and abort controller ref
const chatContainerRef = useRef<HTMLDivElement>(null) const chatContainerRef = useRef<HTMLDivElement>(null)
const abortControllerRef = useRef<AbortController | null>(null) const abortControllerRef = useRef<AbortController | null>(null)
// Initialize context tabs and state for expanding context
const [contextTabs, setContextTabs] = useState<ContextTab[]>([]) const [contextTabs, setContextTabs] = useState<ContextTab[]>([])
const [isContextExpanded, setIsContextExpanded] = useState(false) const [isContextExpanded, setIsContextExpanded] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
// Initialize textarea ref
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
// Scroll to bottom of chat when messages change
useEffect(() => { useEffect(() => {
scrollToBottom() scrollToBottom()
}, [messages]) }, [messages])
// Scroll to bottom of chat when messages change
const scrollToBottom = () => { const scrollToBottom = () => {
if (chatContainerRef.current) { if (chatContainerRef.current) {
setTimeout(() => { setTimeout(() => {
@ -67,6 +55,7 @@ export default function AIChat({
} }
} }
// Add context tab to context tabs
const addContextTab = (type: string, name: string, content: string, lineRange?: { start: number; end: number }) => { const addContextTab = (type: string, name: string, content: string, lineRange?: { start: number; end: number }) => {
const newTab = { const newTab = {
id: nanoid(), id: nanoid(),
@ -78,19 +67,22 @@ export default function AIChat({
setContextTabs(prev => [...prev, newTab]) setContextTabs(prev => [...prev, newTab])
} }
// Remove context tab from context tabs
const removeContextTab = (id: string) => { const removeContextTab = (id: string) => {
setContextTabs(prev => prev.filter(tab => tab.id !== id)) setContextTabs(prev => prev.filter(tab => tab.id !== id))
} }
const handleAddFile = () => { // Add file to context tabs
console.log("Add file to context") const handleAddFile = (tab: ContextTab) => {
setContextTabs(prev => [...prev, tab])
} }
// Format code content to remove starting and ending code block markers if they exist
const formatCodeContent = (content: string) => { const formatCodeContent = (content: string) => {
// Remove starting and ending code block markers if they exist
return content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '') return content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '')
} }
// Get combined context from context tabs
const getCombinedContext = () => { const getCombinedContext = () => {
if (contextTabs.length === 0) return '' if (contextTabs.length === 0) return ''
@ -107,6 +99,7 @@ export default function AIChat({
}).join('\n\n') }).join('\n\n')
} }
// Handle sending message with context
const handleSendWithContext = () => { const handleSendWithContext = () => {
const combinedContext = getCombinedContext() const combinedContext = getCombinedContext()
handleSend( handleSend(
@ -125,50 +118,20 @@ export default function AIChat({
setContextTabs([]) setContextTabs([])
} }
function setContext(context: string | null, fileName?: string, lineRange?: { start: number; end: number }): void { // Set context for the chat
const setContext = (
context: string | null,
name: string,
range?: { start: number, end: number }
) => {
if (!context) { if (!context) {
setContextTabs([]) setContextTabs([])
return return
} }
const existingCodeTab = contextTabs.find(tab => tab.type === 'code') // Always add a new tab instead of updating existing ones
addContextTab('code', name, context, range)
if (existingCodeTab) {
setContextTabs(prev =>
prev.map(tab =>
tab.id === existingCodeTab.id
? { ...tab, content: context, name: fileName || 'Code Context', lineRange }
: tab
)
)
} else {
addContextTab('code', fileName || 'Chat Context', context, lineRange)
} }
}
useEffect(() => {
if (editorRef?.current) {
const editor = editorRef.current;
// Configure editor options for better copy handling
editor.updateOptions({
copyWithSyntaxHighlighting: true,
emptySelectionClipboard: false
});
// Track selection changes
const disposable = editor.onDidChangeCursorSelection((e) => {
if (!e.selection.isEmpty()) {
lastCopiedRangeRef.current = {
startLine: e.selection.startLineNumber,
endLine: e.selection.endLineNumber
};
}
});
return () => disposable.dispose();
}
}, [editorRef?.current]);
return ( return (
<div className="flex flex-col h-screen w-full"> <div className="flex flex-col h-screen w-full">
@ -193,6 +156,7 @@ export default function AIChat({
className="flex-grow overflow-y-auto p-4 space-y-4" className="flex-grow overflow-y-auto p-4 space-y-4"
> >
{messages.map((message, messageIndex) => ( {messages.map((message, messageIndex) => (
// Render chat message component for each message
<ChatMessage <ChatMessage
key={messageIndex} key={messageIndex}
message={message} message={message}
@ -204,6 +168,7 @@ export default function AIChat({
{isLoading && <LoadingDots />} {isLoading && <LoadingDots />}
</div> </div>
<div className="p-4 border-t mb-14"> <div className="p-4 border-t mb-14">
{/* Render context tabs component */}
<ContextTabs <ContextTabs
activeFileName={activeFileName} activeFileName={activeFileName}
onAddFile={handleAddFile} onAddFile={handleAddFile}
@ -224,9 +189,9 @@ export default function AIChat({
}) })
}} }}
/> />
{/* Render chat input component */}
<ChatInput <ChatInput
textareaRef={textareaRef} textareaRef={textareaRef}
files={[]}
addContextTab={addContextTab} addContextTab={addContextTab}
editorRef={editorRef} editorRef={editorRef}
input={input} input={input}
@ -243,13 +208,11 @@ export default function AIChat({
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
}} }}
onFileMention={(fileName) => {
}}
lastCopiedRangeRef={lastCopiedRangeRef} lastCopiedRangeRef={lastCopiedRangeRef}
activeFileName={activeFileName} activeFileName={activeFileName}
contextTabs={contextTabs.map(tab => ({ contextTabs={contextTabs.map(tab => ({
...tab, ...tab,
title: tab.id // Add missing title property title: tab.id
}))} }))}
onRemoveTab={removeContextTab} onRemoveTab={removeContextTab}
/> />

View File

@ -1,30 +1,39 @@
import React from "react" import React from "react"
// Stringify content for chat message component
export const stringifyContent = ( export const stringifyContent = (
content: any, content: any,
seen = new WeakSet() seen = new WeakSet()
): string => { ): string => {
// Stringify content if it's a string
if (typeof content === "string") { if (typeof content === "string") {
return content return content
} }
// Stringify content if it's null
if (content === null) { if (content === null) {
return "null" return "null"
} }
// Stringify content if it's undefined
if (content === undefined) { if (content === undefined) {
return "undefined" return "undefined"
} }
// Stringify content if it's a number or boolean
if (typeof content === "number" || typeof content === "boolean") { if (typeof content === "number" || typeof content === "boolean") {
return content.toString() return content.toString()
} }
// Stringify content if it's a function
if (typeof content === "function") { if (typeof content === "function") {
return content.toString() return content.toString()
} }
// Stringify content if it's a symbol
if (typeof content === "symbol") { if (typeof content === "symbol") {
return content.toString() return content.toString()
} }
// Stringify content if it's a bigint
if (typeof content === "bigint") { if (typeof content === "bigint") {
return content.toString() + "n" return content.toString() + "n"
} }
// Stringify content if it's a valid React element
if (React.isValidElement(content)) { if (React.isValidElement(content)) {
return React.Children.toArray( return React.Children.toArray(
(content as React.ReactElement).props.children (content as React.ReactElement).props.children
@ -32,11 +41,13 @@ export const stringifyContent = (
.map((child) => stringifyContent(child, seen)) .map((child) => stringifyContent(child, seen))
.join("") .join("")
} }
// Stringify content if it's an array
if (Array.isArray(content)) { if (Array.isArray(content)) {
return ( return (
"[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]" "[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]"
) )
} }
// Stringify content if it's an object
if (typeof content === "object") { if (typeof content === "object") {
if (seen.has(content)) { if (seen.has(content)) {
return "[Circular]" return "[Circular]"
@ -51,19 +62,23 @@ export const stringifyContent = (
return Object.prototype.toString.call(content) return Object.prototype.toString.call(content)
} }
} }
// Stringify content if it's a primitive value
return String(content) return String(content)
} }
// Copy to clipboard for chat message component
export const copyToClipboard = ( export const copyToClipboard = (
text: string, text: string,
setCopiedText: (text: string | null) => void setCopiedText: (text: string | null) => void
) => { ) => {
// Copy text to clipboard for chat message component
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
setCopiedText(text) setCopiedText(text)
setTimeout(() => setCopiedText(null), 2000) setTimeout(() => setCopiedText(null), 2000)
}) })
} }
// Handle send for chat message component
export const handleSend = async ( export const handleSend = async (
input: string, input: string,
context: string | null, context: string | null,
@ -76,14 +91,26 @@ export const handleSend = async (
abortControllerRef: React.MutableRefObject<AbortController | null>, abortControllerRef: React.MutableRefObject<AbortController | null>,
activeFileContent: string activeFileContent: string
) => { ) => {
// Return if input is empty and context is null
if (input.trim() === "" && !context) return if (input.trim() === "" && !context) return
const newMessage = { // Get timestamp for chat message component
const timestamp = new Date().toLocaleTimeString('en-US', {
hour12: true,
hour: '2-digit',
minute: '2-digit'
}).replace(/(\d{2}):(\d{2})/, '$1:$2')
// Create user message for chat message component
const userMessage = {
role: "user" as const, role: "user" as const,
content: input, content: input,
context: context || undefined, context: context || undefined,
timestamp: timestamp
} }
const updatedMessages = [...messages, newMessage]
// Update messages for chat message component
const updatedMessages = [...messages, userMessage]
setMessages(updatedMessages) setMessages(updatedMessages)
setInput("") setInput("")
setIsContextExpanded(false) setIsContextExpanded(false)
@ -93,11 +120,13 @@ export const handleSend = async (
abortControllerRef.current = new AbortController() abortControllerRef.current = new AbortController()
try { try {
// Create anthropic messages for chat message component
const anthropicMessages = updatedMessages.map((msg) => ({ const anthropicMessages = updatedMessages.map((msg) => ({
role: msg.role === "user" ? "human" : "assistant", role: msg.role === "user" ? "human" : "assistant",
content: msg.content, content: msg.content,
})) }))
// Fetch AI response for chat message component
const response = await fetch( const response = await fetch(
`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`, `${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`,
{ {
@ -114,20 +143,24 @@ export const handleSend = async (
} }
) )
// Throw error if response is not ok
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to get AI response") throw new Error("Failed to get AI response")
} }
// Get reader for chat message component
const reader = response.body?.getReader() const reader = response.body?.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
const assistantMessage = { role: "assistant" as const, content: "" } const assistantMessage = { role: "assistant" as const, content: "" }
setMessages([...updatedMessages, assistantMessage]) setMessages([...updatedMessages, assistantMessage])
setIsLoading(false) setIsLoading(false)
// Initialize buffer for chat message component
let buffer = "" let buffer = ""
const updateInterval = 100 const updateInterval = 100
let lastUpdateTime = Date.now() let lastUpdateTime = Date.now()
// Read response from reader for chat message component
if (reader) { if (reader) {
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
@ -146,6 +179,7 @@ export const handleSend = async (
} }
} }
// Update messages for chat message component
setMessages((prev) => { setMessages((prev) => {
const updatedMessages = [...prev] const updatedMessages = [...prev]
const lastMessage = updatedMessages[updatedMessages.length - 1] const lastMessage = updatedMessages[updatedMessages.length - 1]
@ -154,6 +188,7 @@ export const handleSend = async (
}) })
} }
} catch (error: any) { } catch (error: any) {
// Handle abort error for chat message component
if (error.name === "AbortError") { if (error.name === "AbortError") {
console.log("Generation aborted") console.log("Generation aborted")
} else { } else {
@ -171,6 +206,7 @@ export const handleSend = async (
} }
} }
// Handle stop generation for chat message component
export const handleStopGeneration = ( export const handleStopGeneration = (
abortControllerRef: React.MutableRefObject<AbortController | null> abortControllerRef: React.MutableRefObject<AbortController | null>
) => { ) => {
@ -178,3 +214,22 @@ export const handleStopGeneration = (
abortControllerRef.current.abort() abortControllerRef.current.abort()
} }
} }
// Check if text looks like code for chat message component
export 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));
};

View File

@ -0,0 +1,79 @@
import { Components } from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
import { Button } from "../../../ui/button"
import { CornerUpLeft } from "lucide-react"
import { stringifyContent } from "./chatUtils"
// Create markdown components for chat message component
export const createMarkdownComponents = (
renderCopyButton: (text: any) => JSX.Element,
renderMarkdownElement: (props: any) => JSX.Element,
askAboutCode: (code: any) => void
): Components => ({
code: ({ node, className, children, ...props }: {
node?: import('hast').Element,
className?: string,
children?: React.ReactNode,
[key: string]: any,
}) => {
const match = /language-(\w+)/.exec(className || "")
return match ? (
<div className="relative border border-input rounded-md my-4">
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl">
{match[1]}
</div>
<div className="absolute top-0 right-0 flex">
{renderCopyButton(children)}
<Button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
askAboutCode(children)
}}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
<div className="pt-6">
<SyntaxHighlighter
style={vscDarkPlus as any}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
padding: "0.5rem",
fontSize: "0.875rem",
}}
>
{stringifyContent(children)}
</SyntaxHighlighter>
</div>
</div>
) : (
<code className={className} {...props}>{children}</code>
)
},
// Render markdown elements
p: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h1: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h2: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h3: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h4: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h5: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
h6: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
ul: (props) => (
<ul className="list-disc pl-6 mb-4 space-y-2">
{props.children}
</ul>
),
ol: (props) => (
<ol className="list-decimal pl-6 mb-4 space-y-2">
{props.children}
</ol>
),
})

View File

@ -0,0 +1,93 @@
import * as monaco from 'monaco-editor'
import { TFile, TFolder } from "@/lib/types"
import { Socket } from 'socket.io-client';
// Allowed file types for context tabs
export const ALLOWED_FILE_TYPES = {
// Text files
'text/plain': true,
'text/markdown': true,
'text/csv': true,
// Code files
'application/json': true,
'text/javascript': true,
'text/typescript': true,
'text/html': true,
'text/css': true,
// Documents
'application/pdf': true,
// Images
'image/jpeg': true,
'image/png': true,
'image/gif': true,
'image/webp': true,
'image/svg+xml': true,
} as const;
// Message interface
export interface Message {
role: "user" | "assistant"
content: string
context?: string
}
// Context tab interface
export interface ContextTab {
id: string
type: "file" | "code" | "image"
name: string
content: string
lineRange?: { start: number; end: number }
}
// AIChat props interface
export interface AIChatProps {
activeFileContent: string
activeFileName: string
onClose: () => void
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null>
files: (TFile | TFolder)[]
}
// Chat input props interface
export interface ChatInputProps {
input: string
setInput: (input: string) => void
isGenerating: boolean
handleSend: (useFullContext?: boolean) => void
handleStopGeneration: () => void
onImageUpload: (file: File) => void
addContextTab: (type: string, title: string, content: string, lineRange?: { start: number, end: number }) => void
activeFileName?: string
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
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<HTMLTextAreaElement>
}
// Chat message props interface
export interface MessageProps {
message: {
role: "user" | "assistant"
content: string
context?: string
}
setContext: (context: string | null, name: string, range?: { start: number, end: number }) => void
setIsContextExpanded: (isExpanded: boolean) => void
socket: Socket | null
}
// Context tabs props interface
export interface ContextTabsProps {
activeFileName: string
onAddFile: (tab: ContextTab) => void
contextTabs: ContextTab[]
onRemoveTab: (id: string) => void
isExpanded: boolean
onToggleExpand: () => void
files?: (TFile | TFolder)[]
onFileSelect?: (file: TFile) => void
socket: Socket | null
}