feat: introduce apply button functionality (v0.1)
### Summary - Added a new "Apply" button to code snippets provided by the AI assistant. - The button is designed to seamlessly merge the AI-generated snippet into the relevant file in the editor. ### Current Issues 1. **Sticky Accept/Decline Buttons:** These activate for every snippet instead of being limited to the relevant snippet. 2. **Discard Button:** Currently non-functional. 3. **Highlight Inconsistencies:** The green-red code highlights for old and new code are inconsistent. ### To Do - Implement a toast notification when the "Apply" button is pressed on an irrelevant tab to prevent code application errors. ### Workflow Implemented 1. The "Apply" button is added alongside "Copy" and "Reply" for AI-generated code snippets. 2. Upon clicking "Apply," the code snippet and relevant file content (active file) are sent to a secondary model (GPT-4O). 3. The system prompt for GPT-4O instructs it to merge the snippet with the file content: - Ensure the original file functionality remains intact. - Integrate the code snippet seamlessly. 4. The output from GPT-4O is injected directly into the code editor. 5. Changes are visually highlighted: - Green for new code. - Red for removed code. 6. Highlights remain until the user explicitly accepts or discards the changes.
This commit is contained in:
77
frontend/components/editor/AIChat/ApplyButton.tsx
Normal file
77
frontend/components/editor/AIChat/ApplyButton.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { Check, Loader2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "../../ui/button"
|
||||
|
||||
interface ApplyButtonProps {
|
||||
code: string
|
||||
activeFileName: string
|
||||
activeFileContent: string
|
||||
editorRef: { current: any }
|
||||
onApply: (mergedCode: string) => void
|
||||
}
|
||||
|
||||
export default function ApplyButton({
|
||||
code,
|
||||
activeFileName,
|
||||
activeFileContent,
|
||||
editorRef,
|
||||
onApply,
|
||||
}: ApplyButtonProps) {
|
||||
const [isApplying, setIsApplying] = useState(false)
|
||||
|
||||
const handleApply = async () => {
|
||||
setIsApplying(true)
|
||||
try {
|
||||
const response = await fetch("/api/merge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
originalCode: activeFileContent,
|
||||
newCode: String(code),
|
||||
fileName: activeFileName,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text())
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let mergedCode = ""
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
mergedCode += decoder.decode(value, { stream: true })
|
||||
}
|
||||
}
|
||||
onApply(mergedCode.trim())
|
||||
} catch (error) {
|
||||
console.error("Error applying code:", error)
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to apply code changes"
|
||||
)
|
||||
} finally {
|
||||
setIsApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="p-1 h-6"
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
@ -13,6 +13,12 @@ export default function ChatMessage({
|
||||
setContext,
|
||||
setIsContextExpanded,
|
||||
socket,
|
||||
handleApplyCode,
|
||||
activeFileName,
|
||||
activeFileContent,
|
||||
editorRef,
|
||||
mergeDecorationsCollection,
|
||||
setMergeDecorationsCollection,
|
||||
}: MessageProps) {
|
||||
// State for expanded message index
|
||||
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
|
||||
@ -104,7 +110,13 @@ export default function ChatMessage({
|
||||
const components = createMarkdownComponents(
|
||||
renderCopyButton,
|
||||
renderMarkdownElement,
|
||||
askAboutCode
|
||||
askAboutCode,
|
||||
activeFileName,
|
||||
activeFileContent,
|
||||
editorRef,
|
||||
handleApplyCode,
|
||||
mergeDecorationsCollection,
|
||||
setMergeDecorationsCollection
|
||||
)
|
||||
|
||||
return (
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useSocket } from "@/context/SocketContext"
|
||||
import { TFile } from "@/lib/types"
|
||||
import { X, ChevronDown } from "lucide-react"
|
||||
import { ChevronDown, X } from "lucide-react"
|
||||
import { nanoid } from "nanoid"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import LoadingDots from "../../ui/LoadingDots"
|
||||
@ -18,6 +18,9 @@ export default function AIChat({
|
||||
lastCopiedRangeRef,
|
||||
files,
|
||||
templateType,
|
||||
handleApplyCode,
|
||||
mergeDecorationsCollection,
|
||||
setMergeDecorationsCollection,
|
||||
}: AIChatProps) {
|
||||
// Initialize socket and messages
|
||||
const { socket } = useSocket()
|
||||
@ -176,7 +179,7 @@ export default function AIChat({
|
||||
const fileExt = tab.name.split(".").pop() || "txt"
|
||||
return {
|
||||
...tab,
|
||||
content: `\`\`\`${fileExt}\n${activeFileContent}\n\`\`\``
|
||||
content: `\`\`\`${fileExt}\n${activeFileContent}\n\`\`\``,
|
||||
}
|
||||
}
|
||||
return tab
|
||||
@ -214,6 +217,12 @@ export default function AIChat({
|
||||
setContext={setContext}
|
||||
setIsContextExpanded={setIsContextExpanded}
|
||||
socket={socket}
|
||||
handleApplyCode={handleApplyCode}
|
||||
activeFileName={activeFileName}
|
||||
activeFileContent={activeFileContent}
|
||||
editorRef={editorRef}
|
||||
mergeDecorationsCollection={mergeDecorationsCollection}
|
||||
setMergeDecorationsCollection={setMergeDecorationsCollection}
|
||||
/>
|
||||
))}
|
||||
{isLoading && <LoadingDots />}
|
||||
|
@ -1,15 +1,23 @@
|
||||
import { CornerUpLeft } from "lucide-react"
|
||||
import { Check, CornerUpLeft, X } from "lucide-react"
|
||||
import monaco from "monaco-editor"
|
||||
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 ApplyButton from "../ApplyButton"
|
||||
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
|
||||
askAboutCode: (code: any) => void,
|
||||
activeFileName: string,
|
||||
activeFileContent: string,
|
||||
editorRef: any,
|
||||
handleApplyCode: (mergedCode: string) => void,
|
||||
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection,
|
||||
setMergeDecorationsCollection?: (collection: undefined) => void
|
||||
): Components => ({
|
||||
code: ({
|
||||
node,
|
||||
@ -33,6 +41,57 @@ export const createMarkdownComponents = (
|
||||
<div className="flex border border-input shadow-lg bg-background rounded-md">
|
||||
{renderCopyButton(children)}
|
||||
<div className="w-px bg-input"></div>
|
||||
{!mergeDecorationsCollection ? (
|
||||
<ApplyButton
|
||||
code={String(children)}
|
||||
activeFileName={activeFileName}
|
||||
activeFileContent={activeFileContent}
|
||||
editorRef={editorRef}
|
||||
onApply={handleApplyCode}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (
|
||||
setMergeDecorationsCollection &&
|
||||
mergeDecorationsCollection &&
|
||||
editorRef?.current
|
||||
) {
|
||||
mergeDecorationsCollection.clear()
|
||||
setMergeDecorationsCollection(undefined)
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="p-1 h-6"
|
||||
title="Accept Changes"
|
||||
>
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
</Button>
|
||||
<div className="w-px bg-input"></div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (
|
||||
setMergeDecorationsCollection &&
|
||||
mergeDecorationsCollection &&
|
||||
editorRef?.current
|
||||
) {
|
||||
editorRef.current.getModel()?.setValue(activeFileContent)
|
||||
mergeDecorationsCollection.clear()
|
||||
setMergeDecorationsCollection(undefined)
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="p-1 h-6"
|
||||
title="Discard Changes"
|
||||
>
|
||||
<X className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="w-px bg-input"></div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
|
@ -56,6 +56,9 @@ export interface AIChatProps {
|
||||
files: (TFile | TFolder)[]
|
||||
templateType: string
|
||||
templateConfig?: TemplateConfig
|
||||
handleApplyCode: (mergedCode: string) => void
|
||||
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
|
||||
setMergeDecorationsCollection?: (collection: undefined) => void
|
||||
}
|
||||
|
||||
// Chat input props interface
|
||||
@ -105,6 +108,12 @@ export interface MessageProps {
|
||||
) => void
|
||||
setIsContextExpanded: (isExpanded: boolean) => void
|
||||
socket: Socket | null
|
||||
handleApplyCode: (mergedCode: string) => void
|
||||
activeFileName: string
|
||||
activeFileContent: string
|
||||
editorRef: any
|
||||
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
|
||||
setMergeDecorationsCollection?: (collection: undefined) => void
|
||||
}
|
||||
|
||||
// Context tabs props interface
|
||||
|
@ -104,6 +104,13 @@ export default function CodeEditor({
|
||||
// Added this state to track the most recent content for each file
|
||||
const [fileContents, setFileContents] = useState<Record<string, string>>({})
|
||||
|
||||
// Apply Button merger decoration state
|
||||
const [mergeDecorations, setMergeDecorations] = useState<
|
||||
monaco.editor.IModelDeltaDecoration[]
|
||||
>([])
|
||||
const [mergeDecorationsCollection, setMergeDecorationsCollection] =
|
||||
useState<monaco.editor.IEditorDecorationsCollection>()
|
||||
|
||||
// Editor state
|
||||
const [editorLanguage, setEditorLanguage] = useState("plaintext")
|
||||
const [cursorLine, setCursorLine] = useState(0)
|
||||
@ -375,6 +382,49 @@ export default function CodeEditor({
|
||||
})
|
||||
}, [editorRef])
|
||||
|
||||
// handle apply code
|
||||
const handleApplyCode = useCallback(
|
||||
(mergedCode: string) => {
|
||||
if (!editorRef) return
|
||||
|
||||
const originalLines = activeFileContent.split("\n")
|
||||
const mergedLines = mergedCode.split("\n")
|
||||
|
||||
const decorations: monaco.editor.IModelDeltaDecoration[] = []
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < Math.max(originalLines.length, mergedLines.length);
|
||||
i++
|
||||
) {
|
||||
if (originalLines[i] !== mergedLines[i]) {
|
||||
decorations.push({
|
||||
range: new monaco.Range(i + 1, 1, i + 1, 1),
|
||||
options: {
|
||||
isWholeLine: true,
|
||||
className:
|
||||
i < originalLines.length
|
||||
? "removed-line-decoration"
|
||||
: "added-line-decoration",
|
||||
glyphMarginClassName:
|
||||
i < originalLines.length
|
||||
? "removed-line-glyph"
|
||||
: "added-line-glyph",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update editor content
|
||||
editorRef.setValue(mergedCode)
|
||||
|
||||
// Apply decorations
|
||||
const newDecorations = editorRef.createDecorationsCollection(decorations)
|
||||
setMergeDecorationsCollection(newDecorations)
|
||||
},
|
||||
[editorRef, activeFileContent]
|
||||
)
|
||||
|
||||
// Generate widget effect
|
||||
useEffect(() => {
|
||||
if (generate.show) {
|
||||
@ -1234,6 +1284,9 @@ export default function CodeEditor({
|
||||
lastCopiedRangeRef={lastCopiedRangeRef}
|
||||
files={files}
|
||||
templateType={sandboxData.type}
|
||||
handleApplyCode={handleApplyCode}
|
||||
mergeDecorationsCollection={mergeDecorationsCollection}
|
||||
setMergeDecorationsCollection={setMergeDecorationsCollection}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
|
Reference in New Issue
Block a user