diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 77d60ad12b..858d9ef1e5 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1164,6 +1164,53 @@ color: var(--primary); } +.markdown-preview-source-block { + position: relative; +} + +.markdown-preview-commented-range { + background-color: color-mix(in oklch, var(--accent) 46%, transparent); + border-radius: 4px; + cursor: pointer; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.markdown-preview-commented-range:hover { + background-color: color-mix(in oklch, var(--accent) 62%, transparent); +} + +.markdown-preview-comment-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-left: 6px; + color: var(--primary); + vertical-align: middle; + border: 1px solid color-mix(in oklch, var(--primary) 35%, transparent); + border-radius: 4px; + background-color: color-mix(in oklch, var(--background) 88%, transparent); + cursor: pointer; +} + +.markdown-preview-comment-badge:hover { + color: var(--primary-foreground); + background-color: var(--primary); +} + +.markdown-preview-pre-comment-wrapper { + position: relative; +} + +.markdown-preview-pre-comment-wrapper .markdown-preview-comment-badge { + position: absolute; + top: 6px; + right: 6px; + margin-left: 0; +} + /* Drag handle */ .plan-drag-handle { display: flex; diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx index 80520c878d..ff93ccd8fa 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -299,6 +299,7 @@ function CodeMirrorOverlays({ state }: { state: CodeMirrorEditorState }) { comments={state.commentView.comments} position={state.commentView.position} onDelete={state.handleDeleteComment} + onUpdate={state.handleUpdateComment} onClose={state.handleCommentViewClose} /> )} diff --git a/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts b/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts index 919ed1cefa..181d2fbecd 100644 --- a/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts +++ b/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts @@ -118,17 +118,19 @@ function buildCommentGutter( return gutter({ class: "cm-comment-gutter", lineMarker: (view, line) => { - const lineNum = view.state.doc.lineAt(line.from).number; - const lineComments = commentsByLine.get(lineNum); + const docLine = view.state.doc.lineAt(line.from); + if (line.from !== docLine.from) return null; + const lineComments = commentsByLine.get(docLine.number); if (lineComments && lineComments.length > 0) { - return new CommentGutterMarker(lineComments, firstLines.has(lineNum)); + return new CommentGutterMarker(lineComments, firstLines.has(docLine.number)); } return null; }, domEventHandlers: { click: (view, line, event) => { - const lineNum = view.state.doc.lineAt(line.from).number; - const lineComments = commentsByLine.get(lineNum); + const docLine = view.state.doc.lineAt(line.from); + if (line.from !== docLine.from) return false; + const lineComments = commentsByLine.get(docLine.number); if (lineComments && lineComments.length > 0) { event.preventDefault(); event.stopPropagation(); @@ -224,6 +226,7 @@ export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) { const addComment = useCommentsStore((state) => state.addComment); const removeComment = useCommentsStore((state) => state.removeComment); + const updateComment = useCommentsStore((state) => state.updateComment); const comments = useDiffFileComments(sessionId ?? "", path); const langExt = getCodeMirrorExtensionFromPath(path); @@ -504,17 +507,31 @@ export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) { (commentId: string) => { if (!sessionId) return; removeComment(commentId); - if (commentView && commentView.comments.length <= 1) { - setCommentView(null); - } else if (commentView) { - setCommentView({ - ...commentView, - comments: commentView.comments.filter((c) => c.id !== commentId), - }); - } + setCommentView((view) => { + if (!view) return view; + const nextComments = view.comments.filter((comment) => comment.id !== commentId); + return nextComments.length > 0 ? { ...view, comments: nextComments } : null; + }); toast({ title: "Comment deleted" }); }, - [sessionId, removeComment, commentView, toast], + [sessionId, removeComment, toast], + ); + + const handleUpdateComment = useCallback( + (commentId: string, text: string) => { + updateComment(commentId, { text }); + setCommentView((view) => { + if (!view) return view; + return { + ...view, + comments: view.comments.map((comment) => + comment.id === commentId ? { ...comment, text } : comment, + ), + }; + }); + toast({ title: "Comment updated" }); + }, + [toast, updateComment], ); const handleCommentViewClose = useCallback(() => { @@ -536,6 +553,7 @@ export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) { handleCommentSubmitAndRun, handlePopoverClose, handleDeleteComment, + handleUpdateComment, handleCommentViewClose, }; } diff --git a/apps/web/components/editors/monaco/use-monaco-editor-state.ts b/apps/web/components/editors/monaco/use-monaco-editor-state.ts index 2e9365a73a..4ead1e889b 100644 --- a/apps/web/components/editors/monaco/use-monaco-editor-state.ts +++ b/apps/web/components/editors/monaco/use-monaco-editor-state.ts @@ -214,7 +214,7 @@ export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) { isWholeLine: true, className: "monaco-comment-line", lineNumberClassName: "monaco-comment-line-number", - linesDecorationsClassName: firstLines.has(lineNum) + firstLineDecorationClassName: firstLines.has(lineNum) ? "monaco-comment-bar-icon" : "monaco-comment-bar", }, diff --git a/apps/web/components/task/comment-view-popover.tsx b/apps/web/components/task/comment-view-popover.tsx index 5b23acd46b..914dc7da76 100644 --- a/apps/web/components/task/comment-view-popover.tsx +++ b/apps/web/components/task/comment-view-popover.tsx @@ -1,91 +1,21 @@ "use client"; -import { useRef, useEffect, useState, useCallback } from "react"; -import { IconTrash, IconGripHorizontal } from "@tabler/icons-react"; +import { useRef, useState, useCallback } from "react"; +import { IconEdit, IconTrash, IconGripHorizontal } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { cn } from "@/lib/utils"; import type { DiffComment } from "@/lib/diff/types"; +import { CommentForm } from "@/components/diff/comment-form"; +import { useDraggablePopover, usePopoverDismiss } from "@/components/task/use-draggable-popover"; type CommentViewPopoverProps = { comments: DiffComment[]; position: { x: number; y: number }; onDelete: (commentId: string) => void; + onUpdate?: (commentId: string, text: string) => void; onClose: () => void; }; -type DragState = { startX: number; startY: number; origLeft: number; origTop: number }; - -function computePopoverInitialPos( - position: { x: number; y: number }, - width: number, - height: number, -) { - let left = position.x; - let top = position.y; - if (left + width > window.innerWidth - 16) left = Math.max(16, window.innerWidth - width - 16); - if (top + height > window.innerHeight - 16) top = Math.max(16, position.y - height); - return { left, top }; -} - -function useDraggablePos(position: { x: number; y: number }, width: number, height: number) { - const [pos, setPos] = useState(() => computePopoverInitialPos(position, width, height)); - const dragRef = useRef(null); - - const onDragStart = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - dragRef.current = { - startX: e.clientX, - startY: e.clientY, - origLeft: pos.left, - origTop: pos.top, - }; - const onMove = (ev: MouseEvent) => { - if (!dragRef.current) return; - const dx = ev.clientX - dragRef.current.startX; - const dy = ev.clientY - dragRef.current.startY; - setPos({ left: dragRef.current.origLeft + dx, top: dragRef.current.origTop + dy }); - }; - const onUp = () => { - dragRef.current = null; - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - }, - [pos.left, pos.top], - ); - - return { pos, onDragStart }; -} - -function usePopoverClose(onClose: () => void, popoverRef: React.RefObject) { - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) onClose(); - }; - const timer = setTimeout(() => { - document.addEventListener("mousedown", handleClickOutside); - }, 100); - return () => { - clearTimeout(timer); - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [onClose, popoverRef]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.stopPropagation(); - onClose(); - } - }; - document.addEventListener("keydown", handleKeyDown, true); - return () => document.removeEventListener("keydown", handleKeyDown, true); - }, [onClose]); -} - function formatLineRange(start: number, end: number) { return start === end ? `L${start}` : `L${start}-${end}`; } @@ -93,31 +23,76 @@ function formatLineRange(start: number, end: number) { function CommentItem({ comment, onDelete, + onUpdate, + isEditing, + onStartEdit, + onCancelEdit, }: { comment: DiffComment; onDelete: (id: string) => void; + onUpdate?: (id: string, text: string) => void; + isEditing: boolean; + onStartEdit: (id: string) => void; + onCancelEdit: () => void; }) { + const handleUpdate = useCallback( + (text: string) => { + onUpdate?.(comment.id, text); + onCancelEdit(); + }, + [comment.id, onCancelEdit, onUpdate], + ); + return (
{formatLineRange(comment.startLine, comment.endLine)} - +
+ {onUpdate && !isEditing && ( + + )} + +
- {comment.codeContent && ( -
-          {comment.codeContent}
-        
+ {isEditing ? ( + + ) : ( + <> + {comment.codeContent && ( +
+              {comment.codeContent}
+            
+ )} +

+ {comment.text} +

+ )} -

{comment.text}

); } @@ -126,11 +101,13 @@ export function CommentViewPopover({ comments, position, onDelete, + onUpdate, onClose, }: CommentViewPopoverProps) { const popoverRef = useRef(null); - const { pos, onDragStart } = useDraggablePos(position, 350, 200); - usePopoverClose(onClose, popoverRef); + const [editingCommentId, setEditingCommentId] = useState(null); + const { pos, onDragStart } = useDraggablePopover(position, 350, 200); + usePopoverDismiss(onClose, popoverRef); return (
{comments.map((comment) => ( - + setEditingCommentId(null)} + /> ))}
diff --git a/apps/web/components/task/editor-comment-popover.tsx b/apps/web/components/task/editor-comment-popover.tsx index a829f88fd7..d2725abec5 100644 --- a/apps/web/components/task/editor-comment-popover.tsx +++ b/apps/web/components/task/editor-comment-popover.tsx @@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button"; import { Textarea } from "@kandev/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; +import { useDraggablePopover, usePopoverDismiss } from "@/components/task/use-draggable-popover"; type EditorCommentPopoverProps = { selectedText: string; @@ -16,76 +17,9 @@ type EditorCommentPopoverProps = { onClose: () => void; }; -type DragState = { startX: number; startY: number; origLeft: number; origTop: number }; - -function computeInitialPos(position: { x: number; y: number }, width: number, height: number) { - let left = position.x; - let top = position.y; - if (left + width > window.innerWidth - 16) left = Math.max(16, window.innerWidth - width - 16); - if (top + height > window.innerHeight - 16) top = Math.max(16, position.y - height); - return { left, top }; -} - -function useDraggablePos(position: { x: number; y: number }, width: number, height: number) { - const [pos, setPos] = useState(() => computeInitialPos(position, width, height)); - const dragRef = useRef(null); - - const onDragStart = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - dragRef.current = { - startX: e.clientX, - startY: e.clientY, - origLeft: pos.left, - origTop: pos.top, - }; - const onMove = (ev: MouseEvent) => { - if (!dragRef.current) return; - const dx = ev.clientX - dragRef.current.startX; - const dy = ev.clientY - dragRef.current.startY; - setPos({ left: dragRef.current.origLeft + dx, top: dragRef.current.origTop + dy }); - }; - const onUp = () => { - dragRef.current = null; - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - }, - [pos.left, pos.top], - ); - - return { pos, onDragStart }; -} - -function usePopoverDismiss( - onClose: () => void, - popoverRef: React.RefObject, -) { - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) onClose(); - }; - const timer = setTimeout(() => { - document.addEventListener("mousedown", handleClickOutside); - }, 100); - return () => { - clearTimeout(timer); - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [onClose, popoverRef]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.stopPropagation(); - onClose(); - } - }; - document.addEventListener("keydown", handleKeyDown, true); - return () => document.removeEventListener("keydown", handleKeyDown, true); - }, [onClose]); +function getPopoverWidth() { + if (typeof window === "undefined") return 384; + return Math.min(384, Math.max(280, window.innerWidth - 32)); } function PopoverBody({ @@ -195,7 +129,7 @@ export function EditorCommentPopover({ const [comment, setComment] = useState(""); const textareaRef = useRef(null); const popoverRef = useRef(null); - const { pos, onDragStart } = useDraggablePos(position, 384, 220); + const { pos, onDragStart } = useDraggablePopover(position, getPopoverWidth(), 220); usePopoverDismiss(onClose, popoverRef); useEffect(() => { @@ -221,7 +155,7 @@ export function EditorCommentPopover({
); diff --git a/apps/web/components/task/file-editor-panel.tsx b/apps/web/components/task/file-editor-panel.tsx index bc439ccbdf..0d21381a61 100644 --- a/apps/web/components/task/file-editor-panel.tsx +++ b/apps/web/components/task/file-editor-panel.tsx @@ -237,6 +237,7 @@ export const FileEditorPanel = memo(function FileEditorPanel({ const updateFileState = useDockviewStore((s) => s.updateFileState); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSession = useAppStore((state) => activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null, ); @@ -275,6 +276,7 @@ export const FileEditorPanel = memo(function FileEditorPanel({ } const worktreePath = activeSession?.worktree_path ?? undefined; + const repositoryId = activeSession?.repository_id ?? undefined; const category = resolveFileCategory(isBinary, path); if (category === "image") @@ -304,6 +306,8 @@ export const FileEditorPanel = memo(function FileEditorPanel({ vcsDiff={vcsDiff} isSaving={savingFiles.has(fileKey)} sessionId={activeSessionId || undefined} + taskId={activeTaskId} + repositoryId={repositoryId} worktreePath={worktreePath} repo={repo} enableComments={!!activeSessionId} diff --git a/apps/web/components/task/file-tab-content.tsx b/apps/web/components/task/file-tab-content.tsx index 9c301b810f..eeee5db051 100644 --- a/apps/web/components/task/file-tab-content.tsx +++ b/apps/web/components/task/file-tab-content.tsx @@ -11,14 +11,16 @@ export function FileTabContent({ tab, activeSession, activeSessionId, + taskId, isSaving, onFileChange, onFileSave, onFileDelete, }: { tab: OpenFileTab; - activeSession: { worktree_path?: string | null } | null; + activeSession: { worktree_path?: string | null; repository_id?: string | null } | null; activeSessionId: string | null; + taskId?: string | null; isSaving: boolean; onFileChange: (path: string, content: string) => void; onFileSave: (path: string) => void; @@ -51,6 +53,8 @@ export function FileTabContent({ isDirty={tab.isDirty} isSaving={isSaving} sessionId={activeSessionId || undefined} + taskId={taskId} + repositoryId={activeSession?.repository_id ?? undefined} worktreePath={activeSession?.worktree_path ?? undefined} enableComments={!!activeSessionId} onChange={(newContent) => onFileChange(tab.path, newContent)} diff --git a/apps/web/components/task/markdown-preview-content.test.ts b/apps/web/components/task/markdown-preview-content.test.ts new file mode 100644 index 0000000000..e5446b6930 --- /dev/null +++ b/apps/web/components/task/markdown-preview-content.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { isInteractiveSourceClickTarget } from "./markdown-preview-content"; + +describe("isInteractiveSourceClickTarget", () => { + it("treats SVG descendants inside interactive elements as interactive", () => { + const button = document.createElement("button"); + button.innerHTML = ""; + + expect(isInteractiveSourceClickTarget(button.querySelector("path"))).toBe(true); + }); + + it("ignores plain non-interactive elements", () => { + expect(isInteractiveSourceClickTarget(document.createElement("span"))).toBe(false); + }); +}); diff --git a/apps/web/components/task/markdown-preview-content.tsx b/apps/web/components/task/markdown-preview-content.tsx index 6910a714fc..d338e5f683 100644 --- a/apps/web/components/task/markdown-preview-content.tsx +++ b/apps/web/components/task/markdown-preview-content.tsx @@ -1,23 +1,49 @@ "use client"; -import { memo } from "react"; -import ReactMarkdown from "react-markdown"; +import { + createContext, + createElement, + memo, + useEffect, + useContext, + useMemo, + useRef, + useState, + type HTMLAttributes, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import ReactMarkdown, { type ExtraProps, type Components } from "react-markdown"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { IconCode } from "@tabler/icons-react"; +import { IconCode, IconMessagePlus } from "@tabler/icons-react"; import { remarkPlugins, markdownComponents } from "@/components/shared/markdown-components"; -import { toRelativePath } from "@/lib/utils"; +import { cn, toRelativePath } from "@/lib/utils"; import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; +import { EditorCommentPopover } from "@/components/task/editor-comment-popover"; +import { CommentViewPopover } from "@/components/task/comment-view-popover"; +import { useMarkdownPreviewComments } from "@/hooks/domains/comments/use-markdown-preview-comments"; +import { + SOURCE_END_ATTR, + SOURCE_START_ATTR, + type SourceLineRange, +} from "@/lib/markdown/source-line-ranges"; +import { commentsBeginInRange, commentsOverlapRange } from "@/lib/markdown/preview-comments"; +import type { DiffComment } from "@/lib/state/slices/comments"; interface MarkdownPreviewToolbarProps { path: string; worktreePath?: string; + commentCount: number; + commentsEnabled: boolean; onTogglePreview: () => void; } function MarkdownPreviewToolbar({ path, worktreePath, + commentCount, + commentsEnabled, onTogglePreview, }: MarkdownPreviewToolbarProps) { return ( @@ -30,6 +56,14 @@ function MarkdownPreviewToolbar({ } right={
+ {commentsEnabled && commentCount > 0 && ( +
+ + + {commentCount} comment{commentCount > 1 ? "s" : ""} + +
+ )} + ); +} + +function SourceBlock({ tag, node, children, className, onClick, ...rest }: SourceBlockProps) { + const commentContext = useContext(PreviewCommentContext); + const range = sourceRangeFromNode(node); + const comments = commentContext?.comments ?? []; + const isCommented = range ? commentsOverlapRange(comments, range) : false; + const canRenderBadge = tag !== "blockquote" && tag !== "li"; + const hasCommentBadge = canRenderBadge && range ? commentsBeginInRange(comments, range) : false; + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + if (!range || !isCommented || event.defaultPrevented || !commentContext) return; + if (isInteractiveSourceClickTarget(event.target)) return; + if (window.getSelection()?.toString().trim()) return; + commentContext.showCommentsForRange(range, { x: event.clientX, y: event.clientY }); + }; + const handleBadgeClick = (event: React.MouseEvent) => { + if (!range || !commentContext) return; + event.preventDefault(); + event.stopPropagation(); + commentContext.showCommentsForRange(range, { x: event.clientX, y: event.clientY }); + }; + const element = createElement( + tag, + { + ...rest, + ...sourceDataAttrs(range), + "data-testid": isCommented + ? "markdown-preview-commented-range" + : "markdown-preview-source-block", + className: cn( + "markdown-preview-source-block", + isCommented && "markdown-preview-commented-range", + className, + ), + onClick: handleClick, + }, + children, + hasCommentBadge && tag !== "pre" ? : null, + ); + + if (!hasCommentBadge || tag !== "pre") return element; + return ( +
+ {element} + +
+ ); +} + +function MarkdownPreviewTable({ node, children }: MarkdownSourceBlockProps) { + return ( + + {children}
+
+ ); +} + +function sourceComponent(tag: keyof HTMLElementTagNameMap) { + return function MarkdownPreviewSourceComponent(props: MarkdownSourceBlockProps) { + return ; + }; +} + +const markdownPreviewComponents: Components = { + ...markdownComponents, + p: sourceComponent("p"), + h1: sourceComponent("h1"), + h2: sourceComponent("h2"), + h3: sourceComponent("h3"), + h4: sourceComponent("h4"), + h5: sourceComponent("h5"), + h6: sourceComponent("h6"), + li: sourceComponent("li"), + blockquote: sourceComponent("blockquote"), + pre: sourceComponent("pre"), + table: (props) => , +}; + +type MarkdownPreviewCommentState = ReturnType; + +function MarkdownPreviewCommentOverlays({ + commentsEnabled, + overlayRoot, + commentState, +}: { + commentsEnabled: boolean; + overlayRoot: HTMLElement | null; + commentState: MarkdownPreviewCommentState; +}) { + if (!commentsEnabled || !overlayRoot) return null; + + return createPortal( + <> + {commentState.currentSelection && !commentState.textSelection && ( + + )} + {commentState.textSelection && ( +
+ +
+ )} + {commentState.commentView && ( + + )} + , + overlayRoot, + ); +} + export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({ path, content, worktreePath, + sessionId, + taskId, + repositoryId, + enableComments = false, onTogglePreview, }: MarkdownPreviewContentProps) { + const rootRef = useRef(null); + const scrollRef = useRef(null); + const [overlayRoot, setOverlayRoot] = useState(null); + const commentsEnabled = enableComments && !!sessionId; + const commentState = useMarkdownPreviewComments({ + path, + content, + sessionId, + taskId, + repositoryId, + enabled: commentsEnabled, + rootRef, + }); + const previewCommentContextValue = useMemo( + () => ({ + comments: commentState.comments, + showCommentsForRange: commentState.showCommentsForRange, + }), + [commentState.comments, commentState.showCommentsForRange], + ); + const dismissCommentOverlays = commentState.dismissOverlays; + + useEffect(() => { + setOverlayRoot(document.body); + }, []); + + useEffect(() => { + if (!commentsEnabled) return; + const scrollElement = scrollRef.current; + const dismissOverlays = () => dismissCommentOverlays(); + scrollElement?.addEventListener("scroll", dismissOverlays, { passive: true }); + window.addEventListener("resize", dismissOverlays); + return () => { + scrollElement?.removeEventListener("scroll", dismissOverlays); + window.removeEventListener("resize", dismissOverlays); + }; + }, [commentsEnabled, dismissCommentOverlays]); + return ( -
+
-
-
- - {content} - +
+
+ + + {content} + +
+
); }); diff --git a/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx b/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx index 652152d3a7..a06cb8fd05 100644 --- a/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx +++ b/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx @@ -32,7 +32,9 @@ export function MobileFileViewerPanel({ file, sessionId, onClose }: MobileFileVi const activeSession = useAppStore((state) => sessionId ? (state.taskSessions.items[sessionId] ?? null) : null, ); + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const worktreePath = activeSession?.worktree_path ?? undefined; + const repositoryId = activeSession?.repository_id ?? undefined; const viewerKind = useMemo(() => resolveViewerKind(file), [file]); const markdownFile = isMarkdownFile(file.path); @@ -86,6 +88,10 @@ export function MobileFileViewerPanel({ file, sessionId, onClose }: MobileFileVi path={file.path} content={content} worktreePath={worktreePath} + sessionId={sessionId ?? undefined} + taskId={activeTaskId} + repositoryId={repositoryId} + enableComments={!!sessionId} onTogglePreview={() => setMarkdownPreview((current) => !current)} /> ) : ( diff --git a/apps/web/components/task/task-center-panel.tsx b/apps/web/components/task/task-center-panel.tsx index a347c9039d..aae7a85302 100644 --- a/apps/web/components/task/task-center-panel.tsx +++ b/apps/web/components/task/task-center-panel.tsx @@ -426,6 +426,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel(props: TaskCenterPa tab={tab} activeSession={activeSession} activeSessionId={activeSessionId} + taskId={activeSessionId ? activeTaskId : null} isSaving={savingFiles.has(tab.path)} onFileChange={handleFileChange} onFileSave={handleFileSave} diff --git a/apps/web/components/task/use-draggable-popover.test.ts b/apps/web/components/task/use-draggable-popover.test.ts new file mode 100644 index 0000000000..784d28bd30 --- /dev/null +++ b/apps/web/components/task/use-draggable-popover.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isEditablePopoverDismissTarget } from "./use-draggable-popover"; + +describe("isEditablePopoverDismissTarget", () => { + it("treats form controls as editable popover targets", () => { + const wrapper = document.createElement("div"); + wrapper.innerHTML = ""; + + expect(isEditablePopoverDismissTarget(wrapper.querySelector("textarea"))).toBe(true); + }); + + it("treats contenteditable descendants as editable popover targets", () => { + const editor = document.createElement("div"); + editor.setAttribute("contenteditable", "true"); + const child = document.createElement("span"); + editor.appendChild(child); + + expect(isEditablePopoverDismissTarget(child)).toBe(true); + }); + + it("ignores non-editable targets", () => { + expect(isEditablePopoverDismissTarget(document.createElement("button"))).toBe(false); + }); +}); diff --git a/apps/web/components/task/use-draggable-popover.ts b/apps/web/components/task/use-draggable-popover.ts new file mode 100644 index 0000000000..e1447a6775 --- /dev/null +++ b/apps/web/components/task/use-draggable-popover.ts @@ -0,0 +1,96 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type RefObject, +} from "react"; + +type AnchorPosition = { x: number; y: number }; +type PopoverPosition = { left: number; top: number }; +type DragState = { startX: number; startY: number; origLeft: number; origTop: number }; + +export function isEditablePopoverDismissTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return Boolean(target.closest("input, textarea, select, [contenteditable]")); +} + +export function computePopoverInitialPos( + position: AnchorPosition, + width: number, + height: number, +): PopoverPosition { + if (typeof window === "undefined") return { left: position.x, top: position.y }; + + let left = position.x; + let top = position.y; + if (left + width > window.innerWidth - 16) left = Math.max(16, window.innerWidth - width - 16); + if (top + height > window.innerHeight - 16) top = Math.max(16, position.y - height); + return { left, top }; +} + +export function useDraggablePopover(position: AnchorPosition, width: number, height: number) { + const [pos, setPos] = useState(() => computePopoverInitialPos(position, width, height)); + const dragRef = useRef(null); + + const onDragStart = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + dragRef.current = { + startX: event.clientX, + startY: event.clientY, + origLeft: pos.left, + origTop: pos.top, + }; + const onMove = (moveEvent: MouseEvent) => { + if (!dragRef.current) return; + const dx = moveEvent.clientX - dragRef.current.startX; + const dy = moveEvent.clientY - dragRef.current.startY; + setPos({ left: dragRef.current.origLeft + dx, top: dragRef.current.origTop + dy }); + }; + const onUp = () => { + dragRef.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [pos.left, pos.top], + ); + + return { pos, onDragStart }; +} + +export function usePopoverDismiss( + onClose: () => void, + popoverRef: RefObject, +) { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) onClose(); + }; + const timer = setTimeout(() => { + document.addEventListener("mousedown", handleClickOutside); + }, 100); + return () => { + clearTimeout(timer); + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [onClose, popoverRef]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isEditablePopoverDismissTarget(event.target)) return; + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown, true); + return () => document.removeEventListener("keydown", handleKeyDown, true); + }, [onClose]); +} diff --git a/apps/web/e2e/helpers/markdown-preview.ts b/apps/web/e2e/helpers/markdown-preview.ts new file mode 100644 index 0000000000..4b32bf1350 --- /dev/null +++ b/apps/web/e2e/helpers/markdown-preview.ts @@ -0,0 +1,45 @@ +import type { Locator } from "@playwright/test"; + +export async function selectMarkdownPreviewText(target: Locator): Promise { + await target.evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = window.getSelection(); + if (!selection) throw new Error("Browser selection is unavailable"); + selection.removeAllRanges(); + selection.addRange(range); + + const rect = element.getBoundingClientRect(); + element.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + clientX: rect.left + rect.width / 2, + clientY: rect.bottom, + }), + ); + }); +} + +export async function selectMarkdownPreviewRange(start: Locator, end: Locator): Promise { + await start.evaluate( + (startElement, endElement) => { + const range = document.createRange(); + range.setStartBefore(startElement); + range.setEndAfter(endElement); + const selection = window.getSelection(); + if (!selection) throw new Error("Browser selection is unavailable"); + selection.removeAllRanges(); + selection.addRange(range); + + const rect = endElement.getBoundingClientRect(); + endElement.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + clientX: rect.left + rect.width / 2, + clientY: rect.bottom, + }), + ); + }, + await end.elementHandle(), + ); +} diff --git a/apps/web/e2e/tests/chat/markdown-preview.spec.ts b/apps/web/e2e/tests/chat/markdown-preview.spec.ts index 394bbece65..779c6d6fa0 100644 --- a/apps/web/e2e/tests/chat/markdown-preview.spec.ts +++ b/apps/web/e2e/tests/chat/markdown-preview.spec.ts @@ -4,6 +4,10 @@ import { type Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; +import { + selectMarkdownPreviewRange, + selectMarkdownPreviewText, +} from "../../helpers/markdown-preview"; import { SessionPage } from "../../pages/session-page"; const MARKDOWN_CONTENT = `# Hello World @@ -65,6 +69,23 @@ async function openFileInPreview( await expect(testPage.getByTestId("markdown-preview")).toBeVisible({ timeout: 5_000 }); } +/** Open a markdown file from the Files panel and leave it in code mode. */ +async function openFileInCode( + testPage: Page, + session: SessionPage, + fileName: string, +): Promise { + await session.clickTab("Files"); + await expect(session.files).toBeVisible({ timeout: 5_000 }); + const fileRow = session.files.getByText(fileName); + await expect(fileRow).toBeVisible({ timeout: 10_000 }); + await fileRow.click(); + + const editorTab = testPage.locator(`.dv-default-tab:has-text('${fileName}')`); + await expect(editorTab).toBeVisible({ timeout: 10_000 }); + await expect(testPage.locator(".monaco-editor").first()).toBeVisible({ timeout: 10_000 }); +} + test.describe("Markdown preview", () => { test.describe.configure({ retries: 1, timeout: 120_000 }); @@ -228,4 +249,161 @@ test.describe("Markdown preview", () => { await expect(preview).toBeVisible({ timeout: 10_000 }); await expect(preview.locator("h1")).toContainText("Preview From Diff"); }); + + test("can add a pending comment from markdown preview selection", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + const filePath = path.join(repoDir, "comment-preview.md"); + fs.writeFileSync(filePath, MARKDOWN_CONTENT); + + const { session } = await seedTaskWithSession( + testPage, + apiClient, + seedData, + "Markdown Preview Comment Test", + ); + + await openFileInPreview(testPage, session, "comment-preview.md"); + const preview = testPage.getByTestId("markdown-preview"); + await selectMarkdownPreviewText(preview.locator("p").first()); + + const commentButton = testPage.getByTestId("markdown-preview-comment-button"); + await expect(commentButton).toBeVisible({ timeout: 5_000 }); + await commentButton.click(); + + await expect(testPage.getByRole("button", { name: "Run" })).toBeVisible(); + await testPage + .locator('textarea[placeholder="Add your comment or instruction..."]') + .fill("Please tighten this paragraph."); + await testPage.getByRole("button", { name: "Add", exact: true }).click(); + + await expect(testPage.getByTestId("markdown-preview-commented-range").first()).toBeVisible({ + timeout: 5_000, + }); + + await session.clickSessionChatTab(); + await expect(session.activeChat().getByText("comment-preview.md (1)")).toBeVisible({ + timeout: 5_000, + }); + }); + + test("shows one editable badge for a multiline markdown preview comment", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + const filePath = path.join(repoDir, "multiline-preview-comment.md"); + fs.writeFileSync( + filePath, + [ + "# Multiline Preview Comment", + "", + "First paragraph for the preview selection.", + "", + "Second paragraph for the preview selection.", + "", + "Third paragraph for the preview selection.", + "", + ].join("\n"), + ); + + const { session } = await seedTaskWithSession( + testPage, + apiClient, + seedData, + "Markdown Preview Multiline Comment Test", + ); + + await openFileInPreview(testPage, session, "multiline-preview-comment.md"); + const preview = testPage.getByTestId("markdown-preview"); + await selectMarkdownPreviewRange(preview.locator("p").nth(0), preview.locator("p").nth(2)); + + const commentButton = testPage.getByTestId("markdown-preview-comment-button"); + await expect(commentButton).toBeVisible({ timeout: 5_000 }); + await commentButton.click(); + + await testPage + .locator('textarea[placeholder="Add your comment or instruction..."]') + .fill("Please revise these paragraphs."); + await testPage.getByRole("button", { name: "Add", exact: true }).click(); + + const badges = preview.getByTestId("markdown-preview-comment-badge"); + await expect(badges).toHaveCount(1); + await badges.first().click(); + + await testPage.getByText("Please revise these paragraphs.").hover(); + await testPage.getByRole("button", { name: "Edit comment" }).click(); + const editTextarea = testPage.locator('textarea[placeholder="Add a comment..."]').last(); + await editTextarea.fill("Please tighten these paragraphs."); + await testPage.getByRole("button", { name: "Update", exact: true }).click(); + + await expect(testPage.getByText("Please tighten these paragraphs.")).toBeVisible({ + timeout: 5_000, + }); + }); + + test("shows one code comment marker on the first visual row of a wrapped line", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + const fileName = "wrapped-code-comment.md"; + const wrappedLine = Array.from( + { length: 80 }, + (_, i) => `wrapped-word-${i.toString().padStart(2, "0")}`, + ).join(" "); + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + fs.writeFileSync(path.join(repoDir, fileName), `${wrappedLine}\n`); + + const { session, sessionId } = await seedTaskWithSession( + testPage, + apiClient, + seedData, + "Markdown Code Wrapped Comment Test", + ); + await testPage.evaluate( + ({ sid, pathName, codeContent }) => { + window.sessionStorage.setItem( + `kandev.comments.${sid}`, + JSON.stringify([ + { + id: "wrapped-code-comment", + source: "diff", + sessionId: sid, + filePath: pathName, + startLine: 1, + endLine: 1, + side: "additions", + codeContent, + text: "Review this wrapped line.", + createdAt: new Date().toISOString(), + status: "pending", + }, + ]), + ); + }, + { sid: sessionId, pathName: fileName, codeContent: wrappedLine }, + ); + + await openFileInCode(testPage, session, fileName); + await expect(testPage.locator(".view-line").first()).toContainText("wrapped-word-00", { + timeout: 10_000, + }); + await expect + .poll(() => testPage.locator(".view-line").count(), { timeout: 10_000 }) + .toBeGreaterThan(1); + + await expect + .poll(() => testPage.locator(".monaco-comment-bar-icon").count(), { + timeout: 10_000, + }) + .toBe(1); + }); }); diff --git a/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts b/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts index d42687a625..2a12c30692 100644 --- a/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts +++ b/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts @@ -10,6 +10,7 @@ import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import type { BackendContext } from "../../fixtures/backend"; import { GitHelper, makeGitEnv, createStandardProfile } from "../../helpers/git-helper"; +import { selectMarkdownPreviewText } from "../../helpers/markdown-preview"; import { SessionPage } from "../../pages/session-page"; function createLongFileContent(lines = 500): string { @@ -256,7 +257,7 @@ test.describe("Mobile file viewer panel", () => { }) => { test.setTimeout(90_000); - const { filePath } = await setupMobileFileViewerTest({ + const { session, filePath } = await setupMobileFileViewerTest({ testPage, apiClient, seedData, @@ -276,6 +277,35 @@ test.describe("Mobile file viewer panel", () => { await viewer.getByTestId("markdown-preview-toggle").tap(); await expect(viewer.getByTestId("markdown-preview")).toBeVisible(); + + await selectMarkdownPreviewText(viewer.getByTestId("markdown-preview").locator("p").first()); + const commentButton = testPage.getByTestId("markdown-preview-comment-button"); + await expect(commentButton).toBeVisible({ timeout: 5_000 }); + await commentButton.tap(); + + await testPage + .locator('textarea[placeholder="Add your comment or instruction..."]') + .fill("Mobile preview comment."); + await testPage.getByRole("button", { name: "Add", exact: true }).tap(); + await expect(viewer.getByTestId("markdown-preview-commented-range").first()).toBeVisible({ + timeout: 5_000, + }); + + const badge = viewer.getByTestId("markdown-preview-comment-badge"); + await expect(badge).toHaveCount(1); + await badge.tap(); + await testPage.getByRole("button", { name: "Edit comment" }).tap(); + await testPage + .locator('textarea[placeholder="Add a comment..."]') + .fill("Updated mobile comment."); + await testPage.getByRole("button", { name: "Update", exact: true }).tap(); + await expect(testPage.getByText("Updated mobile comment.")).toBeVisible({ timeout: 5_000 }); + + await testPage.getByRole("button", { name: "Chat" }).tap(); + const fileName = filePath.split("/").pop() ?? filePath; + await expect(session.activeChat().getByText(`${fileName} (1)`)).toBeVisible({ + timeout: 5_000, + }); }); test("viewer header shows full path for files inside subdirectories", async ({ diff --git a/apps/web/hooks/domains/comments/use-diff-comments.test.ts b/apps/web/hooks/domains/comments/use-diff-comments.test.ts new file mode 100644 index 0000000000..65b2d8c37c --- /dev/null +++ b/apps/web/hooks/domains/comments/use-diff-comments.test.ts @@ -0,0 +1,61 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useCommentsStore, type DiffComment } from "@/lib/state/slices/comments"; +import { useDiffFileComments } from "./use-diff-comments"; + +const SESSION_ID = "session-1"; +const FILE_PATH = "README.md"; + +function diffComment(overrides: Partial): DiffComment { + return { + id: "comment-" + Math.random().toString(36).slice(2), + sessionId: SESSION_ID, + source: "diff", + text: "tighten this", + filePath: FILE_PATH, + startLine: 1, + endLine: 1, + side: "additions", + codeContent: "hello", + createdAt: new Date().toISOString(), + status: "pending", + ...overrides, + }; +} + +describe("useDiffFileComments", () => { + beforeEach(() => { + sessionStorage.clear(); + useCommentsStore.setState({ + byId: {}, + bySession: {}, + pendingForChat: [], + editingCommentId: null, + }); + }); + + it("filters comments by repositoryId when one is provided", () => { + act(() => { + const store = useCommentsStore.getState(); + store.addComment(diffComment({ id: "repo-a", repositoryId: "repo-a" })); + store.addComment(diffComment({ id: "repo-b", repositoryId: "repo-b" })); + store.addComment(diffComment({ id: "legacy" })); + }); + + const { result } = renderHook(() => useDiffFileComments(SESSION_ID, FILE_PATH, "repo-a")); + + expect(result.current.map((comment) => comment.id)).toEqual(["repo-a", "legacy"]); + }); + + it("preserves legacy unscoped reads when repositoryId is omitted", () => { + act(() => { + const store = useCommentsStore.getState(); + store.addComment(diffComment({ id: "repo-a", repositoryId: "repo-a" })); + store.addComment(diffComment({ id: "repo-b", repositoryId: "repo-b" })); + }); + + const { result } = renderHook(() => useDiffFileComments(SESSION_ID, FILE_PATH)); + + expect(result.current.map((comment) => comment.id)).toEqual(["repo-a", "repo-b"]); + }); +}); diff --git a/apps/web/hooks/domains/comments/use-diff-comments.ts b/apps/web/hooks/domains/comments/use-diff-comments.ts index 9b79048152..53e462793b 100644 --- a/apps/web/hooks/domains/comments/use-diff-comments.ts +++ b/apps/web/hooks/domains/comments/use-diff-comments.ts @@ -8,7 +8,11 @@ const EMPTY_COMMENTS: DiffComment[] = []; /** * Get all diff comments for a specific file in a session. */ -export function useDiffFileComments(sessionId: string, filePath: string): DiffComment[] { +export function useDiffFileComments( + sessionId: string, + filePath: string, + repositoryId?: string, +): DiffComment[] { const byId = useCommentsStore((state) => state.byId); const sessionIds = useCommentsStore((state) => state.bySession[sessionId]); const hydrateSession = useCommentsStore((state) => state.hydrateSession); @@ -23,11 +27,12 @@ export function useDiffFileComments(sessionId: string, filePath: string): DiffCo for (const id of sessionIds) { const comment = byId[id]; if (comment && isDiffComment(comment) && comment.filePath === filePath) { + if (repositoryId && comment.repositoryId && comment.repositoryId !== repositoryId) continue; result.push(comment); } } return result.length === 0 ? EMPTY_COMMENTS : result; - }, [byId, sessionIds, filePath]); + }, [byId, sessionIds, filePath, repositoryId]); } const EMPTY_BY_FILE: Record = {}; diff --git a/apps/web/hooks/domains/comments/use-markdown-preview-comments.test.ts b/apps/web/hooks/domains/comments/use-markdown-preview-comments.test.ts new file mode 100644 index 0000000000..3f683e5a7a --- /dev/null +++ b/apps/web/hooks/domains/comments/use-markdown-preview-comments.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { isIgnoredTarget } from "./use-markdown-preview-comments"; + +describe("isIgnoredTarget", () => { + it("treats SVG descendants inside interactive elements as ignored", () => { + const button = document.createElement("button"); + button.innerHTML = ""; + + expect(isIgnoredTarget(button.querySelector("path"))).toBe(true); + }); + + it("does not ignore plain non-interactive elements", () => { + expect(isIgnoredTarget(document.createElement("span"))).toBe(false); + }); +}); diff --git a/apps/web/hooks/domains/comments/use-markdown-preview-comments.ts b/apps/web/hooks/domains/comments/use-markdown-preview-comments.ts new file mode 100644 index 0000000000..6dd0210219 --- /dev/null +++ b/apps/web/hooks/domains/comments/use-markdown-preview-comments.ts @@ -0,0 +1,398 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction, +} from "react"; +import { useToast } from "@/components/toast-provider"; +import { useCommentsStore, type DiffComment } from "@/lib/state/slices/comments"; +import { + resolveMarkdownDomSelection, + type MarkdownPreviewSelection, + type SourceLineRange, +} from "@/lib/markdown/source-line-ranges"; +import { buildMarkdownPreviewComment, commentsOverlapRange } from "@/lib/markdown/preview-comments"; +import { useDiffFileComments } from "./use-diff-comments"; +import { useRunComment } from "./use-run-comment"; + +export type MarkdownCommentView = { + comments: DiffComment[]; + position: { x: number; y: number }; +} | null; + +type UseMarkdownPreviewCommentsArgs = { + path: string; + repositoryId?: string | null; + content: string; + sessionId?: string; + taskId?: string | null; + enabled: boolean; + rootRef: RefObject; +}; + +export function isIgnoredTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return Boolean( + target.closest( + [ + ".floating-comment-btn", + "[data-markdown-comment-popover]", + "[data-markdown-preview-toolbar]", + "button", + "a", + "textarea", + "input", + "select", + ].join(","), + ), + ); +} + +function clearBrowserSelection() { + window.getSelection()?.removeAllRanges(); +} + +function isCommentShortcut(event: KeyboardEvent): boolean { + return ( + (event.metaKey || event.ctrlKey) && + ((event.shiftKey && event.key.toLowerCase() === "c") || event.key.toLowerCase() === "i") + ); +} + +function useMarkdownSelectionCapture({ + content, + enabled, + rootRef, + onSelectionStart, +}: { + content: string; + enabled: boolean; + rootRef: RefObject; + onSelectionStart: () => void; +}) { + const [currentSelection, setCurrentSelection] = useState(null); + const resolveSelectionRef = useRef<() => void>(() => {}); + const timeoutRef = useRef(null); + + const resolveSelection = useCallback(() => { + if (!enabled) return; + const root = rootRef.current; + const selection = window.getSelection(); + if (!root || !selection) return; + const resolved = resolveMarkdownDomSelection(root, content, selection); + setCurrentSelection(resolved); + }, [content, enabled, rootRef]); + + useEffect(() => { + resolveSelectionRef.current = resolveSelection; + }, [resolveSelection]); + + useEffect(() => { + const root = rootRef.current; + if (!root || !enabled) return; + + const handleSelectionEnd = (event: MouseEvent | TouchEvent) => { + if (isIgnoredTarget(event.target)) return; + if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + resolveSelectionRef.current(); + }, 10); + }; + const handleSelectionStart = (event: MouseEvent | TouchEvent) => { + if (isIgnoredTarget(event.target)) return; + setCurrentSelection(null); + onSelectionStart(); + }; + + root.addEventListener("mouseup", handleSelectionEnd); + root.addEventListener("touchend", handleSelectionEnd); + root.addEventListener("mousedown", handleSelectionStart); + root.addEventListener("touchstart", handleSelectionStart); + return () => { + root.removeEventListener("mouseup", handleSelectionEnd); + root.removeEventListener("touchend", handleSelectionEnd); + root.removeEventListener("mousedown", handleSelectionStart); + root.removeEventListener("touchstart", handleSelectionStart); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [enabled, onSelectionStart, rootRef]); + + return { currentSelection, setCurrentSelection }; +} + +function useMarkdownCommentShortcut({ + currentSelection, + enabled, + rootRef, + onOpenSelection, +}: { + currentSelection: MarkdownPreviewSelection | null; + enabled: boolean; + rootRef: RefObject; + onOpenSelection: () => void; +}) { + useEffect(() => { + const root = rootRef.current; + if (!root || !enabled) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (!isCommentShortcut(event) || !currentSelection) return; + event.preventDefault(); + event.stopPropagation(); + onOpenSelection(); + }; + root.addEventListener("keydown", handleKeyDown, true); + return () => root.removeEventListener("keydown", handleKeyDown, true); + }, [currentSelection, enabled, onOpenSelection, rootRef]); +} + +function useMarkdownCommentSubmitters({ + path, + repositoryId, + sessionId, + taskId, + textSelection, + clearTextSelection, +}: { + path: string; + repositoryId?: string | null; + sessionId?: string; + taskId?: string | null; + textSelection: MarkdownPreviewSelection | null; + clearTextSelection: () => void; +}) { + const addComment = useCommentsStore((s) => s.addComment); + const { runComment } = useRunComment({ sessionId: sessionId ?? null, taskId: taskId ?? null }); + const { toast } = useToast(); + const canRunComment = Boolean(sessionId && taskId); + + const createComment = useCallback( + (text: string): DiffComment | null => { + if (!sessionId || !textSelection) return null; + const comment = buildMarkdownPreviewComment({ + filePath: path, + repositoryId: repositoryId ?? undefined, + sessionId, + selectedText: textSelection.selectedText, + text, + startLine: textSelection.startLine, + endLine: textSelection.endLine, + }); + addComment(comment); + clearTextSelection(); + return comment; + }, + [addComment, clearTextSelection, path, repositoryId, sessionId, textSelection], + ); + + const submitComment = useCallback( + (text: string) => { + const comment = createComment(text); + if (!comment) return; + toast({ + title: "Comment added", + description: "Your comment will be sent with your next message.", + }); + }, + [createComment, toast], + ); + + const submitAndRunComment = useCallback( + async (text: string) => { + if (!canRunComment) { + toast({ + title: "Failed to send comment", + description: "Open a task session before sending to the agent.", + variant: "error", + }); + return; + } + const comment = createComment(text); + if (!comment) return; + try { + const { queued } = await runComment(comment); + toast({ + title: "Comment sent", + description: queued ? "Queued for the agent." : "Sent to the agent.", + }); + } catch { + toast({ + title: "Failed to send comment", + description: "Please try again.", + variant: "error", + }); + } + }, + [canRunComment, createComment, runComment, toast], + ); + + return { submitComment, submitAndRunComment: canRunComment ? submitAndRunComment : undefined }; +} + +function useMarkdownCommentRangeView({ + comments, + onShow, +}: { + comments: DiffComment[]; + onShow: (view: MarkdownCommentView) => void; +}) { + return useCallback( + (range: SourceLineRange, position: { x: number; y: number }) => { + const rangeComments = comments.filter((comment) => commentsOverlapRange([comment], range)); + if (rangeComments.length === 0) return; + onShow({ comments: rangeComments, position }); + }, + [comments, onShow], + ); +} + +function useClearDisabledMarkdownComments({ + enabled, + clearCurrentSelection, + clearTextSelection, + closeCommentView, +}: { + enabled: boolean; + clearCurrentSelection: (selection: MarkdownPreviewSelection | null) => void; + clearTextSelection: (selection: MarkdownPreviewSelection | null) => void; + closeCommentView: () => void; +}) { + useEffect(() => { + if (enabled) return; + clearCurrentSelection(null); + clearTextSelection(null); + closeCommentView(); + }, [clearCurrentSelection, clearTextSelection, closeCommentView, enabled]); +} + +function useVisibleMarkdownCommentActions({ + setCommentView, +}: { + setCommentView: Dispatch>; +}) { + const removeComment = useCommentsStore((s) => s.removeComment); + const updateComment = useCommentsStore((s) => s.updateComment); + const { toast } = useToast(); + const removeVisibleComment = useCallback( + (commentId: string) => { + removeComment(commentId); + setCommentView((view) => { + if (!view) return view; + const nextComments = view.comments.filter((comment) => comment.id !== commentId); + return nextComments.length > 0 ? { ...view, comments: nextComments } : null; + }); + toast({ title: "Comment deleted" }); + }, + [removeComment, setCommentView, toast], + ); + const updateVisibleComment = useCallback( + (commentId: string, text: string) => { + updateComment(commentId, { text }); + setCommentView((view) => { + if (!view) return view; + return { + ...view, + comments: view.comments.map((comment) => + comment.id === commentId ? { ...comment, text } : comment, + ), + }; + }); + toast({ title: "Comment updated" }); + }, + [setCommentView, toast, updateComment], + ); + + return { removeVisibleComment, updateVisibleComment }; +} + +export function useMarkdownPreviewComments({ + path, + repositoryId, + content, + sessionId, + taskId, + enabled, + rootRef, +}: UseMarkdownPreviewCommentsArgs) { + const [textSelection, setTextSelection] = useState(null); + const [commentView, setCommentView] = useState(null); + const comments = useDiffFileComments(sessionId ?? "", path, repositoryId ?? undefined); + + const closeCommentView = useCallback(() => setCommentView(null), []); + const { currentSelection, setCurrentSelection } = useMarkdownSelectionCapture({ + content, + enabled, + rootRef, + onSelectionStart: closeCommentView, + }); + + const openComposer = useCallback(() => { + if (!currentSelection) return; + setTextSelection(currentSelection); + setCurrentSelection(null); + }, [currentSelection, setCurrentSelection]); + + useMarkdownCommentShortcut({ + currentSelection, + enabled, + rootRef, + onOpenSelection: openComposer, + }); + + useClearDisabledMarkdownComments({ + enabled, + clearCurrentSelection: setCurrentSelection, + clearTextSelection: setTextSelection, + closeCommentView, + }); + + const closeComposer = useCallback(() => { + setTextSelection(null); + clearBrowserSelection(); + }, []); + const dismissOverlays = useCallback(() => { + setCurrentSelection(null); + setTextSelection(null); + closeCommentView(); + }, [closeCommentView, setCurrentSelection]); + + const { submitComment, submitAndRunComment } = useMarkdownCommentSubmitters({ + path, + repositoryId, + sessionId, + taskId, + textSelection, + clearTextSelection: closeComposer, + }); + const showCommentsForRange = useMarkdownCommentRangeView({ + comments, + onShow: setCommentView, + }); + const { removeVisibleComment, updateVisibleComment } = useVisibleMarkdownCommentActions({ + setCommentView, + }); + + return { + comments, + currentSelection, + textSelection, + commentView, + openComposer, + closeComposer, + submitComment, + submitAndRunComment, + removeComment: removeVisibleComment, + updateComment: updateVisibleComment, + showCommentsForRange, + closeCommentView, + dismissOverlays, + }; +} diff --git a/apps/web/lib/markdown/preview-comments.test.ts b/apps/web/lib/markdown/preview-comments.test.ts new file mode 100644 index 0000000000..5877dea00e --- /dev/null +++ b/apps/web/lib/markdown/preview-comments.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import type { DiffComment } from "@/lib/state/slices/comments"; +import { + buildMarkdownPreviewComment, + commentsBeginInRange, + commentsOverlapRange, +} from "./preview-comments"; + +describe("markdown preview comments", () => { + it("builds a pending diff comment for selected markdown preview text", () => { + const comment = buildMarkdownPreviewComment({ + filePath: "README.md", + repositoryId: "repo-front", + sessionId: "session-1", + selectedText: "Rendered paragraph", + text: "Tighten wording", + startLine: 3, + endLine: 5, + }); + + expect(comment).toMatchObject({ + source: "diff", + sessionId: "session-1", + repositoryId: "repo-front", + filePath: "README.md", + startLine: 3, + endLine: 5, + side: "additions", + codeContent: "Rendered paragraph", + text: "Tighten wording", + status: "pending", + }); + expect(comment.id).toContain("README.md"); + }); + + it("detects overlap between a rendered source block and existing comments", () => { + const comment = { + source: "diff", + startLine: 10, + endLine: 12, + } as DiffComment; + + expect(commentsOverlapRange([comment], { startLine: 8, endLine: 9 })).toBe(false); + expect(commentsOverlapRange([comment], { startLine: 9, endLine: 10 })).toBe(true); + expect(commentsOverlapRange([comment], { startLine: 12, endLine: 14 })).toBe(true); + expect(commentsOverlapRange([comment], { startLine: 13, endLine: 14 })).toBe(false); + }); + + it("only marks the rendered block where a markdown preview comment begins for the badge", () => { + const comment = { + source: "diff", + startLine: 10, + endLine: 12, + } as DiffComment; + + expect(commentsBeginInRange([comment], { startLine: 8, endLine: 9 })).toBe(false); + expect(commentsBeginInRange([comment], { startLine: 10, endLine: 10 })).toBe(true); + expect(commentsBeginInRange([comment], { startLine: 11, endLine: 12 })).toBe(false); + expect(commentsBeginInRange([comment], { startLine: 8, endLine: 14 })).toBe(true); + }); +}); diff --git a/apps/web/lib/markdown/preview-comments.ts b/apps/web/lib/markdown/preview-comments.ts new file mode 100644 index 0000000000..8fc415de32 --- /dev/null +++ b/apps/web/lib/markdown/preview-comments.ts @@ -0,0 +1,37 @@ +import type { DiffComment } from "@/lib/state/slices/comments"; +import { buildDiffComment } from "@/lib/diff/comment-utils"; +import type { SourceLineRange } from "./source-line-ranges"; + +export type BuildMarkdownPreviewCommentArgs = SourceLineRange & { + filePath: string; + repositoryId?: string; + sessionId: string; + selectedText: string; + text: string; +}; + +export function buildMarkdownPreviewComment(args: BuildMarkdownPreviewCommentArgs): DiffComment { + const comment = buildDiffComment({ + filePath: args.filePath, + sessionId: args.sessionId, + startLine: args.startLine, + endLine: args.endLine, + side: "additions", + text: args.text, + codeContent: args.selectedText, + }); + if (args.repositoryId) comment.repositoryId = args.repositoryId; + return comment; +} + +export function commentsOverlapRange(comments: DiffComment[], range: SourceLineRange): boolean { + return comments.some( + (comment) => comment.startLine <= range.endLine && comment.endLine >= range.startLine, + ); +} + +export function commentsBeginInRange(comments: DiffComment[], range: SourceLineRange): boolean { + return comments.some( + (comment) => comment.startLine >= range.startLine && comment.startLine <= range.endLine, + ); +} diff --git a/apps/web/lib/markdown/source-line-ranges.test.ts b/apps/web/lib/markdown/source-line-ranges.test.ts new file mode 100644 index 0000000000..ab63bf6abb --- /dev/null +++ b/apps/web/lib/markdown/source-line-ranges.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + findLineRangeForSelectedText, + findNearestSourceElement, + readSourceLineRange, + resolveMarkdownDomSelection, + SOURCE_END_ATTR, + SOURCE_START_ATTR, +} from "./source-line-ranges"; + +describe("markdown preview source line ranges", () => { + it("reads a source line range from markdown preview attributes", () => { + const element = document.createElement("p"); + element.setAttribute(SOURCE_START_ATTR, "4"); + element.setAttribute(SOURCE_END_ATTR, "6"); + + expect(readSourceLineRange(element)).toEqual({ startLine: 4, endLine: 6 }); + }); + + it("finds the nearest source element for a nested selected text node", () => { + const root = document.createElement("div"); + const paragraph = document.createElement("p"); + const strong = document.createElement("strong"); + strong.textContent = "selected"; + paragraph.setAttribute(SOURCE_START_ATTR, "8"); + paragraph.setAttribute(SOURCE_END_ATTR, "8"); + paragraph.appendChild(strong); + root.appendChild(paragraph); + + expect(findNearestSourceElement(root, strong.firstChild)).toBe(paragraph); + }); + + it("falls back to raw markdown lookup for rendered text spanning source lines", () => { + const content = [ + "# Title", + "", + "First paragraph", + "continues here", + "", + "Second paragraph", + ].join("\r\n"); + + expect(findLineRangeForSelectedText(content, "First paragraph continues here")).toEqual({ + startLine: 3, + endLine: 4, + }); + }); + + it("limits raw markdown fallback lookup to reasonable selection spans", () => { + const content = Array.from({ length: 35 }, (_, index) => `line ${index + 1}`).join("\n"); + + expect(findLineRangeForSelectedText(content, "line 1 line 2 line 3")).toEqual({ + startLine: 1, + endLine: 3, + }); + expect( + findLineRangeForSelectedText( + content, + Array.from({ length: 31 }, (_, index) => `line ${index + 1}`).join(" "), + ), + ).toBeNull(); + }); + + it("resolves a DOM selection to the combined source line range", () => { + const root = document.createElement("div"); + const first = document.createElement("p"); + first.setAttribute(SOURCE_START_ATTR, "3"); + first.setAttribute(SOURCE_END_ATTR, "3"); + first.textContent = "Alpha paragraph"; + const second = document.createElement("p"); + second.setAttribute(SOURCE_START_ATTR, "5"); + second.setAttribute(SOURCE_END_ATTR, "5"); + second.textContent = "Beta paragraph"; + root.append(first, second); + document.body.appendChild(root); + + const range = document.createRange(); + range.setStart(first.firstChild!, 0); + range.setEnd(second.firstChild!, 4); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + + expect( + resolveMarkdownDomSelection(root, "# Title\n\nAlpha paragraph\n\nBeta paragraph", selection), + ).toMatchObject({ + selectedText: "Alpha paragraphBeta", + startLine: 3, + endLine: 5, + }); + + root.remove(); + selection.removeAllRanges(); + }); + + it("rejects selections that leave the markdown preview root", () => { + const root = document.createElement("div"); + const paragraph = document.createElement("p"); + paragraph.setAttribute(SOURCE_START_ATTR, "3"); + paragraph.setAttribute(SOURCE_END_ATTR, "3"); + paragraph.textContent = "Alpha paragraph"; + const outside = document.createElement("span"); + outside.textContent = "outside"; + root.appendChild(paragraph); + document.body.append(root, outside); + + const range = document.createRange(); + range.setStart(paragraph.firstChild!, 0); + range.setEnd(outside.firstChild!, 3); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + + expect(resolveMarkdownDomSelection(root, "Alpha paragraph", selection)).toBeNull(); + + root.remove(); + outside.remove(); + selection.removeAllRanges(); + }); +}); diff --git a/apps/web/lib/markdown/source-line-ranges.ts b/apps/web/lib/markdown/source-line-ranges.ts new file mode 100644 index 0000000000..c23f6d8d1d --- /dev/null +++ b/apps/web/lib/markdown/source-line-ranges.ts @@ -0,0 +1,161 @@ +export type SourceLineRange = { + startLine: number; + endLine: number; +}; + +export type MarkdownPreviewSelection = SourceLineRange & { + selectedText: string; + position: { x: number; y: number }; +}; + +export const SOURCE_START_ATTR = "data-md-source-start"; +export const SOURCE_END_ATTR = "data-md-source-end"; +const MAX_FALLBACK_SELECTION_SPAN = 30; + +function readPositiveInt(value: string | null): number | null { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +export function readSourceLineRange(element: Element): SourceLineRange | null { + const start = readPositiveInt(element.getAttribute(SOURCE_START_ATTR)); + const end = readPositiveInt(element.getAttribute(SOURCE_END_ATTR)) ?? start; + if (!start || !end) return null; + return { + startLine: Math.min(start, end), + endLine: Math.max(start, end), + }; +} + +export function findNearestSourceElement(root: HTMLElement, node: Node | null): HTMLElement | null { + if (!node || !root.contains(node)) return null; + let element = + node.nodeType === Node.ELEMENT_NODE + ? (node as HTMLElement) + : ((node.parentElement ?? null) as HTMLElement | null); + + while (element && root.contains(element)) { + if (readSourceLineRange(element)) return element; + if (element === root) break; + element = element.parentElement; + } + return null; +} + +function normalizeSearchText(text: string): string { + return text.replace(/\s+/g, " ").trim().toLowerCase(); +} + +function normalizeMarkdownSourceWindow(text: string): string { + return normalizeSearchText( + text + .replace(/^#{1,6}\s+/gm, "") + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+[.)]\s+/gm, "") + .replace(/^>\s?/gm, "") + .replace(/[`*_~[\]()]/g, ""), + ); +} + +export function findLineRangeForSelectedText( + content: string, + selectedText: string, +): SourceLineRange | null { + const needle = normalizeSearchText(selectedText); + if (!needle) return null; + + const lines = content.replace(/\r\n?/g, "\n").split("\n"); + for (let span = 1; span <= Math.min(lines.length, MAX_FALLBACK_SELECTION_SPAN); span++) { + for (let start = 0; start + span <= lines.length; start++) { + const end = start + span - 1; + const haystack = normalizeMarkdownSourceWindow(lines.slice(start, end + 1).join(" ")); + if (!haystack) continue; + if (haystack === needle || haystack.includes(needle)) { + return { startLine: start + 1, endLine: end + 1 }; + } + } + } + return null; +} + +function selectionTouchesRoot(root: HTMLElement, range: Range): boolean { + return root.contains(range.startContainer) && root.contains(range.endContainer); +} + +function selectionPosition( + range: Range, + fallbackElement: HTMLElement | null, +): { x: number; y: number } { + const rect = range.getBoundingClientRect?.(); + if (rect && (rect.left || rect.top || rect.right || rect.bottom)) { + return { x: rect.right, y: rect.bottom }; + } + const fallbackRect = fallbackElement?.getBoundingClientRect(); + if (fallbackRect) return { x: fallbackRect.right, y: fallbackRect.bottom }; + return { x: 0, y: 0 }; +} + +function mergeSourceRanges( + startRange: SourceLineRange | null, + endRange: SourceLineRange | null, +): SourceLineRange | null { + if (!startRange && !endRange) return null; + const startLine = Math.min( + startRange?.startLine ?? endRange!.startLine, + endRange?.startLine ?? startRange!.startLine, + ); + const endLine = Math.max( + startRange?.endLine ?? endRange!.endLine, + endRange?.endLine ?? startRange!.endLine, + ); + return { startLine, endLine }; +} + +function sourceRangeForSelection( + root: HTMLElement, + content: string, + selectedText: string, + range: Range, +): { + lineRange: SourceLineRange | null; + fallbackElement: HTMLElement | null; +} { + const startElement = findNearestSourceElement(root, range.startContainer); + const endElement = findNearestSourceElement(root, range.endContainer); + const startRange = startElement ? readSourceLineRange(startElement) : null; + const endRange = endElement ? readSourceLineRange(endElement) : null; + return { + lineRange: + mergeSourceRanges(startRange, endRange) ?? + findLineRangeForSelectedText(content, selectedText), + fallbackElement: endElement ?? startElement, + }; +} + +export function resolveMarkdownDomSelection( + root: HTMLElement, + content: string, + selection: Selection, +): MarkdownPreviewSelection | null { + if (selection.rangeCount === 0) return null; + const selectedText = selection.toString().trim(); + if (!selectedText) return null; + + const range = selection.getRangeAt(0); + if (!selectionTouchesRoot(root, range)) return null; + + const { lineRange, fallbackElement } = sourceRangeForSelection( + root, + content, + selectedText, + range, + ); + if (!lineRange) return null; + + return { + ...lineRange, + selectedText, + position: selectionPosition(range, fallbackElement), + }; +}