feat: improve editors (#226)

This commit is contained in:
Carlos Florêncio
2026-02-16 09:55:04 +00:00
committed by GitHub
parent 95f1b2e13b
commit c4ef2d9ba1
68 changed files with 7389 additions and 2605 deletions
@@ -1,4 +1,4 @@
'use client';
// Re-export from the unified diff-viewer component
export { DiffViewInline } from './diff-viewer';
// Re-export resolver-aware version
export { DiffViewInlineResolved as DiffViewInline } from './diff-viewer-resolver';
@@ -0,0 +1,39 @@
'use client';
import { memo } from 'react';
import { useEditorProvider } from '@/hooks/use-editor-resolver';
import { DiffViewer as PierreDiffViewer, DiffViewInline as PierreDiffViewInline } from './diff-viewer';
import { MonacoDiffViewer } from '@/components/editors/monaco/monaco-diff-viewer';
import type { FileDiffData, DiffComment } from '@/lib/diff/types';
interface DiffViewerResolverProps {
data: FileDiffData;
enableComments?: boolean;
sessionId?: string;
onCommentAdd?: (comment: DiffComment) => void;
onCommentDelete?: (commentId: string) => void;
comments?: DiffComment[];
className?: string;
compact?: boolean;
hideHeader?: boolean;
onOpenFile?: (filePath: string) => void;
onRevert?: (filePath: string) => void;
wordWrap?: boolean;
}
export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffViewerResolverProps) {
const provider = useEditorProvider('diff-viewer');
if (provider === 'monaco') {
const { enableComments: _, ...rest } = props;
return <MonacoDiffViewer {...rest} />;
}
return <PierreDiffViewer {...props} />;
});
export function DiffViewInlineResolved({ data, className }: { data: FileDiffData; className?: string }) {
const provider = useEditorProvider('chat-diff');
if (provider === 'monaco') {
return <MonacoDiffViewer data={data} compact hideHeader className={className} />;
}
return <PierreDiffViewInline data={data} className={className} />;
}
+3 -3
View File
@@ -8,7 +8,7 @@ import type { FileDiffOptions, FileDiffMetadata, DiffLineAnnotation, RenderHeade
import { cn } from '@kandev/ui/lib/utils';
import { Button } from '@kandev/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@kandev/ui/tooltip';
import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconExternalLink, IconPlus, IconArrowBackUp } from '@tabler/icons-react';
import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconPencil, IconPlus, IconArrowBackUp } from '@tabler/icons-react';
import type { FileDiffData, DiffComment } from '@/lib/diff/types';
import { useDiffComments } from './use-diff-comments';
import { CommentForm } from './comment-form';
@@ -407,10 +407,10 @@ export const DiffViewer = memo(function DiffViewer({
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
onClick={() => onOpenFile(filePath)}
>
<IconExternalLink className="h-3.5 w-3.5" />
<IconPencil className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Open in editor</TooltipContent>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
)}
</div>
@@ -1,7 +1,7 @@
'use client';
import { memo, useMemo } from 'react';
import { DiffViewer } from './diff-viewer';
import { DiffViewerResolved as DiffViewer } from './diff-viewer-resolver';
import { transformGitDiff } from '@/lib/diff';
import type { DiffComment } from '@/lib/diff/types';
+7 -3
View File
@@ -1,9 +1,13 @@
// Main components
export { DiffViewer } from './diff-viewer';
// Main components - resolver-aware
export { DiffViewerResolved as DiffViewer } from './diff-viewer-resolver';
export { DiffViewInlineResolved as DiffViewInline } from './diff-viewer-resolver';
export { FileDiffViewer } from './file-diff-viewer';
export { DiffViewInline } from './diff-view-inline';
export { CommentForm } from './comment-form';
export { CommentDisplay } from './comment-display';
// Direct access to Pierre implementation (for internal use)
export { DiffViewer as PierreDiffViewer } from './diff-viewer';
export { DiffViewInline as PierreDiffViewInline } from './diff-viewer';
// Hooks
export { useDiffComments } from './use-diff-comments';
@@ -0,0 +1,80 @@
'use client';
import { useTheme } from 'next-themes';
import CodeMirror from '@uiw/react-codemirror';
import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
import { EditorView } from '@codemirror/view';
import { IconCheck, IconCopy } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import { getCodeMirrorExtension } from '@/lib/languages';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
type CodeBlockProps = {
children: React.ReactNode;
className?: string;
};
const getLanguageExtension = (language?: string) => {
if (!language) return undefined;
const lang = language.replace('language-', '').toLowerCase();
return getCodeMirrorExtension(lang);
};
export function CodeMirrorCodeBlock({ children, className }: CodeBlockProps) {
const { copied, copy } = useCopyToClipboard();
const { theme, systemTheme } = useTheme();
const effectiveTheme = theme === 'system' ? systemTheme : theme;
const code = String(children).replace(/\n$/, '');
const languageExtension = getLanguageExtension(className);
// Custom padding theme
const paddingTheme = EditorView.theme({
'&': {
padding: '2px 2px',
},
});
const handleCopy = async () => {
await copy(code);
};
return (
<div className="relative group/code-block my-4 w-fit max-w-full min-w-[50%]">
{/* Copy button */}
<button
onClick={handleCopy}
className={cn(
'absolute top-2 right-2 z-10',
'p-1.5 rounded-md',
'bg-white/10 hover:bg-white/20',
'transition-all duration-200',
'opacity-0 group-hover/code-block:opacity-100',
'cursor-pointer',
)}
title="Copy code"
>
{copied ? (
<IconCheck className="h-3 w-3 text-green-400" />
) : (
<IconCopy className="h-3 w-3 text-gray-400" />
)}
</button>
{/* Code editor */}
<CodeMirror
value={code}
theme={effectiveTheme === 'dark' ? vscodeDark : vscodeLight}
extensions={languageExtension ? [languageExtension, EditorView.lineWrapping, paddingTheme] : [EditorView.lineWrapping, paddingTheme]}
editable={false}
basicSetup={{
lineNumbers: false,
highlightActiveLineGutter: false,
highlightActiveLine: false,
foldGutter: false,
}}
className="text-xs rounded-md overflow-hidden"
/>
</div>
);
}
@@ -0,0 +1,649 @@
'use client';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { EditorView, gutter, GutterMarker, ViewPlugin, type ViewUpdate } from '@codemirror/view';
import type { Extension } from '@codemirror/state';
import { Decoration, type DecorationSet } from '@codemirror/view';
import { getCodeMirrorExtensionFromPath } from '@/lib/languages';
import { Button } from '@kandev/ui/button';
import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus } from '@tabler/icons-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { formatDiffStats } from '@/lib/utils/file-diff';
import { toRelativePath } from '@/lib/utils';
import { vscodeDark } from '@uiw/codemirror-theme-vscode';
import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice';
import type { DiffComment } from '@/lib/diff/types';
import { useToast } from '@/components/toast-provider';
import { EditorCommentPopover } from '@/components/task/editor-comment-popover';
import { CommentViewPopover } from '@/components/task/comment-view-popover';
type FileEditorContentProps = {
path: string;
content: string;
originalContent: string;
isDirty: boolean;
isSaving: boolean;
sessionId?: string;
worktreePath?: string;
enableComments?: boolean;
onChange: (newContent: string) => void;
onSave: () => void;
onDelete?: () => void;
};
type TextSelection = {
text: string;
startLine: number;
endLine: number;
position: { x: number; y: number };
} | null;
type FloatingButtonPosition = {
x: number;
y: number;
} | null;
type CommentViewState = {
comments: DiffComment[];
position: { x: number; y: number };
} | null;
// Create comment line decorations
function createCommentDecorations(
view: EditorView,
comments: DiffComment[]
): DecorationSet {
if (comments.length === 0) {
return Decoration.none;
}
const decorations: Array<{ from: number; decoration: Decoration }> = [];
// Get all lines that have comments
const linesWithComments = new Set<number>();
for (const comment of comments) {
for (let line = comment.startLine; line <= comment.endLine; line++) {
linesWithComments.add(line);
}
}
// Create line decorations for commented lines
for (const lineNum of linesWithComments) {
if (lineNum > view.state.doc.lines) continue;
const line = view.state.doc.line(lineNum);
decorations.push({
from: line.from,
decoration: Decoration.line({
class: 'cm-comment-line',
}),
});
}
decorations.sort((a, b) => a.from - b.from);
return Decoration.set(decorations.map(d => d.decoration.range(d.from)));
}
// Gutter marker for lines with comments - defined outside component to avoid re-creation
class CommentGutterMarker extends GutterMarker {
constructor(
readonly lineComments: DiffComment[],
readonly isFirstLine: boolean
) {
super();
}
toDOM() {
const marker = document.createElement('div');
marker.className = 'cm-comment-gutter-marker';
marker.title = `${this.lineComments.length} comment${this.lineComments.length > 1 ? 's' : ''} - click to view`;
// Add icon only on the first line of a comment
if (this.isFirstLine) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '12');
svg.setAttribute('height', '12');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'rgba(99, 102, 241, 0.9)');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z');
svg.appendChild(path);
marker.appendChild(svg);
}
return marker;
}
eq(other: CommentGutterMarker) {
return this.lineComments.length === other.lineComments.length && this.isFirstLine === other.isFirstLine;
}
}
export function CodeMirrorCodeEditor({
path,
content,
originalContent,
isDirty,
isSaving,
sessionId,
worktreePath,
enableComments = false,
onChange,
onSave,
onDelete,
}: FileEditorContentProps) {
const [wrapEnabled, setWrapEnabled] = useState(true);
const [textSelection, setTextSelection] = useState<TextSelection>(null);
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
const [commentView, setCommentView] = useState<CommentViewState>(null);
const editorRef = useRef<ReactCodeMirrorRef>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const { toast } = useToast();
// Comment store
const addComment = useDiffCommentsStore((state) => state.addComment);
const removeComment = useDiffCommentsStore((state) => state.removeComment);
const comments = useFileComments(sessionId ?? '', path);
const langExt = getCodeMirrorExtensionFromPath(path);
// Create the comment decoration plugin
const commentPlugin = useMemo(() => {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = createCommentDecorations(view, comments);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = createCommentDecorations(update.view, comments);
}
}
},
{ decorations: (v) => v.decorations }
);
}, [comments]);
// Create comment gutter
const commentGutter = useMemo(() => {
// Build a map of line number to comments that include that line
const commentsByLine = new Map<number, DiffComment[]>();
// Track which lines are the first line of at least one comment
const firstLines = new Set<number>();
for (const comment of comments) {
firstLines.add(comment.startLine);
// Add to all lines in the range
for (let lineNum = comment.startLine; lineNum <= comment.endLine; lineNum++) {
const existing = commentsByLine.get(lineNum) || [];
existing.push(comment);
commentsByLine.set(lineNum, existing);
}
}
return gutter({
class: 'cm-comment-gutter',
lineMarker: (view, line) => {
const lineNum = view.state.doc.lineAt(line.from).number;
const lineComments = commentsByLine.get(lineNum);
if (lineComments && lineComments.length > 0) {
return new CommentGutterMarker(lineComments, firstLines.has(lineNum));
}
return null;
},
domEventHandlers: {
click: (view, line, event) => {
const lineNum = view.state.doc.lineAt(line.from).number;
const lineComments = commentsByLine.get(lineNum);
if (lineComments && lineComments.length > 0) {
event.preventDefault();
event.stopPropagation();
setCommentView({
comments: lineComments,
position: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY },
});
return true;
}
return false;
},
},
});
}, [comments]);
// Track mouse position
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
mousePositionRef.current = { x: e.clientX, y: e.clientY };
};
document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}, []);
// Show floating button after selection ends (on mouseup)
const handleSelectionEnd = useCallback(() => {
if (!enableComments || !sessionId) return;
const view = editorRef.current?.view;
if (!view) return;
const selection = view.state.selection.main;
if (selection.empty) {
setFloatingButtonPos(null);
setCurrentSelection(null);
return;
}
const selectedText = view.state.sliceDoc(selection.from, selection.to);
if (!selectedText.trim()) {
setFloatingButtonPos(null);
setCurrentSelection(null);
return;
}
// Get line numbers
const startLine = view.state.doc.lineAt(selection.from).number;
const endLine = view.state.doc.lineAt(selection.to).number;
// Use mouse position for the floating button (where cursor is)
setCurrentSelection({ text: selectedText, startLine, endLine });
setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
}, [enableComments, sessionId]);
// Listen for mouseup to show floating button after selection ends
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || !enableComments || !sessionId) return;
const handleMouseUp = (e: MouseEvent) => {
// Don't show if clicking on the floating button itself
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
return;
}
// Small delay to let selection finalize
setTimeout(handleSelectionEnd, 10);
};
// Clear selection state when clicking (new selection starts)
const handleMouseDown = (e: MouseEvent) => {
// Don't clear if clicking on the floating button
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
return;
}
setFloatingButtonPos(null);
};
wrapper.addEventListener('mouseup', handleMouseUp);
wrapper.addEventListener('mousedown', handleMouseDown);
return () => {
wrapper.removeEventListener('mouseup', handleMouseUp);
wrapper.removeEventListener('mousedown', handleMouseDown);
};
}, [enableComments, sessionId, handleSelectionEnd]);
// Clear floating button when selection is cleared via keyboard
const selectionUpdateExtension = useMemo(() => {
return EditorView.updateListener.of((update) => {
if (update.selectionSet) {
const selection = update.state.selection.main;
if (selection.empty) {
setFloatingButtonPos(null);
setCurrentSelection(null);
}
}
});
}, []);
const extensions: Extension[] = useMemo(() => {
const exts: Extension[] = [
EditorView.editable.of(true),
EditorView.theme({
'&': { backgroundColor: 'hsl(var(--background)) !important' },
'.cm-gutters': { backgroundColor: 'hsl(var(--background)) !important', borderRight: 'none' },
// Comment gutter
'.cm-comment-gutter': {
width: '22px',
cursor: 'pointer',
},
'.cm-comment-gutter .cm-gutterElement': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0',
},
// Marker styles - background color on the marker itself (indigo)
'.cm-comment-gutter-marker': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
cursor: 'pointer',
backgroundColor: 'rgba(99, 102, 241, 0.2)',
},
'.cm-comment-gutter-marker:hover': {
backgroundColor: 'rgba(99, 102, 241, 0.35)',
},
// Comment line highlight with left border (indigo)
'.cm-comment-line': {
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
borderLeft: '3px solid rgba(99, 102, 241, 0.6)',
marginLeft: '-3px',
paddingLeft: '3px',
},
}),
commentGutter,
commentPlugin,
selectionUpdateExtension,
];
if (wrapEnabled) {
exts.push(EditorView.lineWrapping);
}
if (langExt) {
exts.push(langExt);
}
return exts;
}, [langExt, wrapEnabled, commentGutter, commentPlugin, selectionUpdateExtension]);
// Calculate diff stats when dirty
const diffStats = useMemo(() => {
if (!isDirty) return null;
const originalLines = originalContent.split('\n');
const currentLines = content.split('\n');
let additions = 0;
let deletions = 0;
const maxLen = Math.max(originalLines.length, currentLines.length);
for (let i = 0; i < maxLen; i++) {
const origLine = originalLines[i];
const currLine = currentLines[i];
if (origLine === undefined && currLine !== undefined) {
additions++;
} else if (origLine !== undefined && currLine === undefined) {
deletions++;
} else if (origLine !== currLine) {
additions++;
deletions++;
}
}
return { additions, deletions };
}, [isDirty, content, originalContent]);
// Handle Cmd+I to open comment popover
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || !enableComments || !sessionId) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
if (!currentSelection || !floatingButtonPos) return;
e.preventDefault();
e.stopPropagation();
setTextSelection({
...currentSelection,
position: floatingButtonPos,
});
setFloatingButtonPos(null);
}
};
wrapper.addEventListener('keydown', handleKeyDown, true);
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
}, [enableComments, sessionId, currentSelection, floatingButtonPos]);
// Handle Cmd/Ctrl+S keyboard shortcut and Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (isDirty && !isSaving) {
onSave();
}
}
// Escape to close popovers
if (e.key === 'Escape') {
if (textSelection) setTextSelection(null);
if (commentView) setCommentView(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isDirty, isSaving, onSave, textSelection, commentView]);
const handleChange = useCallback(
(value: string) => {
onChange(value);
},
[onChange]
);
// Open comment popover from floating button
const handleFloatingButtonClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!currentSelection || !floatingButtonPos) return;
setTextSelection({
...currentSelection,
position: floatingButtonPos,
});
setFloatingButtonPos(null);
}, [currentSelection, floatingButtonPos]);
// Submit comment
const handleCommentSubmit = useCallback((annotation: string) => {
if (!textSelection || !sessionId) return;
const { text, startLine, endLine } = textSelection;
const comment: DiffComment = {
id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sessionId,
filePath: path,
startLine,
endLine,
side: 'additions',
codeContent: text,
annotation,
createdAt: new Date().toISOString(),
status: 'pending',
};
addComment(comment);
setTextSelection(null);
// Clear selection in editor
const view = editorRef.current?.view;
if (view) {
view.dispatch({
selection: { anchor: view.state.selection.main.head },
});
}
toast({
title: 'Comment added',
description: 'Your comment will be sent with your next message.',
});
}, [textSelection, sessionId, path, addComment, toast]);
// Close popover
const handlePopoverClose = useCallback(() => {
setTextSelection(null);
}, []);
// Delete comment
const handleDeleteComment = useCallback((commentId: string) => {
if (!sessionId) return;
removeComment(sessionId, path, commentId);
// Close the view if no more comments
if (commentView && commentView.comments.length <= 1) {
setCommentView(null);
} else if (commentView) {
setCommentView({
...commentView,
comments: commentView.comments.filter(c => c.id !== commentId),
});
}
toast({
title: 'Comment deleted',
});
}, [sessionId, path, removeComment, commentView, toast]);
// Close comment view
const handleCommentViewClose = useCallback(() => {
setCommentView(null);
}, []);
return (
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
{/* Editor header with save button */}
<div className="flex items-center justify-between px-2 border-foreground/10 border-b">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
{isDirty && diffStats && (
<span className="text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
<div className="flex items-center gap-1">
{enableComments && sessionId && comments.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
<IconMessagePlus className="h-3.5 w-3.5" />
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={() => setWrapEnabled(!wrapEnabled)}
className={`h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? 'text-foreground' : 'text-muted-foreground'}`}
>
{wrapEnabled ? <IconTextWrap className="h-4 w-4" /> : <IconTextWrapDisabled className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}</TooltipContent>
</Tooltip>
{onDelete && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={onDelete}
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
>
<IconTrash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete file</TooltipContent>
</Tooltip>
)}
<Button
size="sm"
variant="default"
onClick={onSave}
disabled={!isDirty || isSaving}
className="cursor-pointer gap-2"
>
{isSaving ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<IconDeviceFloppy className="h-4 w-4" />
Save
<span className="text-xs text-muted-foreground">
({navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+S)
</span>
</>
)}
</Button>
</div>
</div>
{/* CodeMirror editor */}
<div className="flex-1 overflow-hidden relative">
<CodeMirror
ref={editorRef}
value={content}
height="100%"
theme={vscodeDark}
extensions={extensions}
onChange={handleChange}
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
className="h-full overflow-auto text-xs"
/>
{/* Floating "Add comment" button when text is selected */}
{floatingButtonPos && !textSelection && (
<Button
size="sm"
variant="secondary"
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
style={{
left: floatingButtonPos.x + 8,
top: floatingButtonPos.y + 8,
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={handleFloatingButtonClick}
>
<IconMessagePlus className="h-3.5 w-3.5" />
Comment
</Button>
)}
{/* Comment popover for adding new comment */}
{textSelection && (
<EditorCommentPopover
selectedText={textSelection.text}
lineRange={{ start: textSelection.startLine, end: textSelection.endLine }}
position={textSelection.position}
onSubmit={handleCommentSubmit}
onClose={handlePopoverClose}
/>
)}
{/* Comment view popover for viewing/deleting existing comments */}
{commentView && (
<CommentViewPopover
comments={commentView.comments}
position={commentView.position}
onDelete={handleDeleteComment}
onClose={handleCommentViewClose}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,135 @@
'use client';
import { useMemo, useCallback } from 'react';
import {
IconExternalLink,
IconCopy,
IconFolderShare,
} from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { useEditors } from '@/hooks/domains/settings/use-editors';
import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor';
import { useOpenSessionFolder } from '@/hooks/use-open-session-folder';
import { useAppStore } from '@/components/state-provider';
import type { EditorOption } from '@/lib/types/http';
type FileActionsDropdownProps = {
/** File path to open / copy */
filePath: string;
/** Session ID override — defaults to activeSessionId from store */
sessionId?: string;
/** Button size variant */
size?: 'sm' | 'xs';
/** Optional toast callback after copy */
onCopied?: () => void;
};
export function FileActionsDropdown({
filePath,
sessionId: sessionIdProp,
size = 'xs',
onCopied,
}: FileActionsDropdownProps) {
const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const sessionId = sessionIdProp ?? storeSessionId ?? null;
const worktreePath = useAppStore((state) => {
if (!sessionId) return undefined;
return state.taskSessions.items[sessionId]?.worktree_path;
});
const openEditor = useOpenSessionInEditor(sessionId);
const openFolder = useOpenSessionFolder(sessionId);
const { editors } = useEditors();
const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId);
const enabledEditors = useMemo(
() =>
editors.filter((editor: EditorOption) => {
if (!editor.enabled) return false;
if (editor.kind === 'built_in') return editor.installed;
return true;
}),
[editors]
);
const absolutePath = useMemo(() => {
if (!worktreePath) return filePath;
// Avoid double slashes
const base = worktreePath.endsWith('/') ? worktreePath : `${worktreePath}/`;
const rel = filePath.startsWith('/') ? filePath.slice(1) : filePath;
return `${base}${rel}`;
}, [worktreePath, filePath]);
const handleCopyPath = useCallback(() => {
navigator.clipboard.writeText(absolutePath);
onCopied?.();
}, [absolutePath, onCopied]);
const handleOpenInEditor = useCallback(
(editorId: string) => {
void openEditor.open({ editorId, filePath });
},
[openEditor, filePath]
);
const handleOpenFolder = useCallback(() => {
void openFolder.open();
}, [openFolder]);
const btnClass = size === 'xs'
? 'h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100'
: 'h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-foreground';
const iconClass = size === 'xs' ? 'h-3.5 w-3.5' : 'h-4 w-4';
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className={btnClass}>
<IconExternalLink className={iconClass} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Open with...</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-44">
{enabledEditors.map((editor: EditorOption) => (
<DropdownMenuItem
key={editor.id}
className="cursor-pointer text-xs"
onClick={() => handleOpenInEditor(editor.id)}
>
{editor.name}
{editor.id === defaultEditorId && (
<span className="ml-auto text-[10px] text-muted-foreground">default</span>
)}
</DropdownMenuItem>
))}
{enabledEditors.length === 0 && (
<DropdownMenuItem disabled className="text-xs">
No editors configured
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-xs" onClick={handleCopyPath}>
<IconCopy className="h-3.5 w-3.5 mr-1.5" />
Copy path
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer text-xs" onClick={handleOpenFolder}>
<IconFolderShare className="h-3.5 w-3.5 mr-1.5" />
Open folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,16 @@
// Shared state for controlling Monaco's built-in TS/JS provider suppression.
// Separated into its own module (no heavy imports) so both monaco-loader.ts
// and lsp-client-manager.ts can import it without circular dependencies or
// pulling in the full monaco-editor bundle.
let _suppressed = false;
/** Returns true when built-in TS/JS providers should be suppressed (LSP active). */
export function isBuiltinTsSuppressed(): boolean {
return _suppressed;
}
/** Set suppression state. Called synchronously from lsp-client-manager. */
export function setBuiltinTsSuppressed(suppressed: boolean): void {
_suppressed = suppressed;
}
@@ -0,0 +1,82 @@
'use client';
import { useTheme } from 'next-themes';
import Editor from '@monaco-editor/react';
import { IconCheck, IconCopy } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import { getMonacoLanguageFromName } from '@/lib/editor/language-map';
import { EDITOR_FONT_FAMILY } from '@/lib/theme/editor-theme';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { initMonacoThemes } from './monaco-init';
initMonacoThemes();
type MonacoCodeBlockProps = {
children: React.ReactNode;
className?: string;
};
export function MonacoCodeBlock({ children, className }: MonacoCodeBlockProps) {
const { copied, copy } = useCopyToClipboard();
const { resolvedTheme } = useTheme();
const code = String(children).replace(/\n$/, '');
const lang = className?.replace('language-', '').toLowerCase() ?? '';
const language = getMonacoLanguageFromName(lang);
const lineCount = code.split('\n').length;
// Compact height: ~16px per line + 8px padding
const height = Math.min(Math.max(lineCount * 16 + 8, 32), 500);
return (
<div className="relative group/code-block my-4 w-fit max-w-full min-w-[50%]">
<button
onClick={() => copy(code)}
className={cn(
'absolute top-2 right-2 z-10',
'p-1.5 rounded-md',
'bg-white/10 hover:bg-white/20',
'transition-all duration-200',
'opacity-0 group-hover/code-block:opacity-100',
'cursor-pointer',
)}
title="Copy code"
>
{copied ? (
<IconCheck className="h-3 w-3 text-green-400" />
) : (
<IconCopy className="h-3 w-3 text-gray-400" />
)}
</button>
<Editor
height={height}
language={language}
value={code}
theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'}
options={{
readOnly: true,
fontSize: 12,
fontFamily: EDITOR_FONT_FAMILY,
lineHeight: 16,
minimap: { enabled: false },
wordWrap: 'on',
scrollBeyondLastLine: false,
lineNumbers: 'off',
glyphMargin: false,
folding: false,
renderLineHighlight: 'none',
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
overviewRulerBorder: false,
automaticLayout: true,
domReadOnly: true,
contextmenu: false,
padding: { top: 4, bottom: 4 },
}}
className="rounded-md overflow-hidden text-xs"
loading={null}
/>
</div>
);
}
@@ -0,0 +1,719 @@
'use client';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import Editor, { type OnMount, type OnChange } from '@monaco-editor/react';
import type { editor as monacoEditor, IDisposable } from 'monaco-editor';
import { useTheme } from 'next-themes';
import { Button } from '@kandev/ui/button';
import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus, IconArrowsDiff, IconPlugConnected, IconPlugOff, IconAlertTriangle } from '@tabler/icons-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { formatDiffStats } from '@/lib/utils/file-diff';
import { toRelativePath } from '@/lib/utils';
import { getMonacoLanguage } from '@/lib/editor/language-map';
import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from '@/lib/theme/editor-theme';
import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice';
import type { DiffComment } from '@/lib/diff/types';
import { useToast } from '@/components/toast-provider';
import { EditorCommentPopover } from '@/components/task/editor-comment-popover';
import { CommentViewPopover } from '@/components/task/comment-view-popover';
import { diffLines } from 'diff';
import { FileActionsDropdown } from '@/components/editors/file-actions-dropdown';
import { useAppStore } from '@/components/state-provider';
import { useLsp } from '@/hooks/use-lsp';
import type { LspStatus } from '@/lib/lsp/lsp-client-manager';
import { lspClientManager } from '@/lib/lsp/lsp-client-manager';
import { consumePendingCursorPosition } from '@/hooks/use-file-editors';
import { initMonacoThemes } from './monaco-init';
initMonacoThemes();
type MonacoCodeEditorProps = {
path: string;
content: string;
originalContent: string;
isDirty: boolean;
isSaving: boolean;
sessionId?: string;
worktreePath?: string;
enableComments?: boolean;
onChange: (newContent: string) => void;
onSave: () => void;
onDelete?: () => void;
};
type TextSelection = {
text: string;
startLine: number;
endLine: number;
position: { x: number; y: number };
} | null;
type FloatingButtonPosition = {
x: number;
y: number;
} | null;
type CommentViewState = {
comments: DiffComment[];
position: { x: number; y: number };
} | null;
function LspStatusButton({
status,
lspLanguage,
onToggle,
}: {
status: LspStatus;
lspLanguage: string | null;
onToggle: () => void;
}) {
if (!lspLanguage) return null;
type StateConfig = { icon: React.ReactNode; tooltip: string; clickable: boolean };
const configs: Record<string, StateConfig> = {
disabled: {
icon: <IconPlugOff className="h-3.5 w-3.5 text-muted-foreground/50" />,
tooltip: 'LSP: Off \u2014 click to start',
clickable: true,
},
connecting: {
icon: <IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />,
tooltip: 'LSP: Connecting...',
clickable: true,
},
installing: {
icon: <IconLoader2 className="h-3.5 w-3.5 animate-spin text-amber-500" />,
tooltip: 'LSP: Installing language server...',
clickable: false,
},
starting: {
icon: <IconLoader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />,
tooltip: 'LSP: Starting language server...',
clickable: true,
},
ready: {
icon: <IconPlugConnected className="h-3.5 w-3.5 text-emerald-500" />,
tooltip: 'LSP: Connected \u2014 click to stop',
clickable: true,
},
stopping: {
icon: <IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />,
tooltip: 'LSP: Stopping...',
clickable: false,
},
unavailable: {
icon: <IconPlugOff className="h-3.5 w-3.5 text-muted-foreground" />,
tooltip: `LSP: ${'reason' in status ? status.reason : 'Unavailable'}`,
clickable: true,
},
error: {
icon: <IconAlertTriangle className="h-3.5 w-3.5 text-yellow-500" />,
tooltip: `LSP: ${'reason' in status ? status.reason : 'Error'}`,
clickable: true,
},
};
const c = configs[status.state];
if (!c) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer"
onClick={c.clickable ? onToggle : undefined}
disabled={!c.clickable}
>
{c.icon}
</Button>
</TooltipTrigger>
<TooltipContent>{c.tooltip}</TooltipContent>
</Tooltip>
);
}
export function MonacoCodeEditor({
path,
content,
originalContent,
isDirty,
isSaving,
sessionId,
worktreePath,
enableComments = false,
onChange,
onSave,
onDelete,
}: MonacoCodeEditorProps) {
const { resolvedTheme } = useTheme();
const [wrapEnabled, setWrapEnabled] = useState(true);
const [textSelection, setTextSelection] = useState<TextSelection>(null);
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
const [commentView, setCommentView] = useState<CommentViewState>(null);
const [showDiffIndicators, setShowDiffIndicators] = useState(true);
const editorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const decorationsRef = useRef<monacoEditor.IEditorDecorationsCollection | null>(null);
const diffDecorationsRef = useRef<monacoEditor.IEditorDecorationsCollection | null>(null);
const disposablesRef = useRef<IDisposable[]>([]);
const { toast } = useToast();
const addComment = useDiffCommentsStore((state) => state.addComment);
const removeComment = useDiffCommentsStore((state) => state.removeComment);
const comments = useFileComments(sessionId ?? '', path);
const language = getMonacoLanguage(path);
// LSP integration
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const lspSessionId = sessionId ?? activeSessionId ?? null;
const { status: lspStatus, lspLanguage, toggle: toggleLsp } = useLsp(lspSessionId, language);
const hasLspActive = lspStatus.state === 'ready';
// Use absolute path for Monaco model URI so the LSP server receives correct file:// URIs
const monacoPath = worktreePath ? `${worktreePath}/${path}` : path;
// LSP document sync: open/close document when LSP becomes ready or component mounts/unmounts
const documentUri = `file://${monacoPath}`;
useEffect(() => {
if (!hasLspActive || !lspSessionId || !lspLanguage) return;
lspClientManager.openDocument(lspSessionId, lspLanguage, documentUri, language, content);
return () => {
lspClientManager.closeDocument(lspSessionId, lspLanguage, documentUri);
};
// Only run on mount/unmount and when LSP readiness changes — NOT on content changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasLspActive, lspSessionId, lspLanguage, documentUri, language]);
// LSP document change: notify server of content changes (debounced)
const changeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!hasLspActive || !lspSessionId || !lspLanguage) return;
if (changeTimerRef.current) clearTimeout(changeTimerRef.current);
changeTimerRef.current = setTimeout(() => {
lspClientManager.changeDocument(lspSessionId, lspLanguage, documentUri, content);
}, 300);
return () => {
if (changeTimerRef.current) clearTimeout(changeTimerRef.current);
};
}, [hasLspActive, lspSessionId, lspLanguage, documentUri, content]);
// Toast on LSP status changes
const lspStateForToast = lspStatus.state;
const lspReasonForToast = 'reason' in lspStatus ? lspStatus.reason : null;
useEffect(() => {
if (lspStateForToast === 'installing') {
toast({
title: 'Installing language server',
description: 'This may take a moment...',
});
} else if (lspStateForToast === 'unavailable' && lspReasonForToast) {
toast({
title: 'Language server not found',
description: `${lspReasonForToast}. Enable auto-install in Settings → Editors.`,
});
} else if (lspStateForToast === 'error' && lspReasonForToast) {
toast({
title: 'LSP error',
description: lspReasonForToast,
});
}
}, [lspStateForToast, lspReasonForToast, toast]);
// Track mouse position
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
mousePositionRef.current = { x: e.clientX, y: e.clientY };
};
document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}, []);
// Handle editor mount
const handleEditorDidMount: OnMount = useCallback(
(editor) => {
editorRef.current = editor;
decorationsRef.current = editor.createDecorationsCollection([]);
diffDecorationsRef.current = editor.createDecorationsCollection([]);
// Jump to pending cursor position (e.g. from LSP Go-to-Definition)
const pendingPos = consumePendingCursorPosition(path);
if (pendingPos) {
editor.setPosition({ lineNumber: pendingPos.line, column: pendingPos.column });
editor.revealLineInCenter(pendingPos.line);
}
// Cmd/Ctrl+S to save
editor.addCommand(
// eslint-disable-next-line no-bitwise
(window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyMod.CtrlCmd |
(window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyCode.KeyS,
() => {
onSave();
}
);
// Selection change listener for comments
if (enableComments && sessionId) {
const disposable = editor.onDidChangeCursorSelection(() => {
const selection = editor.getSelection();
if (!selection || selection.isEmpty()) {
setCurrentSelection(null);
return;
}
const model = editor.getModel();
if (!model) return;
const text = model.getValueInRange(selection);
if (!text.trim()) {
setCurrentSelection(null);
return;
}
setCurrentSelection({
text,
startLine: selection.startLineNumber,
endLine: selection.endLineNumber,
});
});
disposablesRef.current.push(disposable);
}
// Gutter click for viewing comments
const glyphDisposable = editor.onMouseDown((e) => {
if (e.target.type === 2 /* GUTTER_GLYPH_MARGIN */ || e.target.type === 3 /* GUTTER_LINE_NUMBERS */) {
const lineNumber = e.target.position?.lineNumber;
if (!lineNumber) return;
const lineComments = comments.filter(
(c) => lineNumber >= c.startLine && lineNumber <= c.endLine
);
if (lineComments.length > 0 && e.event.browserEvent) {
setCommentView({
comments: lineComments,
position: { x: e.event.browserEvent.clientX, y: e.event.browserEvent.clientY },
});
}
}
});
disposablesRef.current.push(glyphDisposable);
},
[path, onSave, enableComments, sessionId, comments]
);
// Cleanup disposables
useEffect(() => {
return () => {
for (const d of disposablesRef.current) d.dispose();
disposablesRef.current = [];
};
}, []);
// Update decorations when comments change
useEffect(() => {
if (!decorationsRef.current || !editorRef.current) return;
const decorations: monacoEditor.IModelDeltaDecoration[] = [];
const linesWithComments = new Set<number>();
const firstLines = new Set<number>();
for (const comment of comments) {
firstLines.add(comment.startLine);
for (let line = comment.startLine; line <= comment.endLine; line++) {
linesWithComments.add(line);
}
}
for (const lineNum of linesWithComments) {
decorations.push({
range: {
startLineNumber: lineNum,
startColumn: 1,
endLineNumber: lineNum,
endColumn: 1,
},
options: {
isWholeLine: true,
className: 'monaco-comment-line',
glyphMarginClassName: firstLines.has(lineNum)
? 'monaco-comment-glyph'
: 'monaco-comment-glyph-bg',
glyphMarginHoverMessage: {
value: `${comments.filter((c) => lineNum >= c.startLine && lineNum <= c.endLine).length} comment(s) - click to view`,
},
},
});
}
decorationsRef.current.set(decorations);
}, [comments]);
// Diff gutter indicators
useEffect(() => {
if (!diffDecorationsRef.current || !editorRef.current) return;
if (!showDiffIndicators || !isDirty || !originalContent) {
diffDecorationsRef.current.set([]);
return;
}
const changes = diffLines(originalContent, content);
const decorations: monacoEditor.IModelDeltaDecoration[] = [];
let currentLine = 1;
for (let i = 0; i < changes.length; i++) {
const change = changes[i];
const lineCount = change.count ?? 0;
if (change.removed) {
// Check if next change is an addition (modified lines)
const next = changes[i + 1];
if (next && next.added) {
// Modified: removed + added pair
const addedLineCount = next.count ?? 0;
for (let j = 0; j < addedLineCount; j++) {
decorations.push({
range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 },
options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-modified-gutter' },
});
}
currentLine += addedLineCount;
i++; // skip the added part
} else {
// Standalone deletion: red indicator at previous line
const indicatorLine = Math.max(1, currentLine - 1);
decorations.push({
range: { startLineNumber: indicatorLine, startColumn: 1, endLineNumber: indicatorLine, endColumn: 1 },
options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-deleted-gutter' },
});
}
} else if (change.added) {
// Standalone addition
for (let j = 0; j < lineCount; j++) {
decorations.push({
range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 },
options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-added-gutter' },
});
}
currentLine += lineCount;
} else {
// Unchanged
currentLine += lineCount;
}
}
diffDecorationsRef.current.set(decorations);
}, [content, originalContent, showDiffIndicators, isDirty]);
// Show floating button after mouse up
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || !enableComments || !sessionId) return;
const handleMouseUp = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('.floating-comment-btn')) return;
setTimeout(() => {
if (currentSelection) {
setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
}
}, 10);
};
const handleMouseDown = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('.floating-comment-btn')) return;
setFloatingButtonPos(null);
};
wrapper.addEventListener('mouseup', handleMouseUp);
wrapper.addEventListener('mousedown', handleMouseDown);
return () => {
wrapper.removeEventListener('mouseup', handleMouseUp);
wrapper.removeEventListener('mousedown', handleMouseDown);
};
}, [enableComments, sessionId, currentSelection]);
// Cmd+I for comment popover
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || !enableComments || !sessionId) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
if (!currentSelection || !floatingButtonPos) return;
e.preventDefault();
e.stopPropagation();
setTextSelection({ ...currentSelection, position: floatingButtonPos });
setFloatingButtonPos(null);
}
};
wrapper.addEventListener('keydown', handleKeyDown, true);
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
}, [enableComments, sessionId, currentSelection, floatingButtonPos]);
// Escape to close popovers
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (textSelection) setTextSelection(null);
if (commentView) setCommentView(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [textSelection, commentView]);
const handleChange: OnChange = useCallback(
(value) => {
if (value !== undefined) onChange(value);
},
[onChange]
);
const handleFloatingButtonClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!currentSelection || !floatingButtonPos) return;
setTextSelection({ ...currentSelection, position: floatingButtonPos });
setFloatingButtonPos(null);
},
[currentSelection, floatingButtonPos]
);
const handleCommentSubmit = useCallback(
(annotation: string) => {
if (!textSelection || !sessionId) return;
const comment: DiffComment = {
id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sessionId,
filePath: path,
startLine: textSelection.startLine,
endLine: textSelection.endLine,
side: 'additions',
codeContent: textSelection.text,
annotation,
createdAt: new Date().toISOString(),
status: 'pending',
};
addComment(comment);
setTextSelection(null);
// Clear editor selection
const editor = editorRef.current;
if (editor) {
const pos = editor.getPosition();
if (pos) editor.setSelection({ startLineNumber: pos.lineNumber, startColumn: pos.column, endLineNumber: pos.lineNumber, endColumn: pos.column });
}
toast({ title: 'Comment added', description: 'Your comment will be sent with your next message.' });
},
[textSelection, sessionId, path, addComment, toast]
);
const handlePopoverClose = useCallback(() => setTextSelection(null), []);
const handleDeleteComment = useCallback(
(commentId: string) => {
if (!sessionId) return;
removeComment(sessionId, path, commentId);
if (commentView && commentView.comments.length <= 1) {
setCommentView(null);
} else if (commentView) {
setCommentView({
...commentView,
comments: commentView.comments.filter((c) => c.id !== commentId),
});
}
toast({ title: 'Comment deleted' });
},
[sessionId, path, removeComment, commentView, toast]
);
const handleCommentViewClose = useCallback(() => setCommentView(null), []);
// Diff stats
const diffStats = useMemo(() => {
if (!isDirty) return null;
const originalLines = originalContent.split('\n');
const currentLines = content.split('\n');
let additions = 0;
let deletions = 0;
const maxLen = Math.max(originalLines.length, currentLines.length);
for (let i = 0; i < maxLen; i++) {
const origLine = originalLines[i];
const currLine = currentLines[i];
if (origLine === undefined && currLine !== undefined) additions++;
else if (origLine !== undefined && currLine === undefined) deletions++;
else if (origLine !== currLine) { additions++; deletions++; }
}
return { additions, deletions };
}, [isDirty, content, originalContent]);
return (
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
{/* Editor header */}
<div className="flex items-center justify-between px-2 border-foreground/10 border-b">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
{isDirty && diffStats && (
<span className="text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
<div className="flex items-center gap-1">
{enableComments && sessionId && comments.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
<IconMessagePlus className="h-3.5 w-3.5" />
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
</div>
)}
<LspStatusButton status={lspStatus} lspLanguage={lspLanguage} onToggle={toggleLsp} />
{isDirty && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={() => setShowDiffIndicators(!showDiffIndicators)}
className={`h-8 w-8 p-0 cursor-pointer ${showDiffIndicators ? 'text-foreground' : 'text-muted-foreground'}`}
>
<IconArrowsDiff className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{showDiffIndicators ? 'Hide diff indicators' : 'Show diff indicators'}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={() => setWrapEnabled(!wrapEnabled)}
className={`h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? 'text-foreground' : 'text-muted-foreground'}`}
>
{wrapEnabled ? <IconTextWrap className="h-4 w-4" /> : <IconTextWrapDisabled className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}</TooltipContent>
</Tooltip>
<FileActionsDropdown filePath={path} sessionId={sessionId} size="sm" />
{onDelete && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={onDelete}
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
>
<IconTrash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete file</TooltipContent>
</Tooltip>
)}
<Button
size="sm"
variant="default"
onClick={onSave}
disabled={!isDirty || isSaving}
className="cursor-pointer gap-2"
>
{isSaving ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<IconDeviceFloppy className="h-4 w-4" />
Save
<span className="text-xs text-muted-foreground">
({navigator.platform.includes('Mac') ? '\u2318' : 'Ctrl'}+S)
</span>
</>
)}
</Button>
</div>
</div>
{/* Monaco editor */}
<div className="flex-1 overflow-hidden relative">
<Editor
height="100%"
language={language}
path={monacoPath}
value={content}
theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'}
onChange={handleChange}
onMount={handleEditorDidMount}
options={{
fontSize: EDITOR_FONT_SIZE,
fontFamily: EDITOR_FONT_FAMILY,
lineHeight: 18,
minimap: { enabled: false },
wordWrap: wrapEnabled ? 'on' : 'off',
scrollBeyondLastLine: false,
smoothScrolling: true,
cursorSmoothCaretAnimation: 'on',
glyphMargin: enableComments,
folding: true,
lineNumbers: 'on',
renderLineHighlight: 'line',
automaticLayout: true,
scrollbar: {
verticalScrollbarSize: 10,
horizontalScrollbarSize: 10,
},
padding: { top: 4 },
'semanticHighlighting.enabled': true,
}}
loading={
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Loading editor...
</div>
}
/>
{/* Floating comment button */}
{floatingButtonPos && !textSelection && (
<Button
size="sm"
variant="secondary"
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
style={{ left: floatingButtonPos.x + 8, top: floatingButtonPos.y + 8 }}
onMouseDown={(e) => e.stopPropagation()}
onClick={handleFloatingButtonClick}
>
<IconMessagePlus className="h-3.5 w-3.5" />
Comment
</Button>
)}
{textSelection && (
<EditorCommentPopover
selectedText={textSelection.text}
lineRange={{ start: textSelection.startLine, end: textSelection.endLine }}
position={textSelection.position}
onSubmit={handleCommentSubmit}
onClose={handlePopoverClose}
/>
)}
{commentView && (
<CommentViewPopover
comments={commentView.comments}
position={commentView.position}
onDelete={handleDeleteComment}
onClose={handleCommentViewClose}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,555 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react';
import type { editor as monacoEditor } from 'monaco-editor';
import { useTheme } from 'next-themes';
import { cn } from '@kandev/ui/lib/utils';
import { Button } from '@kandev/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@kandev/ui/tooltip';
import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconPencil, IconArrowBackUp, IconFoldDown, IconFold } from '@tabler/icons-react';
import type { FileDiffData, DiffComment } from '@/lib/diff/types';
import { getMonacoLanguage } from '@/lib/editor/language-map';
import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from '@/lib/theme/editor-theme';
import { useDiffComments } from '@/components/diff/use-diff-comments';
import { CommentForm } from '@/components/diff/comment-form';
import { CommentDisplay } from '@/components/diff/comment-display';
import { initMonacoThemes } from './monaco-init';
initMonacoThemes();
// Global view mode sync (same as pierre diff-viewer)
const DIFF_VIEW_MODE_KEY = 'diff-view-mode';
const DEFAULT_VIEW_MODE = 'unified' as const;
const VIEW_MODE_CHANGE_EVENT = 'diff-view-mode-change';
type ViewMode = 'split' | 'unified';
function getStoredViewMode(): ViewMode {
if (typeof window === 'undefined') return DEFAULT_VIEW_MODE;
const stored = localStorage.getItem(DIFF_VIEW_MODE_KEY);
return stored === 'split' || stored === 'unified' ? stored : DEFAULT_VIEW_MODE;
}
function setStoredViewMode(mode: ViewMode): void {
localStorage.setItem(DIFF_VIEW_MODE_KEY, mode);
window.dispatchEvent(new CustomEvent(VIEW_MODE_CHANGE_EVENT, { detail: mode }));
}
function useGlobalViewMode(): [ViewMode, (mode: ViewMode) => void] {
const subscribe = useCallback((callback: () => void) => {
window.addEventListener(VIEW_MODE_CHANGE_EVENT, callback);
window.addEventListener('storage', callback);
return () => {
window.removeEventListener(VIEW_MODE_CHANGE_EVENT, callback);
window.removeEventListener('storage', callback);
};
}, []);
const getSnapshot = useCallback(() => getStoredViewMode(), []);
const getServerSnapshot = useCallback(() => DEFAULT_VIEW_MODE, []);
const viewMode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return [viewMode, setStoredViewMode];
}
// Global folding toggle sync (same pattern as view mode)
const DIFF_FOLD_KEY = 'diff-fold-unchanged';
const DEFAULT_FOLD = true;
const FOLD_CHANGE_EVENT = 'diff-fold-change';
function getStoredFolding(): boolean {
if (typeof window === 'undefined') return DEFAULT_FOLD;
const stored = localStorage.getItem(DIFF_FOLD_KEY);
if (stored === null) return DEFAULT_FOLD;
return stored === 'true';
}
function setStoredFolding(fold: boolean): void {
localStorage.setItem(DIFF_FOLD_KEY, String(fold));
window.dispatchEvent(new CustomEvent(FOLD_CHANGE_EVENT, { detail: fold }));
}
function useGlobalFolding(): [boolean, (fold: boolean) => void] {
const subscribe = useCallback((callback: () => void) => {
window.addEventListener(FOLD_CHANGE_EVENT, callback);
window.addEventListener('storage', callback);
return () => {
window.removeEventListener(FOLD_CHANGE_EVENT, callback);
window.removeEventListener('storage', callback);
};
}, []);
const getSnapshot = useCallback(() => getStoredFolding(), []);
const getServerSnapshot = useCallback(() => DEFAULT_FOLD, []);
const foldUnchanged = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return [foldUnchanged, setStoredFolding];
}
interface MonacoDiffViewerProps {
data: FileDiffData;
sessionId?: string;
onCommentAdd?: (comment: DiffComment) => void;
onCommentDelete?: (commentId: string) => void;
comments?: DiffComment[];
className?: string;
compact?: boolean;
hideHeader?: boolean;
onOpenFile?: (filePath: string) => void;
onRevert?: (filePath: string) => void;
wordWrap?: boolean;
}
export function MonacoDiffViewer({
data,
sessionId,
onCommentAdd,
onCommentDelete,
comments: externalComments,
className,
compact = false,
hideHeader = false,
onOpenFile,
onRevert,
wordWrap: wordWrapProp,
}: MonacoDiffViewerProps) {
const { resolvedTheme } = useTheme();
const [globalViewMode, setGlobalViewMode] = useGlobalViewMode();
const [foldUnchanged, setFoldUnchanged] = useGlobalFolding();
const [wordWrapLocal, setWordWrap] = useState(false);
const wordWrap = wordWrapProp ?? wordWrapLocal;
const diffEditorRef = useRef<monacoEditor.IStandaloneDiffEditor | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// Context menu state
type ContextMenuState = {
x: number;
y: number;
lineNumber: number;
side: 'original' | 'modified';
isChangedLine: boolean;
lineContent: string;
} | null;
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
// Comment form state
const [showCommentForm, setShowCommentForm] = useState(false);
const [selectedLineRange, setSelectedLineRange] = useState<{ start: number; end: number; side: string } | null>(null);
const {
comments: internalComments,
addComment,
removeComment,
updateComment,
editingCommentId,
setEditingComment,
} = useDiffComments({
sessionId: sessionId || '',
filePath: data.filePath,
diff: data.diff,
newContent: data.newContent,
oldContent: data.oldContent,
});
const comments = externalComments || internalComments;
const language = getMonacoLanguage(data.filePath);
const showHeader = !hideHeader && !compact;
// Parse original/modified content from diff data
// When only a unified diff string is provided (e.g. git diffs), reconstruct
// the original and modified content by parsing the patch hunks.
const { original, modified } = useMemo(() => {
if (data.oldContent || data.newContent) {
return {
original: data.oldContent ?? '',
modified: data.newContent ?? '',
};
}
// Reconstruct from unified diff patch
if (data.diff) {
const originalLines: string[] = [];
const modifiedLines: string[] = [];
const lines = data.diff.split('\n');
for (const line of lines) {
// Skip diff headers
if (line.startsWith('diff ') || line.startsWith('index ') ||
line.startsWith('--- ') || line.startsWith('+++ ') ||
line.startsWith('@@')) {
continue;
}
if (line.startsWith('-')) {
originalLines.push(line.slice(1));
} else if (line.startsWith('+')) {
modifiedLines.push(line.slice(1));
} else if (line.startsWith(' ') || line === '') {
// Context line (or empty line between hunks)
const content = line.startsWith(' ') ? line.slice(1) : line;
originalLines.push(content);
modifiedLines.push(content);
} else if (line.startsWith('\\')) {
// "\ No newline at end of file" — skip
continue;
}
}
return {
original: originalLines.join('\n'),
modified: modifiedLines.join('\n'),
};
}
return { original: '', modified: '' };
}, [data.oldContent, data.newContent, data.diff]);
const handleDiffEditorMount: DiffOnMount = useCallback((editor) => {
diffEditorRef.current = editor;
// Register context menu on both sides
const modifiedEditor = editor.getModifiedEditor();
const originalEditor = editor.getOriginalEditor();
const handleContextMenu = (side: 'original' | 'modified') => (e: monacoEditor.IEditorMouseEvent) => {
if (e.target.position) {
e.event.preventDefault();
e.event.stopPropagation();
const lineNumber = e.target.position.lineNumber;
const targetEditor = side === 'modified' ? modifiedEditor : originalEditor;
const model = targetEditor.getModel();
const lineContent = model ? model.getLineContent(lineNumber) : '';
const lineChanges = editor.getLineChanges();
let isChangedLine = false;
if (lineChanges) {
for (const change of lineChanges) {
if (side === 'modified') {
if (lineNumber >= change.modifiedStartLineNumber && lineNumber <= change.modifiedEndLineNumber) {
isChangedLine = true;
break;
}
} else {
if (lineNumber >= change.originalStartLineNumber && lineNumber <= change.originalEndLineNumber) {
isChangedLine = true;
break;
}
}
}
}
setContextMenu({
x: e.event.posx,
y: e.event.posy,
lineNumber,
side,
isChangedLine,
lineContent,
});
}
};
modifiedEditor.onContextMenu(handleContextMenu('modified'));
originalEditor.onContextMenu(handleContextMenu('original'));
}, []);
// Handle comment submission
const handleCommentSubmit = useCallback(
(content: string) => {
if (!selectedLineRange) return;
if (onCommentAdd && externalComments !== undefined) {
const comment: DiffComment = {
id: `${data.filePath}-${Date.now()}`,
sessionId: sessionId || '',
filePath: data.filePath,
startLine: Math.min(selectedLineRange.start, selectedLineRange.end),
endLine: Math.max(selectedLineRange.start, selectedLineRange.end),
side: (selectedLineRange.side || 'additions') as DiffComment['side'],
codeContent: '',
annotation: content,
createdAt: new Date().toISOString(),
status: 'pending',
};
onCommentAdd(comment);
} else if (sessionId) {
addComment(
{
start: selectedLineRange.start,
end: selectedLineRange.end,
side: selectedLineRange.side as 'additions' | 'deletions',
},
content
);
}
setShowCommentForm(false);
setSelectedLineRange(null);
},
[selectedLineRange, sessionId, data.filePath, addComment, onCommentAdd, externalComments]
);
const handleCommentDelete = useCallback(
(commentId: string) => {
if (onCommentDelete && externalComments !== undefined) {
onCommentDelete(commentId);
} else {
removeComment(commentId);
}
},
[removeComment, onCommentDelete, externalComments]
);
const handleCommentUpdate = useCallback(
(commentId: string, content: string) => {
updateComment(commentId, { annotation: content });
setEditingComment(null);
},
[updateComment, setEditingComment]
);
// Close context menu on click or Escape
useEffect(() => {
if (!contextMenu) return;
const handleClose = () => setContextMenu(null);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setContextMenu(null);
};
window.addEventListener('mousedown', handleClose);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('mousedown', handleClose);
window.removeEventListener('keydown', handleKeyDown);
};
}, [contextMenu]);
// Copy all changed lines from the diff
const copyAllChangedLines = useCallback(() => {
const editor = diffEditorRef.current;
if (!editor) return;
const lineChanges = editor.getLineChanges();
if (!lineChanges) return;
const modifiedModel = editor.getModifiedEditor().getModel();
if (!modifiedModel) return;
const changedLines: string[] = [];
for (const change of lineChanges) {
if (change.modifiedStartLineNumber <= change.modifiedEndLineNumber) {
for (let i = change.modifiedStartLineNumber; i <= change.modifiedEndLineNumber; i++) {
changedLines.push(modifiedModel.getLineContent(i));
}
}
}
navigator.clipboard.writeText(changedLines.join('\n'));
setContextMenu(null);
}, []);
if (!data.oldContent && !data.newContent && !data.diff) {
return (
<div className={cn(
'rounded-md border border-border/50 bg-muted/20 p-4 text-muted-foreground',
compact ? 'text-xs' : 'text-sm',
className
)}>
No diff available
</div>
);
}
return (
<div ref={wrapperRef} className={cn('monaco-diff-viewer relative', className)}>
{/* Toolbar */}
{showHeader && (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/50 bg-card/50 rounded-t-md text-xs text-muted-foreground">
<span className="font-mono truncate">{data.filePath}</span>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100" onClick={() => navigator.clipboard.writeText(data.diff || '')}>
<IconCopy className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy diff</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn('h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100', foldUnchanged && 'opacity-100 bg-muted')}
onClick={() => setFoldUnchanged(!foldUnchanged)}
>
{foldUnchanged ? <IconFoldDown className="h-3.5 w-3.5" /> : <IconFold className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>{foldUnchanged ? 'Show all lines' : 'Fold unchanged lines'}</TooltipContent>
</Tooltip>
{onRevert && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100" onClick={() => onRevert(data.filePath)}>
<IconArrowBackUp className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Revert changes</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn('h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100', wordWrap && 'opacity-100 bg-muted')}
onClick={() => setWordWrap(!wordWrap)}
>
<IconTextWrap className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle word wrap</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
onClick={() => setGlobalViewMode(globalViewMode === 'split' ? 'unified' : 'split')}
>
{globalViewMode === 'split' ? <IconLayoutRows className="h-3.5 w-3.5" /> : <IconLayoutColumns className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>{globalViewMode === 'split' ? 'Switch to unified view' : 'Switch to split view'}</TooltipContent>
</Tooltip>
{onOpenFile && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100" onClick={() => onOpenFile(data.filePath)}>
<IconPencil className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
{/* Diff editor */}
<div className={cn('overflow-hidden', showHeader ? 'rounded-b-md' : 'rounded-md', 'border border-border/50')}>
<DiffEditor
height={compact ? '200px' : '400px'}
language={language}
original={original}
modified={modified}
theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'}
onMount={handleDiffEditorMount}
options={{
fontSize: compact ? 11 : EDITOR_FONT_SIZE,
fontFamily: EDITOR_FONT_FAMILY,
lineHeight: compact ? 16 : 18,
minimap: { enabled: false },
wordWrap: wordWrap ? 'on' : 'off',
readOnly: true,
contextmenu: false,
renderSideBySide: globalViewMode === 'split',
scrollBeyondLastLine: false,
smoothScrolling: true,
automaticLayout: true,
renderOverviewRuler: false,
hideUnchangedRegions: {
enabled: foldUnchanged,
contextLineCount: 3,
minimumLineCount: 3,
revealLineCount: 20,
},
folding: !compact,
lineNumbers: compact ? 'off' : 'on',
glyphMargin: false,
scrollbar: {
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
alwaysConsumeMouseWheel: false,
},
padding: { top: 2 },
}}
loading={
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Loading diff...
</div>
}
/>
</div>
{/* Comment annotations below the diff */}
{comments.length > 0 && (
<div className="mt-2 space-y-2">
{comments.map((comment) => (
<div key={comment.id} className="px-2">
{editingCommentId === comment.id ? (
<CommentForm
initialContent={comment.annotation}
onSubmit={(content) => handleCommentUpdate(comment.id, content)}
onCancel={() => setEditingComment(null)}
isEditing
/>
) : (
<CommentDisplay
comment={comment}
onDelete={() => handleCommentDelete(comment.id)}
onEdit={() => setEditingComment(comment.id)}
showCode={false}
/>
)}
</div>
))}
</div>
)}
{/* New comment form */}
{showCommentForm && selectedLineRange && (
<div className="mt-2 px-2">
<CommentForm
onSubmit={handleCommentSubmit}
onCancel={() => {
setShowCommentForm(false);
setSelectedLineRange(null);
}}
/>
</div>
)}
{/* Context menu */}
{contextMenu && (
<div
className="fixed z-50 min-w-[180px] rounded-md border border-border bg-popover text-popover-foreground shadow-md ring-1 ring-border/10 py-1 text-xs"
style={{ left: contextMenu.x, top: contextMenu.y }}
onMouseDown={(e) => e.stopPropagation()}
>
<button
className="flex w-full items-center px-3 py-1.5 hover:bg-accent hover:text-accent-foreground cursor-pointer"
onClick={copyAllChangedLines}
>
Copy all changed lines
</button>
{contextMenu.isChangedLine && (
<button
className="flex w-full items-center px-3 py-1.5 hover:bg-accent hover:text-accent-foreground cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(contextMenu.lineContent);
setContextMenu(null);
}}
>
Copy line {contextMenu.lineNumber}
</button>
)}
{onRevert && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center px-3 py-1.5 hover:bg-accent hover:text-accent-foreground cursor-pointer text-destructive"
onClick={() => {
onRevert(data.filePath);
setContextMenu(null);
}}
>
Revert all changes
</button>
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,108 @@
import type { Monaco } from '@monaco-editor/react';
import type { editor as monacoEditor, Uri, IRange, IPosition } from 'monaco-editor';
import { KANDEV_MONACO_DARK, KANDEV_MONACO_LIGHT } from '@/lib/theme/editor-theme';
let initialized = false;
let monacoInstance: Monaco | null = null;
export function initMonacoThemes() {
if (initialized || typeof window === 'undefined') return;
initialized = true;
// Dynamic import ensures monaco-loader runs (which sets MonacoEnvironment +
// loader.config) before any <Editor> component mounts.
import('./monaco-loader').then(({ monaco }) => {
monacoInstance = monaco;
monaco.editor.defineTheme('kandev-dark', KANDEV_MONACO_DARK);
monaco.editor.defineTheme('kandev-light', KANDEV_MONACO_LIGHT);
// Disable Monaco's built-in TS/JS diagnostics — they can't resolve project-level
// path aliases (@/, tsconfig paths). LSP diagnostics are applied separately via
// setModelMarkers() and work independently of this setting.
setMonacoDiagnostics(false);
// Enable JSX so .tsx/.jsx files parse correctly
const tsDefaults = monaco.languages.typescript.typescriptDefaults;
const jsDefaults = monaco.languages.typescript.javascriptDefaults;
tsDefaults.setCompilerOptions({
jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
allowJs: true,
allowNonTsExtensions: true,
target: monaco.languages.typescript.ScriptTarget.ESNext,
module: monaco.languages.typescript.ModuleKind.ESNext,
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
});
jsDefaults.setCompilerOptions({
jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
allowJs: true,
allowNonTsExtensions: true,
target: monaco.languages.typescript.ScriptTarget.ESNext,
module: monaco.languages.typescript.ModuleKind.ESNext,
});
// Register editor opener to intercept Go-to-Definition navigation.
// When the LSP returns a file:// URI, this opens the file in a dockview tab
// instead of Monaco trying to navigate to a non-existent model.
// Dynamic import to avoid circular dependency (lsp-client-manager → monaco-init).
import('@/lib/lsp/lsp-client-manager').then(({ lspClientManager }) => {
monaco.editor.registerEditorOpener({
openCodeEditor(_source: monacoEditor.ICodeEditor, resource: Uri, selectionOrPosition?: IRange | IPosition) {
const uri = resource.toString();
const opener = lspClientManager.getFileOpener();
if (opener && uri.startsWith('file:')) {
// Extract line/column from the selection or position
let line: number | undefined;
let column: number | undefined;
if (selectionOrPosition) {
if ('lineNumber' in selectionOrPosition) {
// IPosition
line = selectionOrPosition.lineNumber;
column = selectionOrPosition.column;
} else if ('startLineNumber' in selectionOrPosition) {
// IRange
line = selectionOrPosition.startLineNumber;
column = selectionOrPosition.startColumn;
}
}
opener(uri, line, column);
return true;
}
return false;
},
});
});
});
}
export function getMonacoInstance(): Monaco | null {
return monacoInstance;
}
export function setMonacoDiagnostics(enabled: boolean) {
if (!monacoInstance) return;
const tsDefaults = monacoInstance.languages.typescript.typescriptDefaults;
const jsDefaults = monacoInstance.languages.typescript.javascriptDefaults;
const diagOptions = {
noSemanticValidation: !enabled,
noSuggestionDiagnostics: !enabled,
noSyntaxValidation: !enabled,
};
tsDefaults.setDiagnosticsOptions(diagOptions);
jsDefaults.setDiagnosticsOptions(diagOptions);
}
/**
* Suppress or restore Monaco's built-in TS/JS language providers (hover,
* completions, definitions, references, etc.). When LSP is active these
* should be suppressed to avoid duplicate results.
*
* Uses the shared flag in builtin-providers.ts which is checked both at
* registration time (to decide whether to wrap a provider) and at call time
* (to decide whether wrapped providers return empty results).
*/
export function setMonacoBuiltinProviders(enabled: boolean) {
import('./builtin-providers').then(({ setBuiltinTsSuppressed }) => {
setBuiltinTsSuppressed(!enabled);
});
}
@@ -0,0 +1,92 @@
'use client';
import { useMemo } from 'react';
import { DiffEditor } from '@monaco-editor/react';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import type { FileDiffData } from '@/lib/diff/types';
import { getMonacoLanguage } from '@/lib/editor/language-map';
import { EDITOR_FONT_FAMILY } from '@/lib/theme/editor-theme';
import { initMonacoThemes } from './monaco-init';
initMonacoThemes();
type MonacoInlineDiffProps = {
data: FileDiffData;
className?: string;
};
export function MonacoInlineDiff({ data, className }: MonacoInlineDiffProps) {
const { resolvedTheme } = useTheme();
const language = getMonacoLanguage(data.filePath);
const { original, modified } = useMemo(() => {
if (data.oldContent || data.newContent) {
return {
original: data.oldContent ?? '',
modified: data.newContent ?? '',
};
}
// Reconstruct from unified diff patch
if (data.diff) {
const originalLines: string[] = [];
const modifiedLines: string[] = [];
for (const line of data.diff.split('\n')) {
if (line.startsWith('diff ') || line.startsWith('index ') ||
line.startsWith('--- ') || line.startsWith('+++ ') ||
line.startsWith('@@') || line.startsWith('\\')) continue;
if (line.startsWith('-')) {
originalLines.push(line.slice(1));
} else if (line.startsWith('+')) {
modifiedLines.push(line.slice(1));
} else {
const content = line.startsWith(' ') ? line.slice(1) : line;
originalLines.push(content);
modifiedLines.push(content);
}
}
return { original: originalLines.join('\n'), modified: modifiedLines.join('\n') };
}
return { original: '', modified: '' };
}, [data.oldContent, data.newContent, data.diff]);
const lineCount = Math.max(
original.split('\n').length,
modified.split('\n').length
);
const height = Math.min(Math.max(lineCount * 16 + 8, 48), 300);
return (
<div className={cn('mt-3 rounded-md border border-border/50 bg-background/60 px-3 py-2 text-xs overflow-hidden', className)}>
<DiffEditor
height={height}
language={language}
original={original}
modified={modified}
theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'}
options={{
readOnly: true,
fontSize: 11,
fontFamily: EDITOR_FONT_FAMILY,
lineHeight: 16,
minimap: { enabled: false },
wordWrap: 'on',
renderSideBySide: false,
scrollBeyondLastLine: false,
lineNumbers: 'off',
glyphMargin: false,
folding: false,
renderOverviewRuler: false,
scrollbar: { vertical: 'hidden', horizontal: 'hidden', alwaysConsumeMouseWheel: false },
automaticLayout: true,
domReadOnly: true,
contextmenu: false,
padding: { top: 2, bottom: 2 },
}}
loading={null}
/>
</div>
);
}
@@ -0,0 +1,116 @@
import * as monacoImport from 'monaco-editor';
import type { Monaco } from '@monaco-editor/react';
import type { IDisposable } from 'monaco-editor';
import { loader } from '@monaco-editor/react';
import { isBuiltinTsSuppressed } from './builtin-providers';
// Cast to Monaco type (from @monaco-editor/react) which has the full
// languages.typescript typings. The main 'monaco-editor' export marks
// languages.typescript as deprecated in favor of top-level typescript
// namespace, but @monaco-editor/react's Monaco type still uses it.
const monaco = monacoImport as unknown as Monaco;
// ---------------------------------------------------------------------------
// Built-in TS/JS provider suppression
// ---------------------------------------------------------------------------
// Monaco v0.55.1's setModeConfiguration fires onDidChange, but the
// tsMode.js setupMode() function never subscribes to it — providers are
// registered once and never torn down. So we intercept provider registration
// to wrap built-in TS/JS providers with a suppression flag check. When LSP
// is active, the wrappers return null/empty instead of calling the original.
//
// Key: we check `isBuiltinTsSuppressed()` at REGISTRATION time. If the flag
// is already true when a provider is registered, it means the LSP client is
// registering its own providers — those should NOT be wrapped. Only providers
// registered while the flag is false (Monaco's built-in ones) get wrapped.
// ---------------------------------------------------------------------------
const TS_LANGUAGES = new Set([
'typescript',
'javascript',
'typescriptreact',
'javascriptreact',
]);
// Configure workers before Monaco initializes.
// The `new URL('...', import.meta.url)` pattern is recognized by Turbopack/webpack
// to bundle each worker as a separate chunk.
if (typeof window !== 'undefined') {
self.MonacoEnvironment = {
getWorker(_workerId: string, label: string) {
switch (label) {
case 'json':
return new Worker(new URL('monaco-editor/esm/vs/language/json/json.worker.js', import.meta.url), { type: 'module' });
case 'css':
case 'scss':
case 'less':
return new Worker(new URL('monaco-editor/esm/vs/language/css/css.worker.js', import.meta.url), { type: 'module' });
case 'html':
case 'handlebars':
case 'razor':
return new Worker(new URL('monaco-editor/esm/vs/language/html/html.worker.js', import.meta.url), { type: 'module' });
case 'typescript':
case 'javascript':
return new Worker(new URL('monaco-editor/esm/vs/language/typescript/ts.worker.js', import.meta.url), { type: 'module' });
default:
return new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' });
}
},
};
// Tell @monaco-editor/react to use local monaco instance (no CDN fetch)
loader.config({ monaco: monacoImport });
// Expose on window for code that accesses window.monaco (e.g. keybindings in editor)
(window as { monaco?: Monaco }).monaco = monaco;
// Wrap built-in TS/JS provider registration so we can suppress them at call
// time when an external LSP is active. This MUST happen before the first TS
// model is created (which triggers the lazy tsMode setup), and since this
// module runs at import time, it's guaranteed to be in place early enough.
const langs = monacoImport.languages;
type ProviderRegistrationFn = (selector: string, provider: Record<string, unknown>, ...rest: unknown[]) => IDisposable;
function wrapRegistration(
original: ProviderRegistrationFn,
methodName: string,
emptyResult: unknown,
): ProviderRegistrationFn {
return function (selector: string, provider: Record<string, unknown>, ...rest: unknown[]) {
// Only wrap providers for TS/JS languages that are registered while
// suppression is OFF (= Monaco's built-in providers). When suppression
// is already ON at registration time, it means the LSP client is
// registering its own providers — pass those through unwrapped.
if (
typeof selector === 'string' &&
TS_LANGUAGES.has(selector) &&
typeof provider[methodName] === 'function' &&
!isBuiltinTsSuppressed()
) {
const origMethod = (provider[methodName] as (...a: unknown[]) => unknown).bind(provider);
const wrapped = Object.create(provider);
wrapped[methodName] = function (...args: unknown[]) {
if (isBuiltinTsSuppressed()) return emptyResult;
return origMethod(...args);
};
return original.call(langs, selector, wrapped, ...rest);
}
return original.call(langs, selector, provider, ...rest);
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- monkey-patching Monaco internals
const l = langs as Record<string, any>;
l.registerHoverProvider = wrapRegistration(l.registerHoverProvider.bind(langs), 'provideHover', null);
l.registerCompletionItemProvider = wrapRegistration(l.registerCompletionItemProvider.bind(langs), 'provideCompletionItems', { suggestions: [] });
l.registerSignatureHelpProvider = wrapRegistration(l.registerSignatureHelpProvider.bind(langs), 'provideSignatureHelp', null);
l.registerDefinitionProvider = wrapRegistration(l.registerDefinitionProvider.bind(langs), 'provideDefinition', null);
l.registerReferenceProvider = wrapRegistration(l.registerReferenceProvider.bind(langs), 'provideReferences', null);
l.registerDocumentHighlightProvider = wrapRegistration(l.registerDocumentHighlightProvider.bind(langs), 'provideDocumentHighlights', null);
l.registerCodeActionProvider = wrapRegistration(l.registerCodeActionProvider.bind(langs), 'provideCodeActions', null);
l.registerRenameProvider = wrapRegistration(l.registerRenameProvider.bind(langs), 'provideRenameEdits', null);
l.registerInlayHintsProvider = wrapRegistration(l.registerInlayHintsProvider.bind(langs), 'provideInlayHints', null);
}
export { monaco };
@@ -0,0 +1,5 @@
'use client';
// Re-export the existing Pierre/diffs-based DiffViewer as PierreDiffViewer
// The original implementation stays in components/diff/diff-viewer.tsx
export { DiffViewer as PierreDiffViewer, DiffViewInline as PierreInlineDiff } from '@/components/diff/diff-viewer';
@@ -0,0 +1,221 @@
/**
* TipTap extension for comment highlight decorations.
* Ported from the Milkdown ProseMirror plugin in markdown-editor.tsx.
*/
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
export type CommentHighlight = {
id: string;
selectedText: string;
comment: string;
};
const MIN_COMMENT_TEXT_LENGTH = 3;
const commentHighlightPluginKey = new PluginKey<{
deco: DecorationSet;
comments: CommentHighlight[];
}>('tiptap-comment-highlight');
// Helper to get plain text content and position mapping from a document
function getTextWithPositions(doc: { descendants: (fn: (node: { isText: boolean; text?: string; type: { name: string }; isBlock: boolean }, pos: number) => void) => void }): { text: string; positions: number[] } {
let text = '';
const positions: number[] = [];
doc.descendants((node, pos) => {
if (node.isText && node.text) {
for (let i = 0; i < node.text.length; i++) {
positions.push(pos + i);
text += node.text[i];
}
} else if (node.type.name === 'hardBreak') {
positions.push(-1);
text += ' ';
} else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) {
positions.push(-1);
text += '\n';
}
});
return { text, positions };
}
function normalizeForSearch(text: string): string {
return text
.replace(/\r\n/g, '\n')
.replace(/[\t ]+/g, ' ')
.replace(/\n+/g, '\n')
.trim()
.toLowerCase();
}
function createCommentDecorations(
doc: Parameters<typeof DecorationSet.create>[0],
comments: CommentHighlight[]
): DecorationSet {
const decorations: Decoration[] = [];
if (comments.length === 0) {
return DecorationSet.empty;
}
const { text: fullText, positions } = getTextWithPositions(doc);
const normalizedFullText = normalizeForSearch(fullText);
for (const comment of comments) {
const searchText = normalizeForSearch(comment.selectedText);
if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) continue;
let normalizedIndex = normalizedFullText.indexOf(searchText);
if (normalizedIndex === -1) {
const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]);
if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) {
normalizedIndex = normalizedFullText.indexOf(firstLine);
if (normalizedIndex !== -1) {
addDecorationsForRange(normalizedIndex, firstLine.length, comment, fullText, positions, decorations);
}
}
continue;
}
addDecorationsForRange(normalizedIndex, searchText.length, comment, fullText, positions, decorations);
}
return DecorationSet.create(doc, decorations);
}
function addDecorationsForRange(
normalizedIndex: number,
length: number,
comment: CommentHighlight,
fullText: string,
positions: number[],
decorations: Decoration[]
) {
const normalizedFullText = normalizeForSearch(fullText);
const normalizedChars = normalizedFullText.split('');
const origChars = fullText.split('');
const normToOrig: number[] = [];
let oi = 0;
for (let ni = 0; ni < normalizedChars.length; ni++) {
while (
oi < origChars.length &&
origChars[oi].toLowerCase() !== normalizedChars[ni] &&
/\s/.test(origChars[oi])
) {
oi++;
}
normToOrig[ni] = oi;
oi++;
}
const startOrig = normToOrig[normalizedIndex] ?? 0;
const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig;
let rangeStart = -1;
for (let i = startOrig; i <= endOrig && i < positions.length; i++) {
const pos = positions[i];
if (pos === -1) {
if (rangeStart !== -1) {
const fromPos = positions[rangeStart];
const toPos = positions[i - 1] + 1;
if (fromPos >= 0 && toPos > fromPos) {
decorations.push(
Decoration.inline(fromPos, toPos, {
class: 'comment-highlight',
'data-comment-id': comment.id,
title: comment.comment,
})
);
}
rangeStart = -1;
}
} else {
if (rangeStart === -1) {
rangeStart = i;
}
}
}
if (rangeStart !== -1 && rangeStart < positions.length) {
const fromPos = positions[rangeStart];
const lastValidIndex = Math.min(endOrig, positions.length - 1);
const toPos = positions[lastValidIndex] + 1;
if (fromPos >= 0 && toPos > fromPos) {
decorations.push(
Decoration.inline(fromPos, toPos, {
class: 'comment-highlight',
'data-comment-id': comment.id,
title: comment.comment,
})
);
}
}
}
export const CommentHighlightExtension = Extension.create({
name: 'commentHighlight',
addOptions() {
return {
comments: [] as CommentHighlight[],
};
},
addProseMirrorPlugins() {
const initialComments = this.options.comments as CommentHighlight[];
return [
new Plugin({
key: commentHighlightPluginKey,
state: {
init(_, state) {
return {
deco: createCommentDecorations(state.doc, initialComments),
comments: initialComments,
};
},
apply(tr, value, _, newState) {
const newComments = tr.getMeta(commentHighlightPluginKey);
if (newComments !== undefined) {
return {
deco: createCommentDecorations(newState.doc, newComments),
comments: newComments,
};
}
if (tr.docChanged) {
return {
deco: createCommentDecorations(newState.doc, value.comments),
comments: value.comments,
};
}
return value;
},
},
props: {
decorations(state) {
const pluginState = commentHighlightPluginKey.getState(state);
return pluginState?.deco ?? DecorationSet.empty;
},
},
}),
];
},
});
// Helper to update comments from outside
export function updateCommentHighlights(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor: { view: { dispatch: (tr: any) => void; state: { tr: { setMeta: (key: PluginKey<any>, value: unknown) => any } } } },
comments: CommentHighlight[]
) {
const tr = editor.view.state.tr.setMeta(commentHighlightPluginKey, comments);
editor.view.dispatch(tr);
}
@@ -0,0 +1,104 @@
/**
* TipTap node view extension for rendering mermaid diagrams.
* Ported from the Milkdown mermaidCodeBlockView in markdown-editor.tsx.
*/
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
import { createElement, useEffect, useRef, useState } from 'react';
import mermaid from 'mermaid';
let mermaidInitialized = false;
let mermaidIdCounter = 0;
function initMermaid(theme: 'dark' | 'light' = 'dark') {
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
});
mermaidInitialized = true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function MermaidNodeView({ node }: { node: any }) {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const code = node.textContent;
useEffect(() => {
if (!mermaidInitialized) initMermaid();
if (!containerRef.current || !code.trim()) return;
const id = `mermaid-${++mermaidIdCounter}`;
mermaid
.render(id, code)
.then(({ svg }) => {
if (containerRef.current) {
containerRef.current.innerHTML = svg;
setError(null);
}
})
.catch((err: Error) => {
setError(err.message);
});
}, [code]);
return createElement(
NodeViewWrapper,
{ className: 'mermaid-container' },
error
? createElement('pre', { className: 'mermaid-error' }, `Error rendering diagram: ${error}`)
: createElement('div', { ref: containerRef })
);
}
/**
* Custom CodeBlock extension that renders mermaid blocks as diagrams.
* For non-mermaid blocks, uses default rendering.
*/
export const MermaidCodeBlock = Node.create({
name: 'mermaidCodeBlock',
group: 'block',
content: 'text*',
marks: '',
code: true,
defining: true,
addAttributes() {
return {
language: {
default: null,
parseHTML: (element) => element.getAttribute('data-language'),
renderHTML: (attributes) => {
if (!attributes.language) return {};
return { 'data-language': attributes.language };
},
},
};
},
parseHTML() {
return [
{
tag: 'pre',
preserveWhitespace: 'full',
getAttrs: (node) => {
const el = node as HTMLElement;
const code = el.querySelector('code');
const lang = code?.className?.match(/language-(\w+)/)?.[1];
if (lang === 'mermaid') return { language: 'mermaid' };
return false; // Let default code block handle non-mermaid
},
},
];
},
renderHTML({ HTMLAttributes }) {
return ['pre', mergeAttributes(HTMLAttributes), ['code', {}, 0]];
},
addNodeView() {
return ReactNodeViewRenderer(MermaidNodeView);
},
});
@@ -0,0 +1,236 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Link from '@tiptap/extension-link';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableCell } from '@tiptap/extension-table-cell';
import { TableHeader } from '@tiptap/extension-table-header';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { Markdown } from 'tiptap-markdown';
import { common, createLowlight } from 'lowlight';
import { CommentHighlightExtension, updateCommentHighlights, type CommentHighlight } from './tiptap-comment-highlight';
export type { CommentHighlight };
export type TextSelection = {
text: string;
position: { x: number; y: number };
};
type TipTapPlanEditorProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onSelectionChange?: (selection: TextSelection | null) => void;
onSelectionEnd?: (selection: TextSelection | null) => void;
comments?: CommentHighlight[];
onCommentClick?: (id: string, position: { x: number; y: number }) => void;
};
const MIN_COMMENT_TEXT_LENGTH = 3;
const lowlight = createLowlight(common);
export function TipTapPlanEditor({
value,
onChange,
placeholder = 'Start typing...',
onSelectionChange,
onSelectionEnd,
comments = [],
onCommentClick,
}: TipTapPlanEditorProps) {
const { resolvedTheme } = useTheme();
const wrapperRef = useRef<HTMLDivElement>(null);
const onChangeRef = useRef(onChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const onSelectionEndRef = useRef(onSelectionEnd);
const onCommentClickRef = useRef(onCommentClick);
const [isReady, setIsReady] = useState(false);
// Keep refs updated
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
useEffect(() => { onSelectionEndRef.current = onSelectionEnd; }, [onSelectionEnd]);
useEffect(() => { onCommentClickRef.current = onCommentClick; }, [onCommentClick]);
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false, // We use CodeBlockLowlight instead
}),
CodeBlockLowlight.configure({ lowlight }),
Markdown.configure({
html: true,
transformPastedText: true,
transformCopiedText: true,
}),
Placeholder.configure({ placeholder }),
Link.configure({ openOnClick: false }),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: false }),
TableRow,
TableCell,
TableHeader,
CommentHighlightExtension.configure({ comments }),
],
content: value,
editorProps: {
attributes: {
class: 'tiptap-plan-editor',
},
},
onUpdate: ({ editor: ed }) => {
// tiptap-markdown adds getMarkdown() to the editor storage
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const md = (ed.storage as any).markdown?.getMarkdown?.() as string | undefined;
onChangeRef.current(md ?? ed.getText());
},
onCreate: () => {
setIsReady(true);
},
});
// Update comment decorations when comments change
useEffect(() => {
if (!editor || !isReady) return;
try {
updateCommentHighlights(editor as Parameters<typeof updateCommentHighlights>[0], comments);
} catch {
// Editor might not be ready yet
}
}, [comments, editor, isReady]);
// Set up click handler for comment highlights via event delegation
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const commentHighlight = target.closest('.comment-highlight');
if (commentHighlight && onCommentClickRef.current) {
const commentId = commentHighlight.getAttribute('data-comment-id');
if (commentId) {
onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY });
}
}
};
wrapper.addEventListener('click', handleClick);
return () => wrapper.removeEventListener('click', handleClick);
}, []);
// Handle mouseup to show floating button when selection ends
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleMouseUp = (e: MouseEvent) => {
if (!onSelectionEndRef.current) return;
const mouseX = e.clientX;
const mouseY = e.clientY;
setTimeout(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
onSelectionEndRef.current?.(null);
return;
}
const selectedText = selection.toString().trim();
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
onSelectionEndRef.current?.(null);
return;
}
onSelectionEndRef.current?.({
text: selectedText,
position: { x: mouseX, y: mouseY },
});
}, 10);
};
const handleMouseDown = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('.floating-comment-btn')) return;
onSelectionEndRef.current?.(null);
};
wrapper.addEventListener('mouseup', handleMouseUp);
wrapper.addEventListener('mousedown', handleMouseDown);
return () => {
wrapper.removeEventListener('mouseup', handleMouseUp);
wrapper.removeEventListener('mousedown', handleMouseDown);
};
}, []);
// Handle Cmd+I to trigger selection popover
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
if (!onSelectionChangeRef.current) return;
const selection = window.getSelection();
if (!selection) return;
e.preventDefault();
e.stopImmediatePropagation();
if (selection.isCollapsed || !selection.toString().trim()) return;
const selectedText = selection.toString().trim();
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) return;
const focusNode = selection.focusNode;
const focusOffset = selection.focusOffset;
if (focusNode) {
const focusRange = document.createRange();
focusRange.setStart(focusNode, focusOffset);
focusRange.setEnd(focusNode, focusOffset);
const focusRect = focusRange.getBoundingClientRect();
let x = focusRect.right || focusRect.left;
let y = focusRect.bottom || focusRect.top;
if (x === 0 && y === 0) {
const range = selection.getRangeAt(0);
const rects = range.getClientRects();
const lastRect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect();
x = lastRect.right;
y = lastRect.bottom;
}
onSelectionChangeRef.current({
text: selectedText,
position: { x, y },
});
}
}
};
wrapper.addEventListener('keydown', handleKeyDown, true);
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
}, []);
return (
<div
ref={wrapperRef}
className={`tiptap-plan-wrapper h-full relative ${resolvedTheme === 'dark' ? 'dark' : ''}`}
>
<EditorContent editor={editor} className="h-full" />
{!isReady && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-background/80">
Loading editor...
</div>
)}
</div>
);
}
@@ -22,6 +22,7 @@ type ReviewDialogProps = {
baseBranch?: string;
taskTitle?: string;
onSendComments: (comments: DiffComment[]) => void;
onOpenFile?: (filePath: string) => void;
gitStatusFiles: Record<string, FileInfo> | null;
cumulativeDiff: CumulativeDiff | null;
};
@@ -34,6 +35,7 @@ export const ReviewDialog = memo(function ReviewDialog({
baseBranch,
taskTitle,
onSendComments,
onOpenFile,
gitStatusFiles,
cumulativeDiff,
}: ReviewDialogProps) {
@@ -257,6 +259,7 @@ export const ReviewDialog = memo(function ReviewDialog({
wordWrap={wordWrap}
onToggleReviewed={handleToggleReviewed}
onDiscard={handleDiscard}
onOpenFile={onOpenFile}
fileRefs={fileRefs}
/>
) : (
+26 -22
View File
@@ -3,14 +3,14 @@
import { memo, useEffect, useRef, useState, useCallback } from 'react';
import {
IconAlertTriangle,
IconCopy,
IconArrowBackUp,
IconPencil,
} from '@tabler/icons-react';
import { Checkbox } from '@kandev/ui/checkbox';
import { Button } from '@kandev/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { FileDiffViewer } from '@/components/diff';
import { useToast } from '@/components/toast-provider';
import { FileActionsDropdown } from '@/components/editors/file-actions-dropdown';
import type { ReviewFile } from './types';
type ReviewDiffListProps = {
@@ -22,6 +22,7 @@ type ReviewDiffListProps = {
wordWrap: boolean;
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
onOpenFile?: (filePath: string) => void;
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
};
@@ -34,6 +35,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({
wordWrap,
onToggleReviewed,
onDiscard,
onOpenFile,
fileRefs,
}: ReviewDiffListProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
@@ -51,6 +53,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({
wordWrap={wordWrap}
onToggleReviewed={onToggleReviewed}
onDiscard={onDiscard}
onOpenFile={onOpenFile}
sectionRef={fileRefs.get(file.path)}
scrollContainer={scrollContainerRef}
/>
@@ -68,6 +71,7 @@ type FileDiffSectionProps = {
wordWrap: boolean;
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
onOpenFile?: (filePath: string) => void;
sectionRef?: React.RefObject<HTMLDivElement | null>;
scrollContainer: React.RefObject<HTMLDivElement | null>;
};
@@ -81,13 +85,13 @@ function FileDiffSection({
wordWrap,
onToggleReviewed,
onDiscard,
onOpenFile,
sectionRef,
scrollContainer,
}: FileDiffSectionProps) {
const [isVisible, setIsVisible] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const autoMarkedRef = useRef(false);
const { toast } = useToast();
// Lazy rendering: mount the diff viewer only when section becomes visible
useEffect(() => {
@@ -144,11 +148,6 @@ function FileDiffSection({
[file.path, onToggleReviewed]
);
const handleCopyPath = useCallback(() => {
navigator.clipboard.writeText(file.path);
toast({ title: 'Copied', description: file.path, variant: 'success' });
}, [file.path, toast]);
const handleDiscard = useCallback(() => {
onDiscard(file.path);
}, [file.path, onDiscard]);
@@ -184,20 +183,25 @@ function FileDiffSection({
{/* File actions */}
<div className="flex items-center gap-0.5">
{/* Copy path */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
onClick={handleCopyPath}
>
<IconCopy className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy file path</TooltipContent>
</Tooltip>
{/* Edit in editor tab */}
{onOpenFile && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
onClick={() => onOpenFile(file.path)}
>
<IconPencil className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
)}
{/* Open with external editor / copy path / open folder */}
<FileActionsDropdown filePath={file.path} sessionId={sessionId} size="xs" />
{/* Discard changes */}
{file.source === 'uncommitted' && (
+375 -122
View File
@@ -1,11 +1,16 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { IconEdit, IconTrash } from '@tabler/icons-react';
import { IconEdit, IconTrash, IconChevronDown, IconExternalLink, IconInfoCircle } from '@tabler/icons-react';
import { Badge } from '@kandev/ui/badge';
import { Button } from '@kandev/ui/button';
import { Checkbox } from '@kandev/ui/checkbox';
import { Input } from '@kandev/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Separator } from '@kandev/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { Switch } from '@kandev/ui/switch';
import { Textarea } from '@kandev/ui/textarea';
import { SettingsPageTemplate } from '@/components/settings/settings-page-template';
import { Combobox, type ComboboxOption } from '@/components/combobox';
import { useAppStore } from '@/components/state-provider';
@@ -13,6 +18,7 @@ import { EditableCard } from '@/components/settings/editable-card';
import { useEditors } from '@/hooks/domains/settings/use-editors';
import { createEditor, deleteEditor, updateEditor, updateUserSettings } from '@/lib/api';
import { useRequest } from '@/lib/http/use-request';
import { LSP_DEFAULT_CONFIGS } from '@/lib/lsp/lsp-client-manager';
import type { EditorOption } from '@/lib/types/http';
type CustomKind = 'custom_command' | 'custom_remote_ssh' | 'custom_hosted_url';
@@ -36,6 +42,21 @@ const CUSTOM_KIND_OPTIONS: Array<{ value: CustomKind; label: string }> = [
const PLACEHOLDER_HINT = '{cwd} {file} {rel} {line} {column}';
const LSP_LANGUAGE_OPTIONS = [
{ id: 'typescript', label: 'TypeScript / JavaScript', binary: 'typescript-language-server',
docsUrl: 'https://github.com/typescript-language-server/typescript-language-server#workspace-configuration',
installHint: 'Installs typescript-language-server and typescript via npm into ~/.kandev/lsp-servers/' },
{ id: 'go', label: 'Go', binary: 'gopls',
docsUrl: 'https://github.com/golang/tools/blob/master/gopls/doc/settings.md',
installHint: 'Runs "go install golang.org/x/tools/gopls@latest". Requires Go to be installed.' },
{ id: 'rust', label: 'Rust', binary: 'rust-analyzer',
docsUrl: 'https://rust-analyzer.github.io/book/configuration.html',
installHint: 'Downloads the rust-analyzer binary from GitHub releases into ~/.kandev/lsp-servers/' },
{ id: 'python', label: 'Python', binary: 'pyright-langserver',
docsUrl: 'https://microsoft.github.io/pyright/#/settings',
installHint: 'Installs pyright via npm into ~/.kandev/lsp-servers/' },
];
function getCustomKindLabel(kind: string) {
return CUSTOM_KIND_OPTIONS.find((option) => option.value === kind)?.label ?? 'Custom';
}
@@ -288,6 +309,98 @@ export function EditorsSettings() {
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// LSP settings
const [lspAutoStartLanguages, setLspAutoStartLanguages] = useState<string[]>(
() => currentUserSettings.lspAutoStartLanguages ?? []
);
const [lspAutoInstallLanguages, setLspAutoInstallLanguages] = useState<string[]>(
() => currentUserSettings.lspAutoInstallLanguages ?? []
);
const [baselineLspAutoStart, setBaselineLspAutoStart] = useState<string[]>(
() => currentUserSettings.lspAutoStartLanguages ?? []
);
const [baselineLspAutoInstall, setBaselineLspAutoInstall] = useState<string[]>(
() => currentUserSettings.lspAutoInstallLanguages ?? []
);
// LSP server configs (per-language JSON overrides)
const initConfigStrings = useCallback((): Record<string, string> => {
const configs = currentUserSettings.lspServerConfigs ?? {};
const result: Record<string, string> = {};
for (const [lang, config] of Object.entries(configs)) {
if (config && Object.keys(config).length > 0) {
result[lang] = JSON.stringify(config, null, 2);
}
}
return result;
}, [currentUserSettings.lspServerConfigs]);
const [lspConfigStrings, setLspConfigStrings] = useState<Record<string, string>>(initConfigStrings);
const [baselineLspConfigStrings, setBaselineLspConfigStrings] = useState<Record<string, string>>(initConfigStrings);
const [expandedConfigLang, setExpandedConfigLang] = useState<string | null>(null);
const [lspConfigErrors, setLspConfigErrors] = useState<Record<string, string>>({});
const toggleAutoStart = useCallback((langId: string, checked: boolean) => {
setLspAutoStartLanguages((prev) =>
checked ? [...prev, langId] : prev.filter((id) => id !== langId)
);
}, []);
const toggleAutoInstall = useCallback((langId: string, checked: boolean) => {
setLspAutoInstallLanguages((prev) =>
checked ? [...prev, langId] : prev.filter((id) => id !== langId)
);
}, []);
const updateLspConfigString = useCallback((langId: string, value: string) => {
setLspConfigStrings((prev) => {
if (!value.trim()) {
const next = { ...prev };
delete next[langId];
return next;
}
return { ...prev, [langId]: value };
});
// Validate JSON
if (!value.trim()) {
setLspConfigErrors((prev) => {
const next = { ...prev };
delete next[langId];
return next;
});
return;
}
try {
const parsed = JSON.parse(value);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
setLspConfigErrors((prev) => ({ ...prev, [langId]: 'Must be a JSON object' }));
} else {
setLspConfigErrors((prev) => {
const next = { ...prev };
delete next[langId];
return next;
});
}
} catch {
setLspConfigErrors((prev) => ({ ...prev, [langId]: 'Invalid JSON' }));
}
}, []);
const parseLspConfigs = useCallback((): Record<string, Record<string, unknown>> | null => {
const result: Record<string, Record<string, unknown>> = {};
for (const [lang, str] of Object.entries(lspConfigStrings)) {
if (!str.trim()) continue;
try {
const parsed = JSON.parse(str);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
result[lang] = parsed as Record<string, unknown>;
} catch {
return null;
}
}
return result;
}, [lspConfigStrings]);
const customEditors = useMemo(() => {
const items = editors.filter(isCustomEditor);
return items.slice().sort((a, b) => {
@@ -350,17 +463,32 @@ export function EditorsSettings() {
}));
}, [availableEditors, defaultEditorId]);
const isDirty = defaultEditorId !== baselineDefaultId;
const lspAutoStartDirty =
lspAutoStartLanguages.length !== baselineLspAutoStart.length ||
lspAutoStartLanguages.some((id) => !baselineLspAutoStart.includes(id));
const lspAutoInstallDirty =
lspAutoInstallLanguages.length !== baselineLspAutoInstall.length ||
lspAutoInstallLanguages.some((id) => !baselineLspAutoInstall.includes(id));
const lspConfigsDirty = JSON.stringify(lspConfigStrings) !== JSON.stringify(baselineLspConfigStrings);
const isDirty = defaultEditorId !== baselineDefaultId || lspAutoStartDirty || lspAutoInstallDirty || lspConfigsDirty;
const saveDefaultRequest = useRequest(async () => {
const payload = {
const parsedConfigs = parseLspConfigs();
if (parsedConfigs === null) return; // Invalid JSON, don't save
const payload: Parameters<typeof updateUserSettings>[0] = {
workspace_id: currentUserSettings.workspaceId ?? '',
board_id: currentUserSettings.boardId ?? '',
repository_ids: currentUserSettings.repositoryIds ?? [],
default_editor_id: defaultEditorId || undefined,
lsp_auto_start_languages: lspAutoStartLanguages,
lsp_auto_install_languages: lspAutoInstallLanguages,
lsp_server_configs: parsedConfigs,
};
const response = await updateUserSettings(payload, { cache: 'no-store' });
setBaselineDefaultId(defaultEditorId);
setBaselineLspAutoStart([...lspAutoStartLanguages]);
setBaselineLspAutoInstall([...lspAutoInstallLanguages]);
setBaselineLspConfigStrings({ ...lspConfigStrings });
if (response?.settings) {
setUserSettings({
workspaceId: response.settings.workspace_id || null,
@@ -372,6 +500,9 @@ export function EditorsSettings() {
enablePreviewOnClick: response.settings.enable_preview_on_click ?? false,
chatSubmitKey: response.settings.chat_submit_key ?? 'cmd_enter',
reviewAutoMarkOnScroll: response.settings.review_auto_mark_on_scroll ?? true,
lspAutoStartLanguages: response.settings.lsp_auto_start_languages ?? [],
lspAutoInstallLanguages: response.settings.lsp_auto_install_languages ?? [],
lspServerConfigs: response.settings.lsp_server_configs ?? {},
loaded: true,
});
}
@@ -426,17 +557,141 @@ export function EditorsSettings() {
return (
<SettingsPageTemplate
title="Editors"
description="Choose which editor opens files and worktrees"
description="Configure the included code editor and external editors"
isDirty={isDirty}
saveStatus={saveDefaultRequest.status}
onSave={() => {
void saveDefaultRequest.run();
}}
>
<div className="space-y-8">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Default editor</div>
<div className="flex flex-wrap items-center gap-3">
<div className="space-y-6">
{/* ── File Editor ────────────────────────────────────── */}
<div className="space-y-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
File Editor
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium text-foreground">Language Servers</div>
<div className="text-xs text-muted-foreground">
Auto-start language servers when opening files to get diagnostics, hover info, and go-to-definition. You can also toggle each server on/off per file.
<br />
When enabled, install your project&apos;s dependencies (e.g. <code className="text-[11px] bg-muted px-1 rounded">npm install</code> via repository setup scripts) to avoid missing type errors.
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{LSP_LANGUAGE_OPTIONS.map((lang) => (
<div key={lang.id} className="rounded-lg border border-border/60 bg-background px-4 py-3 space-y-2.5">
<div>
<div className="text-sm font-medium text-foreground">{lang.label}</div>
<div className="text-xs text-muted-foreground">{lang.binary}</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Auto-start</span>
<Switch
checked={lspAutoStartLanguages.includes(lang.id)}
onCheckedChange={(checked) => toggleAutoStart(lang.id, checked === true)}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`lsp-install-${lang.id}`}
checked={lspAutoInstallLanguages.includes(lang.id)}
onCheckedChange={(checked) => toggleAutoInstall(lang.id, checked === true)}
className="h-3.5 w-3.5"
/>
<label htmlFor={`lsp-install-${lang.id}`} className="text-xs text-muted-foreground cursor-pointer">
Auto-install if not found
</label>
<Tooltip>
<TooltipTrigger asChild>
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[260px] text-xs">
{lang.installHint}
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium text-foreground">Server Configuration</div>
<div className="text-xs text-muted-foreground">
Override settings sent to each language server via <code className="text-[11px] bg-muted px-1 rounded">workspace/configuration</code>. JSON format.
</div>
</div>
{LSP_LANGUAGE_OPTIONS.map((lang) => {
const isExpanded = expandedConfigLang === lang.id;
const configStr = lspConfigStrings[lang.id] ?? '';
const defaultConfig = LSP_DEFAULT_CONFIGS[lang.id];
const hasDefaults = defaultConfig && Object.keys(defaultConfig).length > 0;
const error = lspConfigErrors[lang.id];
return (
<div key={lang.id} className="rounded-lg border border-border/60 bg-background overflow-hidden">
<button
type="button"
className="flex w-full items-center justify-between px-4 py-2.5 text-left hover:bg-muted/50 transition-colors"
onClick={() => setExpandedConfigLang(isExpanded ? null : lang.id)}
>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{lang.label}</span>
{configStr.trim() && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">custom</Badge>
)}
</div>
<IconChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
</button>
{isExpanded && (
<div className="border-t border-border/60 px-4 py-3 space-y-2">
{hasDefaults && (
<div className="text-[11px] text-muted-foreground">
Defaults: <code className="bg-muted px-1 rounded">{JSON.stringify(defaultConfig)}</code>
</div>
)}
<a
href={lang.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
View available settings
<IconExternalLink className="h-3 w-3" />
</a>
<Textarea
value={configStr}
onChange={(e) => updateLspConfigString(lang.id, e.target.value)}
placeholder={hasDefaults ? JSON.stringify(defaultConfig, null, 2) : '{\n \n}'}
className="font-mono text-xs min-h-[80px] resize-y"
rows={4}
/>
{error && (
<div className="text-xs text-destructive">{error}</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
<Separator />
{/* ── External Editors ───────────────────────────────── */}
<div className="space-y-6">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
External Editors
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Default</div>
<div className="min-w-[280px]">
<Combobox
options={defaultOptions}
@@ -452,126 +707,124 @@ export function EditorsSettings() {
/>
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-foreground">Custom editors</div>
<Button type="button" variant="outline" onClick={() => setIsAdding(true)}>
Add custom editor
</Button>
</div>
{isAdding && (
<EditorForm
title="New custom editor"
initialState={defaultFormState()}
onCancel={() => setIsAdding(false)}
onSave={(state) => {
void createRequest.run(state);
}}
submitLabel="Add editor"
isSaving={createRequest.isLoading}
/>
)}
<div className="space-y-3">
{customEditors.length === 0 && !isAdding && (
<div className="rounded-lg border border-dashed border-border/70 bg-muted/30 p-4 text-sm text-muted-foreground">
No custom editors yet.
</div>
)}
{customEditors.map((editor) => {
return (
<EditableCard
key={editor.id}
isEditing={editingId === editor.id}
historyId={`editor-${editor.id}`}
onOpen={() => setEditingId(editor.id)}
onClose={() => setEditingId(null)}
renderEdit={({ close }) => (
<EditorForm
title={`Edit ${editor.name}`}
initialState={formStateFromEditor(editor)}
onCancel={close}
onSave={(state) => {
void updateRequest.run(editor.id, state).then(() => {
close();
});
}}
submitLabel="Save changes"
isSaving={updateRequest.isLoading}
/>
)}
renderPreview={({ open }) => (
<div
className="rounded-lg border border-border/70 bg-background p-4 flex items-center justify-between gap-3 cursor-pointer"
onClick={open}
>
<div className="min-w-0">
<div className="font-medium text-sm text-foreground truncate">
{editor.name}
</div>
<div className="text-xs text-muted-foreground truncate">
{getCustomEditorSummary(editor)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="cursor-pointer"
onClick={(event) => {
event.stopPropagation();
open();
}}
>
<IconEdit className="h-4 w-4" />
Edit
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="cursor-pointer"
onClick={(event) => {
event.stopPropagation();
void deleteRequest.run(editor.id);
}}
>
<IconTrash className="h-4 w-4" />
Remove
</Button>
</div>
</div>
)}
/>
);
})}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-foreground">Custom Editors</div>
<Button type="button" variant="outline" onClick={() => setIsAdding(true)}>
Add custom editor
</Button>
</div>
{builtInEditors.length === 0 ? null : (
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Supported Editors
</div>
<div className="grid gap-2 md:grid-cols-2">
{builtInEditors.map((editor) => (
<div
key={editor.id}
className="rounded-lg border border-border/60 bg-background px-3 py-2 flex items-center justify-between"
>
<span className="text-sm text-foreground truncate">{editor.name}</span>
<Badge variant={editor.installed ? 'secondary' : 'outline'}>
{editor.installed ? 'Installed' : 'Not installed'}
</Badge>
{isAdding && (
<EditorForm
title="New custom editor"
initialState={defaultFormState()}
onCancel={() => setIsAdding(false)}
onSave={(state) => {
void createRequest.run(state);
}}
submitLabel="Add editor"
isSaving={createRequest.isLoading}
/>
)}
<div className="space-y-3">
{customEditors.length === 0 && !isAdding && (
<div className="rounded-lg border border-dashed border-border/70 bg-muted/30 p-4 text-sm text-muted-foreground">
No custom editors yet.
</div>
))}
)}
{customEditors.map((editor) => {
return (
<EditableCard
key={editor.id}
isEditing={editingId === editor.id}
historyId={`editor-${editor.id}`}
onOpen={() => setEditingId(editor.id)}
onClose={() => setEditingId(null)}
renderEdit={({ close }) => (
<EditorForm
title={`Edit ${editor.name}`}
initialState={formStateFromEditor(editor)}
onCancel={close}
onSave={(state) => {
void updateRequest.run(editor.id, state).then(() => {
close();
});
}}
submitLabel="Save changes"
isSaving={updateRequest.isLoading}
/>
)}
renderPreview={({ open }) => (
<div
className="rounded-lg border border-border/70 bg-background p-4 flex items-center justify-between gap-3 cursor-pointer"
onClick={open}
>
<div className="min-w-0">
<div className="font-medium text-sm text-foreground truncate">
{editor.name}
</div>
<div className="text-xs text-muted-foreground truncate">
{getCustomEditorSummary(editor)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="cursor-pointer"
onClick={(event) => {
event.stopPropagation();
open();
}}
>
<IconEdit className="h-4 w-4" />
Edit
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="cursor-pointer"
onClick={(event) => {
event.stopPropagation();
void deleteRequest.run(editor.id);
}}
>
<IconTrash className="h-4 w-4" />
Remove
</Button>
</div>
</div>
)}
/>
);
})}
</div>
</div>
)}
{builtInEditors.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Supported Editors</div>
<div className="grid gap-2 md:grid-cols-2">
{builtInEditors.map((editor) => (
<div
key={editor.id}
className="rounded-lg border border-border/60 bg-background px-3 py-2 flex items-center justify-between"
>
<span className="text-sm text-foreground truncate">{editor.name}</span>
<Badge variant={editor.installed ? 'secondary' : 'outline'}>
{editor.installed ? 'Installed' : 'Not installed'}
</Badge>
</div>
))}
</div>
</div>
)}
</div>
</div>
</SettingsPageTemplate>
);
@@ -1,80 +1,17 @@
'use client';
import { useTheme } from 'next-themes';
import CodeMirror from '@uiw/react-codemirror';
import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
import { EditorView } from '@codemirror/view';
import { IconCheck, IconCopy } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import { getCodeMirrorExtension } from '@/lib/languages';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { useEditorProvider } from '@/hooks/use-editor-resolver';
import { MonacoCodeBlock } from '@/components/editors/monaco/monaco-code-block';
import { CodeMirrorCodeBlock } from '@/components/editors/codemirror/codemirror-code-block';
type CodeBlockProps = {
children: React.ReactNode;
className?: string;
};
const getLanguageExtension = (language?: string) => {
if (!language) return undefined;
const lang = language.replace('language-', '').toLowerCase();
return getCodeMirrorExtension(lang);
};
export function CodeBlock({ children, className }: CodeBlockProps) {
const { copied, copy } = useCopyToClipboard();
const { theme, systemTheme } = useTheme();
const effectiveTheme = theme === 'system' ? systemTheme : theme;
const code = String(children).replace(/\n$/, '');
const languageExtension = getLanguageExtension(className);
// Custom padding theme
const paddingTheme = EditorView.theme({
'&': {
padding: '2px 2px',
},
});
const handleCopy = async () => {
await copy(code);
};
return (
<div className="relative group/code-block my-4 w-fit max-w-full min-w-[50%]">
{/* Copy button */}
<button
onClick={handleCopy}
className={cn(
'absolute top-2 right-2 z-10',
'p-1.5 rounded-md',
'bg-white/10 hover:bg-white/20',
'transition-all duration-200',
'opacity-0 group-hover/code-block:opacity-100',
'cursor-pointer',
)}
title="Copy code"
>
{copied ? (
<IconCheck className="h-3 w-3 text-green-400" />
) : (
<IconCopy className="h-3 w-3 text-gray-400" />
)}
</button>
{/* Code editor */}
<CodeMirror
value={code}
theme={effectiveTheme === 'dark' ? vscodeDark : vscodeLight}
extensions={languageExtension ? [languageExtension, EditorView.lineWrapping, paddingTheme] : [EditorView.lineWrapping, paddingTheme]}
editable={false}
basicSetup={{
lineNumbers: false,
highlightActiveLineGutter: false,
highlightActiveLine: false,
foldGutter: false,
}}
className="text-xs rounded-md overflow-hidden"
/>
</div>
);
export function CodeBlock(props: CodeBlockProps) {
const provider = useEditorProvider('chat-code-block');
return provider === 'monaco'
? <MonacoCodeBlock {...props} />
: <CodeMirrorCodeBlock {...props} />;
}
@@ -1,17 +1,23 @@
'use client';
import { cn } from '@/lib/utils';
import { useEditorProvider } from '@/hooks/use-editor-resolver';
import { MonacoInlineDiff } from '@/components/editors/monaco/monaco-inline-diff';
import { DiffViewInline } from '@/components/diff';
import type { FileDiffData } from '@/lib/diff/types';
type DiffViewBlockProps = {
/** Diff data in the new format */
data: FileDiffData;
/** Additional class name */
className?: string;
};
export function DiffViewBlock({ data, className }: DiffViewBlockProps) {
const provider = useEditorProvider('chat-diff');
if (provider === 'monaco') {
return <MonacoInlineDiff data={data} className={className} />;
}
return (
<div className={cn("mt-3 rounded-md border border-border/50 bg-background/60 px-3 py-2 text-xs", className)}>
<DiffViewInline data={data} />
@@ -42,6 +42,7 @@ import { useFileEditors } from '@/hooks/use-file-editors';
import { startProcess } from '@/lib/api';
import { createUserShell } from '@/lib/api/domains/user-shell-api';
import { useRepositoryScripts } from '@/hooks/domains/workspace/use-repository-scripts';
import { useLspFileOpener } from '@/hooks/use-lsp-file-opener';
// Panel components
import { TaskSessionSidebar, NewTaskButton } from './task-session-sidebar';
@@ -500,6 +501,9 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
const effectiveSessionId = useAppStore((state) => state.tasks.activeSessionId) ?? sessionId ?? null;
const hasDevScript = Boolean(repository?.dev_script?.trim());
// Connect LSP Go-to-Definition navigation to dockview file tabs
useLspFileOpener();
// Keep sessionIdRef in sync for use inside event handlers
useEffect(() => {
sessionIdRef.current = effectiveSessionId;
@@ -1,22 +1,8 @@
'use client';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { EditorView, gutter, GutterMarker, ViewPlugin, type ViewUpdate } from '@codemirror/view';
import type { Extension } from '@codemirror/state';
import { Decoration, type DecorationSet } from '@codemirror/view';
import { getCodeMirrorExtensionFromPath } from '@/lib/languages';
import { Button } from '@kandev/ui/button';
import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus } from '@tabler/icons-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { formatDiffStats } from '@/lib/utils/file-diff';
import { toRelativePath } from '@/lib/utils';
import { vscodeDark } from '@uiw/codemirror-theme-vscode';
import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice';
import type { DiffComment } from '@/lib/diff/types';
import { useToast } from '@/components/toast-provider';
import { EditorCommentPopover } from './editor-comment-popover';
import { CommentViewPopover } from './comment-view-popover';
import { useEditorProvider } from '@/hooks/use-editor-resolver';
import { MonacoCodeEditor } from '@/components/editors/monaco/monaco-code-editor';
import { CodeMirrorCodeEditor } from '@/components/editors/codemirror/codemirror-code-editor';
type FileEditorContentProps = {
path: string;
@@ -32,618 +18,9 @@ type FileEditorContentProps = {
onDelete?: () => void;
};
type TextSelection = {
text: string;
startLine: number;
endLine: number;
position: { x: number; y: number };
} | null;
type FloatingButtonPosition = {
x: number;
y: number;
} | null;
type CommentViewState = {
comments: DiffComment[];
position: { x: number; y: number };
} | null;
// Create comment line decorations
function createCommentDecorations(
view: EditorView,
comments: DiffComment[]
): DecorationSet {
if (comments.length === 0) {
return Decoration.none;
}
const decorations: Array<{ from: number; decoration: Decoration }> = [];
// Get all lines that have comments
const linesWithComments = new Set<number>();
for (const comment of comments) {
for (let line = comment.startLine; line <= comment.endLine; line++) {
linesWithComments.add(line);
}
}
// Create line decorations for commented lines
for (const lineNum of linesWithComments) {
if (lineNum > view.state.doc.lines) continue;
const line = view.state.doc.line(lineNum);
decorations.push({
from: line.from,
decoration: Decoration.line({
class: 'cm-comment-line',
}),
});
}
decorations.sort((a, b) => a.from - b.from);
return Decoration.set(decorations.map(d => d.decoration.range(d.from)));
}
// Gutter marker for lines with comments - defined outside component to avoid re-creation
class CommentGutterMarker extends GutterMarker {
constructor(
readonly lineComments: DiffComment[],
readonly isFirstLine: boolean
) {
super();
}
toDOM() {
const marker = document.createElement('div');
marker.className = 'cm-comment-gutter-marker';
marker.title = `${this.lineComments.length} comment${this.lineComments.length > 1 ? 's' : ''} - click to view`;
// Add icon only on the first line of a comment
if (this.isFirstLine) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '12');
svg.setAttribute('height', '12');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'rgba(99, 102, 241, 0.9)');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z');
svg.appendChild(path);
marker.appendChild(svg);
}
return marker;
}
eq(other: CommentGutterMarker) {
return this.lineComments.length === other.lineComments.length && this.isFirstLine === other.isFirstLine;
}
}
export function FileEditorContent({
path,
content,
originalContent,
isDirty,
isSaving,
sessionId,
worktreePath,
enableComments = false,
onChange,
onSave,
onDelete,
}: FileEditorContentProps) {
const [wrapEnabled, setWrapEnabled] = useState(true);
const [textSelection, setTextSelection] = useState<TextSelection>(null);
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
const [commentView, setCommentView] = useState<CommentViewState>(null);
const editorRef = useRef<ReactCodeMirrorRef>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const { toast } = useToast();
// Comment store
const addComment = useDiffCommentsStore((state) => state.addComment);
const removeComment = useDiffCommentsStore((state) => state.removeComment);
const comments = useFileComments(sessionId ?? '', path);
const langExt = getCodeMirrorExtensionFromPath(path);
// Create the comment decoration plugin
const commentPlugin = useMemo(() => {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = createCommentDecorations(view, comments);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = createCommentDecorations(update.view, comments);
}
}
},
{ decorations: (v) => v.decorations }
);
}, [comments]);
// Create comment gutter
const commentGutter = useMemo(() => {
// Build a map of line number to comments that include that line
const commentsByLine = new Map<number, DiffComment[]>();
// Track which lines are the first line of at least one comment
const firstLines = new Set<number>();
for (const comment of comments) {
firstLines.add(comment.startLine);
// Add to all lines in the range
for (let lineNum = comment.startLine; lineNum <= comment.endLine; lineNum++) {
const existing = commentsByLine.get(lineNum) || [];
existing.push(comment);
commentsByLine.set(lineNum, existing);
}
}
return gutter({
class: 'cm-comment-gutter',
lineMarker: (view, line) => {
const lineNum = view.state.doc.lineAt(line.from).number;
const lineComments = commentsByLine.get(lineNum);
if (lineComments && lineComments.length > 0) {
return new CommentGutterMarker(lineComments, firstLines.has(lineNum));
}
return null;
},
domEventHandlers: {
click: (view, line, event) => {
const lineNum = view.state.doc.lineAt(line.from).number;
const lineComments = commentsByLine.get(lineNum);
if (lineComments && lineComments.length > 0) {
event.preventDefault();
event.stopPropagation();
setCommentView({
comments: lineComments,
position: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY },
});
return true;
}
return false;
},
},
});
}, [comments]);
// Track mouse position
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
mousePositionRef.current = { x: e.clientX, y: e.clientY };
};
document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}, []);
// Show floating button after selection ends (on mouseup)
const handleSelectionEnd = useCallback(() => {
if (!enableComments || !sessionId) return;
const view = editorRef.current?.view;
if (!view) return;
const selection = view.state.selection.main;
if (selection.empty) {
setFloatingButtonPos(null);
setCurrentSelection(null);
return;
}
const selectedText = view.state.sliceDoc(selection.from, selection.to);
if (!selectedText.trim()) {
setFloatingButtonPos(null);
setCurrentSelection(null);
return;
}
// Get line numbers
const startLine = view.state.doc.lineAt(selection.from).number;
const endLine = view.state.doc.lineAt(selection.to).number;
// Use mouse position for the floating button (where cursor is)
setCurrentSelection({ text: selectedText, startLine, endLine });
setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
}, [enableComments, sessionId]);
// Listen for mouseup to show floating button after selection ends
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || !enableComments || !sessionId) return;
const handleMouseUp = (e: MouseEvent) => {
// Don't show if clicking on the floating button itself
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
return;
}
// Small delay to let selection finalize
setTimeout(handleSelectionEnd, 10);
};
// Clear selection state when clicking (new selection starts)
const handleMouseDown = (e: MouseEvent) => {
// Don't clear if clicking on the floating button
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
return;
}
setFloatingButtonPos(null);
};
wrapper.addEventListener('mouseup', handleMouseUp);
wrapper.addEventListener('mousedown', handleMouseDown);
return () => {
wrapper.removeEventListener('mouseup', handleMouseUp);
wrapper.removeEventListener('mousedown', handleMouseDown);
};
}, [enableComments, sessionId, handleSelectionEnd]);
// Clear floating button when selection is cleared via keyboard
const selectionUpdateExtension = useMemo(() => {
return EditorView.updateListener.of((update) => {
if (update.selectionSet) {
const selection = update.state.selection.main;
if (selection.empty) {
setFloatingButtonPos(null);
setCurrentSelection(null);
}
}
});
}, []);
const extensions: Extension[] = useMemo(() => {
const exts: Extension[] = [
EditorView.editable.of(true),
EditorView.theme({
'&': { backgroundColor: 'hsl(var(--background)) !important' },
'.cm-gutters': { backgroundColor: 'hsl(var(--background)) !important', borderRight: 'none' },
// Comment gutter
'.cm-comment-gutter': {
width: '22px',
cursor: 'pointer',
},
'.cm-comment-gutter .cm-gutterElement': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0',
},
// Marker styles - background color on the marker itself (indigo)
'.cm-comment-gutter-marker': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
cursor: 'pointer',
backgroundColor: 'rgba(99, 102, 241, 0.2)',
},
'.cm-comment-gutter-marker:hover': {
backgroundColor: 'rgba(99, 102, 241, 0.35)',
},
// Comment line highlight with left border (indigo)
'.cm-comment-line': {
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
borderLeft: '3px solid rgba(99, 102, 241, 0.6)',
marginLeft: '-3px',
paddingLeft: '3px',
},
}),
commentGutter,
commentPlugin,
selectionUpdateExtension,
];
if (wrapEnabled) {
exts.push(EditorView.lineWrapping);
}
if (langExt) {
exts.push(langExt);
}
return exts;
}, [langExt, wrapEnabled, commentGutter, commentPlugin, selectionUpdateExtension]);
// Calculate diff stats when dirty
const diffStats = useMemo(() => {
if (!isDirty) return null;
const originalLines = originalContent.split('\n');
const currentLines = content.split('\n');
let additions = 0;
let deletions = 0;
const maxLen = Math.max(originalLines.length, currentLines.length);
for (let i = 0; i < maxLen; i++) {
const origLine = originalLines[i];
const currLine = currentLines[i];
if (origLine === undefined && currLine !== undefined) {
additions++;
} else if (origLine !== undefined && currLine === undefined) {
deletions++;
} else if (origLine !== currLine) {
additions++;
deletions++;
}
}
return { additions, deletions };
}, [isDirty, content, originalContent]);
// Handle Cmd+I to open comment popover
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || !enableComments || !sessionId) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
if (!currentSelection || !floatingButtonPos) return;
e.preventDefault();
e.stopPropagation();
setTextSelection({
...currentSelection,
position: floatingButtonPos,
});
setFloatingButtonPos(null);
}
};
wrapper.addEventListener('keydown', handleKeyDown, true);
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
}, [enableComments, sessionId, currentSelection, floatingButtonPos]);
// Handle Cmd/Ctrl+S keyboard shortcut and Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (isDirty && !isSaving) {
onSave();
}
}
// Escape to close popovers
if (e.key === 'Escape') {
if (textSelection) setTextSelection(null);
if (commentView) setCommentView(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isDirty, isSaving, onSave, textSelection, commentView]);
const handleChange = useCallback(
(value: string) => {
onChange(value);
},
[onChange]
);
// Open comment popover from floating button
const handleFloatingButtonClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!currentSelection || !floatingButtonPos) return;
setTextSelection({
...currentSelection,
position: floatingButtonPos,
});
setFloatingButtonPos(null);
}, [currentSelection, floatingButtonPos]);
// Submit comment
const handleCommentSubmit = useCallback((annotation: string) => {
if (!textSelection || !sessionId) return;
const { text, startLine, endLine } = textSelection;
const comment: DiffComment = {
id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sessionId,
filePath: path,
startLine,
endLine,
side: 'additions',
codeContent: text,
annotation,
createdAt: new Date().toISOString(),
status: 'pending',
};
addComment(comment);
setTextSelection(null);
// Clear selection in editor
const view = editorRef.current?.view;
if (view) {
view.dispatch({
selection: { anchor: view.state.selection.main.head },
});
}
toast({
title: 'Comment added',
description: 'Your comment will be sent with your next message.',
});
}, [textSelection, sessionId, path, addComment, toast]);
// Close popover
const handlePopoverClose = useCallback(() => {
setTextSelection(null);
}, []);
// Delete comment
const handleDeleteComment = useCallback((commentId: string) => {
if (!sessionId) return;
removeComment(sessionId, path, commentId);
// Close the view if no more comments
if (commentView && commentView.comments.length <= 1) {
setCommentView(null);
} else if (commentView) {
setCommentView({
...commentView,
comments: commentView.comments.filter(c => c.id !== commentId),
});
}
toast({
title: 'Comment deleted',
});
}, [sessionId, path, removeComment, commentView, toast]);
// Close comment view
const handleCommentViewClose = useCallback(() => {
setCommentView(null);
}, []);
return (
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
{/* Editor header with save button */}
<div className="flex items-center justify-between px-2 border-foreground/10 border-b">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
{isDirty && diffStats && (
<span className="text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
<div className="flex items-center gap-1">
{enableComments && sessionId && comments.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
<IconMessagePlus className="h-3.5 w-3.5" />
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={() => setWrapEnabled(!wrapEnabled)}
className={`h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? 'text-foreground' : 'text-muted-foreground'}`}
>
{wrapEnabled ? <IconTextWrap className="h-4 w-4" /> : <IconTextWrapDisabled className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}</TooltipContent>
</Tooltip>
{onDelete && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={onDelete}
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
>
<IconTrash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete file</TooltipContent>
</Tooltip>
)}
<Button
size="sm"
variant="default"
onClick={onSave}
disabled={!isDirty || isSaving}
className="cursor-pointer gap-2"
>
{isSaving ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<IconDeviceFloppy className="h-4 w-4" />
Save
<span className="text-xs text-muted-foreground">
({navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+S)
</span>
</>
)}
</Button>
</div>
</div>
{/* CodeMirror editor */}
<div className="flex-1 overflow-hidden relative">
<CodeMirror
ref={editorRef}
value={content}
height="100%"
theme={vscodeDark}
extensions={extensions}
onChange={handleChange}
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
className="h-full overflow-auto text-xs"
/>
{/* Floating "Add comment" button when text is selected */}
{floatingButtonPos && !textSelection && (
<Button
size="sm"
variant="secondary"
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
style={{
left: floatingButtonPos.x + 8,
top: floatingButtonPos.y + 8,
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={handleFloatingButtonClick}
>
<IconMessagePlus className="h-3.5 w-3.5" />
Comment
</Button>
)}
{/* Comment popover for adding new comment */}
{textSelection && (
<EditorCommentPopover
selectedText={textSelection.text}
lineRange={{ start: textSelection.startLine, end: textSelection.endLine }}
position={textSelection.position}
onSubmit={handleCommentSubmit}
onClose={handlePopoverClose}
/>
)}
{/* Comment view popover for viewing/deleting existing comments */}
{commentView && (
<CommentViewPopover
comments={commentView.comments}
position={commentView.position}
onDelete={handleDeleteComment}
onClose={handleCommentViewClose}
/>
)}
</div>
</div>
);
export function FileEditorContent(props: FileEditorContentProps) {
const provider = useEditorProvider('code-editor');
return provider === 'monaco'
? <MonacoCodeEditor {...props} />
: <CodeMirrorCodeEditor {...props} />;
}
@@ -1,605 +0,0 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useTheme } from 'next-themes';
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
import { nord } from '@milkdown/theme-nord';
import { commonmark, codeBlockSchema } from '@milkdown/preset-commonmark';
import { gfm } from '@milkdown/preset-gfm';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
import { Plugin, PluginKey } from '@milkdown/prose/state';
import { Decoration, DecorationSet } from '@milkdown/prose/view';
import { $prose, $view } from '@milkdown/utils';
import type { Node as ProseMirrorNode } from '@milkdown/prose/model';
import mermaid from 'mermaid';
export type TextSelection = {
text: string;
position: { x: number; y: number };
};
export type CommentHighlight = {
id: string;
selectedText: string;
comment: string;
};
type MilkdownEditorProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onSelectionChange?: (selection: TextSelection | null) => void;
/** Called when selection ends (mouseup) with selected text - for showing floating button */
onSelectionEnd?: (selection: TextSelection | null) => void;
comments?: CommentHighlight[];
onCommentClick?: (id: string, position: { x: number; y: number }) => void;
};
// Minimum text length required for comment highlighting
const MIN_COMMENT_TEXT_LENGTH = 3;
// Initialize mermaid with default config
let mermaidInitialized = false;
function initMermaid(theme: 'dark' | 'light' = 'dark') {
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
});
mermaidInitialized = true;
}
// Unique ID counter for mermaid diagrams
let mermaidIdCounter = 0;
// Create mermaid node view for code blocks using $view
const mermaidCodeBlockView = $view(codeBlockSchema.node, () => {
return (node: ProseMirrorNode) => {
const language = node.attrs.language as string;
// If not mermaid, return null to use default rendering
if (language !== 'mermaid') {
// Create default code block rendering
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = node.textContent;
if (language) {
code.className = `language-${language}`;
}
pre.appendChild(code);
return {
dom: pre,
contentDOM: code,
};
}
// Track current content for comparison in update
let currentContent = node.textContent;
// Create container for mermaid diagram
const dom = document.createElement('div');
dom.className = 'mermaid-container';
// Initialize mermaid if not already done
if (!mermaidInitialized) {
initMermaid();
}
// Render the mermaid diagram
const id = `mermaid-${++mermaidIdCounter}`;
mermaid.render(id, currentContent).then(({ svg }: { svg: string }) => {
dom.innerHTML = svg;
}).catch((err: Error) => {
dom.innerHTML = `<pre class="mermaid-error">Error rendering diagram: ${err.message}</pre>`;
});
return {
dom,
contentDOM: null,
update: (updatedNode: ProseMirrorNode) => {
if (updatedNode.type.name !== 'code_block') return false;
const updatedLang = updatedNode.attrs.language as string;
if (updatedLang !== 'mermaid') return false;
const newContent = updatedNode.textContent;
if (newContent !== currentContent) {
currentContent = newContent; // Update tracked content
const newId = `mermaid-${++mermaidIdCounter}`;
mermaid.render(newId, newContent).then(({ svg }: { svg: string }) => {
dom.innerHTML = svg;
}).catch((err: Error) => {
dom.innerHTML = `<pre class="mermaid-error">Error rendering diagram: ${err.message}</pre>`;
});
}
return true;
},
};
};
});
// Plugin key for managing comment highlights
const commentHighlightPluginKey = new PluginKey<{
deco: DecorationSet;
comments: CommentHighlight[];
}>('comment-highlight');
// Plugin key for placeholder
const placeholderPluginKey = new PluginKey<DecorationSet>('placeholder');
// Create placeholder plugin that shows text when editor is empty
function createPlaceholderPlugin(placeholder: string) {
return $prose(() => {
return new Plugin({
key: placeholderPluginKey,
props: {
decorations(state) {
const doc = state.doc;
// Check if document is empty (only has one empty paragraph)
if (doc.childCount === 1 && doc.firstChild?.type.name === 'paragraph' && doc.firstChild.content.size === 0) {
return DecorationSet.create(doc, [
Decoration.widget(1, () => {
const span = document.createElement('span');
span.className = 'editor-placeholder';
span.textContent = placeholder;
return span;
}, { side: 0 }),
]);
}
return DecorationSet.empty;
},
},
});
});
}
// Helper to get plain text content and position mapping from a document range
function getTextWithPositions(doc: Parameters<typeof DecorationSet.create>[0]): { text: string; positions: number[] } {
let text = '';
const positions: number[] = []; // Maps text index to document position
doc.descendants((node, pos) => {
if (node.isText && node.text) {
for (let i = 0; i < node.text.length; i++) {
positions.push(pos + i);
text += node.text[i];
}
} else if (node.type.name === 'hardbreak') {
// Handle hardbreak as a space/newline
positions.push(-1);
text += ' ';
} else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) {
// Add newline between blocks
positions.push(-1); // -1 indicates a synthetic newline
text += '\n';
}
});
return { text, positions };
}
// Normalize text for comparison (collapse whitespace, normalize newlines)
function normalizeForSearch(text: string): string {
return text
.replace(/\r\n/g, '\n')
.replace(/[\t ]+/g, ' ')
.replace(/\n+/g, '\n')
.trim()
.toLowerCase();
}
// Create decorations for comment highlights
function createCommentDecorations(
doc: Parameters<typeof DecorationSet.create>[0],
comments: CommentHighlight[]
): DecorationSet {
const decorations: Decoration[] = [];
if (comments.length === 0) {
return DecorationSet.empty;
}
// Get the full text content with position mapping
const { text: fullText, positions } = getTextWithPositions(doc);
const normalizedFullText = normalizeForSearch(fullText);
comments.forEach((comment) => {
const searchText = normalizeForSearch(comment.selectedText);
if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) {
return;
}
// Find the text in the normalized full document text
const normalizedIndex = normalizedFullText.indexOf(searchText);
if (normalizedIndex === -1) {
// Try with just the first line as fallback
const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]);
if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) {
const fallbackIndex = normalizedFullText.indexOf(firstLine);
if (fallbackIndex !== -1) {
createDecorationForRange(fallbackIndex, firstLine.length, comment, fullText, positions, decorations);
}
}
return;
}
createDecorationForRange(normalizedIndex, searchText.length, comment, fullText, positions, decorations);
});
return DecorationSet.create(doc, decorations);
}
// Helper to create decoration(s) for a text range, handling multi-line selections
function createDecorationForRange(
normalizedIndex: number,
length: number,
comment: CommentHighlight,
fullText: string,
positions: number[],
decorations: Decoration[]
) {
// Map normalized index back to original text index
const normalizedFullText = normalizeForSearch(fullText);
const normalizedChars = normalizedFullText.split('');
const origChars = fullText.split('');
// Map from normalized position to original position
const normToOrig: number[] = [];
let oi = 0;
for (let ni = 0; ni < normalizedChars.length; ni++) {
// Skip whitespace differences in original
while (oi < origChars.length &&
origChars[oi].toLowerCase() !== normalizedChars[ni] &&
/\s/.test(origChars[oi])) {
oi++;
}
normToOrig[ni] = oi;
oi++;
}
const startOrig = normToOrig[normalizedIndex] ?? 0;
const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig;
// Now create decorations for each contiguous range (split by newlines/block boundaries)
let rangeStart = -1;
for (let i = startOrig; i <= endOrig && i < positions.length; i++) {
const pos = positions[i];
if (pos === -1) {
// Hit a block boundary - close current range if open
if (rangeStart !== -1) {
const fromPos = positions[rangeStart];
const toPos = positions[i - 1] + 1;
if (fromPos >= 0 && toPos > fromPos) {
decorations.push(Decoration.inline(fromPos, toPos, {
class: 'comment-highlight',
'data-comment-id': comment.id,
title: comment.comment,
}));
}
rangeStart = -1;
}
} else {
// Valid position - start range if not started
if (rangeStart === -1) {
rangeStart = i;
}
}
}
// Close final range
if (rangeStart !== -1 && rangeStart < positions.length) {
const fromPos = positions[rangeStart];
const lastValidIndex = Math.min(endOrig, positions.length - 1);
const toPos = positions[lastValidIndex] + 1;
if (fromPos >= 0 && toPos > fromPos) {
decorations.push(Decoration.inline(fromPos, toPos, {
class: 'comment-highlight',
'data-comment-id': comment.id,
title: comment.comment,
}));
}
}
}
// Create the comment highlight plugin
function createCommentHighlightPlugin(initialComments: CommentHighlight[]) {
return $prose(() => {
return new Plugin({
key: commentHighlightPluginKey,
state: {
init(_, state) {
return {
deco: createCommentDecorations(state.doc, initialComments),
comments: initialComments,
};
},
apply(tr, value, _, newState) {
// Check if comments were updated via meta
const newComments = tr.getMeta(commentHighlightPluginKey);
if (newComments !== undefined) {
return {
deco: createCommentDecorations(newState.doc, newComments),
comments: newComments,
};
}
// If document changed, remap decorations
if (tr.docChanged) {
return {
deco: createCommentDecorations(newState.doc, value.comments),
comments: value.comments,
};
}
return value;
},
},
props: {
decorations(state) {
const pluginState = commentHighlightPluginKey.getState(state);
return pluginState?.deco ?? DecorationSet.empty;
},
},
});
});
}
function MilkdownEditorInner({
value,
onChange,
placeholder = 'Start typing...',
onSelectionChange,
onSelectionEnd,
comments = [],
onCommentClick,
}: MilkdownEditorProps) {
const { resolvedTheme } = useTheme();
const wrapperRef = useRef<HTMLDivElement>(null);
// Capture initial value at mount - editor manages its own state after that
const initialValueRef = useRef(value);
const placeholderRef = useRef(placeholder);
const onChangeRef = useRef(onChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const onSelectionEndRef = useRef(onSelectionEnd);
const commentsRef = useRef(comments);
const onCommentClickRef = useRef(onCommentClick);
// Keep refs updated
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
useEffect(() => {
onSelectionEndRef.current = onSelectionEnd;
}, [onSelectionEnd]);
useEffect(() => {
commentsRef.current = comments;
}, [comments]);
useEffect(() => {
onCommentClickRef.current = onCommentClick;
}, [onCommentClick]);
// Set up click handler for comment highlights via event delegation
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const commentHighlight = target.closest('.comment-highlight');
if (commentHighlight && onCommentClickRef.current) {
const commentId = commentHighlight.getAttribute('data-comment-id');
if (commentId) {
onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY });
}
}
};
wrapper.addEventListener('click', handleClick);
return () => wrapper.removeEventListener('click', handleClick);
}, []);
// Handle mouseup to show floating button when selection ends
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleMouseUp = (e: MouseEvent) => {
if (!onSelectionEndRef.current) return;
// Capture mouse position immediately
const mouseX = e.clientX;
const mouseY = e.clientY;
// Small delay to let selection finalize
setTimeout(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
onSelectionEndRef.current?.(null);
return;
}
const selectedText = selection.toString().trim();
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
onSelectionEndRef.current?.(null);
return;
}
// Use mouse position (where cursor is) instead of selection end
onSelectionEndRef.current?.({
text: selectedText,
position: { x: mouseX, y: mouseY },
});
}, 10);
};
const handleMouseDown = (e: MouseEvent) => {
// Don't clear if clicking on the floating button
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
return;
}
// Clear selection state when starting new selection
onSelectionEndRef.current?.(null);
};
wrapper.addEventListener('mouseup', handleMouseUp);
wrapper.addEventListener('mousedown', handleMouseDown);
return () => {
wrapper.removeEventListener('mouseup', handleMouseUp);
wrapper.removeEventListener('mousedown', handleMouseDown);
};
}, []);
// Handle Cmd+I to trigger selection popover (overrides italic formatting)
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
// Only handle if we have a selection callback
if (!onSelectionChangeRef.current) return;
const selection = window.getSelection();
if (!selection) return;
// Prevent Milkdown's italic formatting immediately
e.preventDefault();
e.stopImmediatePropagation();
// Now check if we have valid selected text
if (selection.isCollapsed || !selection.toString().trim()) {
return;
}
const selectedText = selection.toString().trim();
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
return;
}
// Get position from the focus point (end of selection where cursor is)
const focusNode = selection.focusNode;
const focusOffset = selection.focusOffset;
if (focusNode) {
// Create a range at the focus point to get its position
const focusRange = document.createRange();
focusRange.setStart(focusNode, focusOffset);
focusRange.setEnd(focusNode, focusOffset);
const focusRect = focusRange.getBoundingClientRect();
// If focusRect has no width (collapsed), use its position
// Otherwise fall back to the selection's last rect
let x = focusRect.right || focusRect.left;
let y = focusRect.bottom || focusRect.top;
// If position is 0,0 (can happen), fall back to last rect of selection
if (x === 0 && y === 0) {
const range = selection.getRangeAt(0);
const rects = range.getClientRects();
const lastRect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect();
x = lastRect.right;
y = lastRect.bottom;
}
onSelectionChangeRef.current({
text: selectedText,
position: { x, y },
});
}
}
};
// Attach to wrapper element in capture phase to intercept before Milkdown
wrapper.addEventListener('keydown', handleKeyDown, true);
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
}, []);
// Create the highlight plugin once with initial comments
const highlightPlugin = useCallback(
() => createCommentHighlightPlugin(commentsRef.current),
[]
);
// Create the placeholder plugin once
const placeholderPlugin = useCallback(
() => createPlaceholderPlugin(placeholderRef.current),
[]
);
const { loading, get } = useEditor((root) =>
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
ctx.set(defaultValueCtx, initialValueRef.current);
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
onChangeRef.current(markdown);
});
})
.config(nord)
.use(commonmark)
.use(gfm)
.use(mermaidCodeBlockView)
.use(listener)
.use(highlightPlugin())
.use(placeholderPlugin())
);
// Update decorations when comments change
useEffect(() => {
const editor = get();
if (!editor || loading) return;
try {
// Get the editor view and dispatch a transaction to update comments
editor.action((ctx) => {
try {
const view = ctx.get(editorViewCtx);
if (view) {
const tr = view.state.tr.setMeta(commentHighlightPluginKey, comments);
view.dispatch(tr);
}
} catch {
// editorViewCtx not available yet
}
});
} catch {
// Editor might not be ready
}
}, [comments, get, loading]);
return (
<div
ref={wrapperRef}
className={`milkdown-wrapper h-full relative ${resolvedTheme === 'dark' ? 'dark' : ''}`}
>
<Milkdown />
{loading && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-background/80">
Loading editor...
</div>
)}
</div>
);
}
export function MarkdownEditor(props: MilkdownEditorProps) {
// Generate a unique key on each mount to force fresh Milkdown instance
const [instanceId] = useState(() => Math.random().toString(36).slice(2));
return (
<MilkdownProvider key={instanceId}>
<MilkdownEditorInner {...props} />
</MilkdownProvider>
);
}
@@ -34,6 +34,7 @@ import { ReviewDialog } from '@/components/review/review-dialog';
import type { ReviewFile } from '@/components/review/types';
import { hashDiff, normalizeDiffContent } from '@/components/review/types';
import type { DiffComment } from '@/lib/diff/types';
import { usePanelActions } from '@/hooks/use-panel-actions';
import type { SelectedDiff } from './task-layout';
type TaskChangesPanelProps = {
@@ -46,7 +47,11 @@ type TaskChangesPanelProps = {
const TaskChangesPanel = memo(function TaskChangesPanel({
selectedDiff,
onClearSelected,
onOpenFile: onOpenFileProp,
}: TaskChangesPanelProps) {
const { openFile: panelOpenFile } = usePanelActions();
const handleOpenFile = onOpenFileProp ?? panelOpenFile;
const [splitView, setSplitView] = useState(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('diff-view-mode') === 'split';
@@ -418,6 +423,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
wordWrap={wordWrap}
onToggleReviewed={handleToggleReviewed}
onDiscard={handleDiscard}
onOpenFile={handleOpenFile}
fileRefs={fileRefs}
/>
) : null}
@@ -432,6 +438,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
baseBranch={baseBranch}
taskTitle={taskTitle}
onSendComments={handleDialogSendComments}
onOpenFile={handleOpenFile}
gitStatusFiles={gitStatus?.files ?? null}
cumulativeDiff={cumulativeDiff}
/>
+5 -5
View File
@@ -11,14 +11,14 @@ import { useTaskPlan } from '@/hooks/domains/session/use-task-plan';
import { useAppStore } from '@/components/state-provider';
import { PlanSelectionPopover } from './plan-selection-popover';
import type { PlanComment } from './plan-comments';
import type { TextSelection, CommentHighlight } from './markdown-editor';
import type { TextSelection, CommentHighlight } from '@/components/editors/tiptap/tiptap-plan-editor';
import type { DocumentComment } from '@/lib/state/slices/ui/types';
const EMPTY_COMMENTS: DocumentComment[] = [];
// Dynamic import to avoid SSR issues with Milkdown
const MarkdownEditor = dynamic(
() => import('./markdown-editor').then((mod) => mod.MarkdownEditor),
// Dynamic import to avoid SSR issues with TipTap
const PlanEditor = dynamic(
() => import('@/components/editors/tiptap/tiptap-plan-editor').then((mod) => mod.TipTapPlanEditor),
{ ssr: false, loading: () => <div className="flex h-full items-center justify-center text-muted-foreground text-sm">Loading editor...</div> }
);
@@ -309,7 +309,7 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
ref={editorWrapperRef}
onClick={handleEmptyStateClick}
>
<MarkdownEditor
<PlanEditor
key={`${taskId}-${editorKey}`}
value={draftContent}
onChange={setDraftContent}