diff --git a/web/components/project/ai-edit/edit-code-widget.tsx b/web/components/project/ai-edit/edit-code-widget.tsx index 51344361..cfd615d2 100644 --- a/web/components/project/ai-edit/edit-code-widget.tsx +++ b/web/components/project/ai-edit/edit-code-widget.tsx @@ -2,18 +2,24 @@ 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 + suggestionRef: React.MutableRefObject } /** * 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, @@ -21,32 +27,53 @@ export default function EditCodeWidget({ onAiEdit, suggestionRef, }: EditCodeWidgetProps) { - return ( -
- - {isSelected && showSuggestion && ( - (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( + + {isSelected && showSuggestion && ( + + - - )} - -
+ + Edit Code + + + )} + , + container, ) } diff --git a/web/components/project/ai-edit/generate-widget.tsx b/web/components/project/ai-edit/generate-widget.tsx index 9b71558d..258cc0b4 100644 --- a/web/components/project/ai-edit/generate-widget.tsx +++ b/web/components/project/ai-edit/generate-widget.tsx @@ -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" @@ -27,10 +27,18 @@ interface GenerateInputProps { onClose: () => void } interface GenerateWidgetProps extends GenerateInputProps { - generateRef: React.RefObject - generateWidgetRef: React.RefObject + generateRef: React.MutableRefObject + generateWidgetRef: React.MutableRefObject 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, @@ -39,21 +47,53 @@ export function GenerateWidget({ projectName, ...inputProps }: GenerateWidgetProps) { - return ( - <> - {/* Generate DOM anchor point */} -
- {/* Generate Widget */} -
- {show ? ( - - ) : null} -
- + 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 ? ( + + ) : null, + containers.widget, ) } @@ -106,7 +146,7 @@ function GenerateInput({ fileName: data.fileName, projectId: projectId, projectName: projectName, - } + }, ) // Clean up any potential markdown or explanation text @@ -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 }) @@ -131,7 +171,7 @@ function GenerateInput({ e.preventDefault() handleGenerate({ regenerate: false }) }, - [input, currentPrompt] + [input, currentPrompt], ) useEffect(() => { diff --git a/web/components/project/hooks/useMonacoEditor.ts b/web/components/project/hooks/useMonacoEditor.ts index eb96d3aa..38144f0c 100644 --- a/web/components/project/hooks/useMonacoEditor.ts +++ b/web/components/project/hooks/useMonacoEditor.ts @@ -59,6 +59,7 @@ export interface UseMonacoEditorReturn { handleEditorWillMount: BeforeMount handleEditorMount: OnMount handleAiEdit: (editor?: monaco.editor.ICodeEditor) => void + cleanupWidgets: () => void // Internal setters setEditorRef: (editor: monaco.editor.IStandaloneCodeEditor) => void @@ -101,9 +102,10 @@ export const useMonacoEditor = ({ // Refs const monacoRef = useRef(null) - const generateRef = useRef(null) - const suggestionRef = useRef(null) - const generateWidgetRef = useRef(null) + const generateRef = useRef(null) + const suggestionRef = useRef(null) + const generateWidgetRef = useRef(null) + const suggestionWidgetRef = useRef(null) const lastCopiedRangeRef = useRef<{ startLine: number endLine: number @@ -113,7 +115,7 @@ export const useMonacoEditor = ({ const debouncedSetIsSelected = useRef( debounce((value: boolean) => { setIsSelected(value) - }, 800) + }, 800), ).current // Helper function to fetch file content @@ -125,7 +127,7 @@ export const useMonacoEditor = ({ }) }) }, - [socket] + [socket], ) // Load and merge TSConfig @@ -133,10 +135,10 @@ export const useMonacoEditor = ({ async ( files: (TFolder | TFile)[], editor: monaco.editor.IStandaloneCodeEditor, - monaco: typeof import("monaco-editor") + monaco: typeof import("monaco-editor"), ) => { const tsconfigFiles = files.filter((file) => - file.name.endsWith("tsconfig.json") + file.name.endsWith("tsconfig.json"), ) let mergedConfig: any = { compilerOptions: {} } @@ -172,10 +174,10 @@ export const useMonacoEditor = ({ ...mergedConfig.compilerOptions, }) monaco.languages.typescript.typescriptDefaults.setCompilerOptions( - updatedOptions + updatedOptions, ) monaco.languages.typescript.javascriptDefaults.setCompilerOptions( - updatedOptions + updatedOptions, ) } @@ -189,7 +191,7 @@ export const useMonacoEditor = ({ } }) }, - [fetchFileContent] + [fetchFileContent], ) // Pre-mount editor keybindings @@ -243,7 +245,7 @@ export const useMonacoEditor = ({ } }) }, - [editorRef, generateRef, setGenerate] + [editorRef, generateRef, setGenerate], ) // Post-mount editor keybindings and actions @@ -260,10 +262,10 @@ export const useMonacoEditor = ({ monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true) monaco.languages.typescript.typescriptDefaults.setCompilerOptions( - defaultCompilerOptions + defaultCompilerOptions, ) monaco.languages.typescript.javascriptDefaults.setCompilerOptions( - defaultCompilerOptions + defaultCompilerOptions, ) // Load TSConfig @@ -294,7 +296,7 @@ export const useMonacoEditor = ({ lineNumber, column, lineNumber, - endColumn + endColumn, ), options: { afterContentClassName: "inline-decoration", @@ -336,7 +338,7 @@ export const useMonacoEditor = ({ handleAiEdit, setIsAIChatOpen, debouncedSetIsSelected, - ] + ], ) // Generate widget effect @@ -415,6 +417,26 @@ export const useMonacoEditor = ({ } }) } + + // Cleanup function for generate widget + return () => { + if (generate.widget && editorRef) { + try { + editorRef.removeContentWidget(generate.widget) + } catch (e) { + // Widget may already be removed + } + } + if (generate.id && editorRef) { + try { + editorRef.changeViewZones((changeAccessor) => { + changeAccessor.removeZone(generate.id) + }) + } catch (e) { + // Zone may already be removed + } + } + } }, [ generate.show, generate.id, @@ -428,14 +450,23 @@ export const useMonacoEditor = ({ // Suggestion widget effect useEffect(() => { if (!suggestionRef.current || !editorRef) return + + // Remove any existing widget first + if (suggestionWidgetRef.current) { + try { + editorRef.removeContentWidget(suggestionWidgetRef.current) + } catch (e) { + // Widget may already be removed + } + suggestionWidgetRef.current = null + } + + if (!isSelected) return + const widgetElement = suggestionRef.current const suggestionWidget: monaco.editor.IContentWidget = { - getDomNode: () => { - return widgetElement - }, - getId: () => { - return "suggestion.widget" - }, + getDomNode: () => widgetElement, + getId: () => "suggestion.widget", getPosition: () => { const selection = editorRef?.getSelection() const column = Math.max(3, selection?.positionColumn ?? 1) @@ -453,11 +484,21 @@ export const useMonacoEditor = ({ } }, } - if (isSelected) { - editorRef?.addContentWidget(suggestionWidget) - editorRef?.applyFontInfo(suggestionRef.current) - } else { - editorRef?.removeContentWidget(suggestionWidget) + + suggestionWidgetRef.current = suggestionWidget + editorRef.addContentWidget(suggestionWidget) + editorRef.applyFontInfo(suggestionRef.current) + + // Cleanup function - remove widget when effect re-runs or unmounts + return () => { + if (suggestionWidgetRef.current && editorRef) { + try { + editorRef.removeContentWidget(suggestionWidgetRef.current) + } catch (e) { + // Widget may already be removed or editor disposed + } + suggestionWidgetRef.current = null + } } }, [isSelected, editorRef]) @@ -500,6 +541,40 @@ export const useMonacoEditor = ({ } }, [decorations.options, cursorLine, editorRef]) + // Cleanup function to remove all contentWidgets before tab close + const cleanupWidgets = useCallback(() => { + if (!editorRef) return + try { + // Remove suggestion widget using stored reference + if (suggestionWidgetRef.current) { + try { + editorRef.removeContentWidget(suggestionWidgetRef.current) + } catch (e) { + // Already removed + } + suggestionWidgetRef.current = null + } + // Remove generate widget + if (generate.widget) { + try { + editorRef.removeContentWidget(generate.widget) + } catch (e) { + // Already removed + } + } + // Clear any view zones + if (generate.id) { + editorRef.changeViewZones((changeAccessor) => { + changeAccessor.removeZone(generate.id) + }) + } + // Clear decorations + decorations.instance?.clear() + } catch (err) { + console.warn("Error cleaning up Monaco widgets:", err) + } + }, [editorRef, generate.widget, generate.id, decorations.instance]) + // useEffect(() => {}, [tabs]) return { // Editor state @@ -525,6 +600,7 @@ export const useMonacoEditor = ({ handleEditorWillMount, handleEditorMount, handleAiEdit, + cleanupWidgets, // Internal setters setEditorRef, diff --git a/web/components/project/project-layout.tsx b/web/components/project/project-layout.tsx index a10661c3..62bdbe0a 100644 --- a/web/components/project/project-layout.tsx +++ b/web/components/project/project-layout.tsx @@ -134,6 +134,8 @@ export default function ProjectLayout({ handleEditorWillMount, handleEditorMount, handleAiEdit, + cleanupWidgets, + setIsSelected, } = useMonacoEditor({ editorPanelRef, setIsAIChatOpen, @@ -184,7 +186,7 @@ export default function ProjectLayout({ getUnresolvedSnapshot, restoreFromSnapshot, clearVisuals, - forceClearAllDecorations + forceClearAllDecorations, ) // Store diff functions so sidebar can use them @@ -214,14 +216,14 @@ export default function ProjectLayout({ setMergeDecorationsCollection(decorationsCollection) } }, - [handleApplyCode] + [handleApplyCode], ) const updateFileDraft = useCallback( (fileId: string, content?: string) => { setDraft(fileId, content ?? "") }, - [setDraft] + [setDraft], ) const handleEditorChange = useCallback( @@ -231,7 +233,7 @@ export default function ProjectLayout({ } updateFileDraft(activeTab.id, value) }, - [activeTab?.id, updateFileDraft] + [activeTab?.id, updateFileDraft], ) const waitForEditorModel = useCallback(async () => { @@ -330,7 +332,7 @@ export default function ProjectLayout({ activeTab?.id, hasActiveWidgets, acceptAll, - ] + ], ) const restoreOriginalFile = useCallback( @@ -352,7 +354,7 @@ export default function ProjectLayout({ activeTab?.id, hasActiveWidgets, rejectAll, - ] + ], ) // Handler for rejecting code from chat @@ -368,20 +370,35 @@ export default function ProjectLayout({ const handleCloseTab = useCallback( (tab: TTab) => { const isClosingActive = activeTab?.id === tab.id - if (isClosingActive && hasActiveWidgets()) { + if (isClosingActive) { + // CRITICAL: Clear selection and generate states first to trigger widget removal + // This must happen before we remove the tab to prevent React/Monaco DOM conflicts + setIsSelected(false) + setGenerate((prev) => ({ ...prev, show: false })) + + // Remove all contentWidgets synchronously to prevent DOM errors + // Monaco moves widget DOM nodes out of React's tree, so we must remove them + // before React tries to unmount the component try { - const session = getUnresolvedSnapshot(tab.id) - if (session) { - saveDiffSession(tab.id, session) - } - } catch (error) { - console.warn("Failed to snapshot unresolved diffs on close:", error) + cleanupWidgets() + } catch (err) { + console.warn("Error cleaning up Monaco widgets:", err) } - // Clear widgets before closing the tab - try { - clearVisuals() - } catch (error) { - forceClearAllDecorations() + if (hasActiveWidgets()) { + try { + const session = getUnresolvedSnapshot(tab.id) + if (session) { + saveDiffSession(tab.id, session) + } + } catch (error) { + console.warn("Failed to snapshot unresolved diffs on close:", error) + } + // Clear widgets before closing the tab + try { + clearVisuals() + } catch (error) { + forceClearAllDecorations() + } } } removeTab(tab) @@ -394,7 +411,10 @@ export default function ProjectLayout({ clearVisuals, forceClearAllDecorations, removeTab, - ] + cleanupWidgets, + setIsSelected, + setGenerate, + ], ) // Use the session manager for tab switching @@ -523,10 +543,10 @@ export default function ProjectLayout({ isAIChatOpen && isHorizontalLayout ? "horizontal" : isAIChatOpen - ? "vertical" - : isHorizontalLayout - ? "horizontal" - : "vertical" + ? "vertical" + : isHorizontalLayout + ? "horizontal" + : "vertical" } > {/* Preview Panel */}