Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 54 additions & 27 deletions web/components/project/ai-edit/edit-code-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,78 @@

import { AnimatePresence, motion } from "framer-motion"
import { Sparkles } from "lucide-react"
import { useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { Button } from "../../ui/button"

export interface EditCodeWidgetProps {
isSelected: boolean
showSuggestion: boolean
onAiEdit: () => void
suggestionRef: React.RefObject<HTMLDivElement>
suggestionRef: React.MutableRefObject<HTMLDivElement | null>
}

/**
* Edit Code Widget that appears when text is selected
* Shows an animated "Edit Code" button with AI capabilities
*
* IMPORTANT: The widget container is created imperatively (not via JSX) because
* Monaco moves the DOM node to its overlay. If React managed this node,
* it would crash when trying to unmount a node that's no longer in its expected location.
*/
export default function EditCodeWidget({
isSelected,
showSuggestion,
onAiEdit,
suggestionRef,
}: EditCodeWidgetProps) {
return (
<div ref={suggestionRef} className="relative">
<AnimatePresence>
{isSelected && showSuggestion && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease: "easeOut", duration: 0.2 }}
className="absolute z-50"
const [container, setContainer] = useState<HTMLDivElement | null>(null)

// Create the container div imperatively on mount
useEffect(() => {
const div = document.createElement("div")
div.className = "relative"
suggestionRef.current = div
setContainer(div)

// Cleanup: remove from DOM if it's still attached somewhere
return () => {
suggestionRef.current = null
if (div.parentNode) {
div.parentNode.removeChild(div)
}
}
}, [suggestionRef])

// Don't render anything if container doesn't exist yet
if (!container) return null

// Use portal to render content into the imperatively created container
return createPortal(
<AnimatePresence>
{isSelected && showSuggestion && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease: "easeOut", duration: 0.2 }}
className="absolute z-50"
>
<Button
size="xs"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onAiEdit()
}}
className="shadow-md"
>
<Button
size="xs"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onAiEdit()
}}
className="shadow-md"
>
<Sparkles className="h-3 w-3 mr-1" />
Edit Code
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
<Sparkles className="h-3 w-3 mr-1" />
Edit Code
</Button>
</motion.div>
)}
</AnimatePresence>,
container,
)
}
82 changes: 61 additions & 21 deletions web/components/project/ai-edit/generate-widget.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"use client"

import { processEdit } from "@/app/actions/ai"
import { cn } from "@/lib/utils"
import { useRouter } from "@bprogress/next/app"
import { Editor } from "@monaco-editor/react"
import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"
import { useTheme } from "next-themes"
import { useCallback, useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { toast } from "sonner"
import { Button } from "../../ui/button"

Expand All @@ -27,10 +27,18 @@ interface GenerateInputProps {
onClose: () => void
}
interface GenerateWidgetProps extends GenerateInputProps {
generateRef: React.RefObject<HTMLDivElement>
generateWidgetRef: React.RefObject<HTMLDivElement>
generateRef: React.MutableRefObject<HTMLDivElement | null>
generateWidgetRef: React.MutableRefObject<HTMLDivElement | null>
show: boolean
}

/**
* Generate Widget container
*
* IMPORTANT: The widget containers are created imperatively (not via JSX) because
* Monaco moves the DOM nodes to its overlay. If React managed these nodes,
* it would crash when trying to unmount nodes that are no longer in their expected location.
*/
export function GenerateWidget({
generateRef,
generateWidgetRef,
Expand All @@ -39,21 +47,53 @@ export function GenerateWidget({
projectName,
...inputProps
}: GenerateWidgetProps) {
return (
<>
{/* Generate DOM anchor point */}
<div ref={generateRef} />
{/* Generate Widget */}
<div className={cn(show && "z-50 p-1")} ref={generateWidgetRef}>
{show ? (
<GenerateInput
{...inputProps}
projectId={projectId}
projectName={projectName}
/>
) : null}
</div>
</>
const [containers, setContainers] = useState<{
anchor: HTMLDivElement | null
widget: HTMLDivElement | null
}>({ anchor: null, widget: null })

// Create the container divs imperatively on mount
useEffect(() => {
const anchorDiv = document.createElement("div")
const widgetDiv = document.createElement("div")

generateRef.current = anchorDiv
generateWidgetRef.current = widgetDiv
setContainers({ anchor: anchorDiv, widget: widgetDiv })

// Cleanup: remove from DOM if still attached somewhere
return () => {
generateRef.current = null
generateWidgetRef.current = null
if (anchorDiv.parentNode) {
anchorDiv.parentNode.removeChild(anchorDiv)
}
if (widgetDiv.parentNode) {
widgetDiv.parentNode.removeChild(widgetDiv)
}
}
}, [generateRef, generateWidgetRef])

// Update widget container class when show changes
useEffect(() => {
if (containers.widget) {
containers.widget.className = show ? "z-50 p-1" : ""
}
}, [show, containers.widget])

// Don't render anything if containers don't exist yet
if (!containers.widget) return null

// Use portal to render content into the imperatively created widget container
return createPortal(
show ? (
<GenerateInput
{...inputProps}
projectId={projectId}
projectName={projectName}
/>
) : null,
containers.widget,
)
}

Expand Down Expand Up @@ -106,7 +146,7 @@ function GenerateInput({
fileName: data.fileName,
projectId: projectId,
projectName: projectName,
}
},
)

// Clean up any potential markdown or explanation text
Expand All @@ -120,7 +160,7 @@ function GenerateInput({
router.refresh()
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to generate code"
error instanceof Error ? error.message : "Failed to generate code",
)
} finally {
setLoading({ generate: false, regenerate: false })
Expand All @@ -131,7 +171,7 @@ function GenerateInput({
e.preventDefault()
handleGenerate({ regenerate: false })
},
[input, currentPrompt]
[input, currentPrompt],
)

useEffect(() => {
Expand Down
Loading