fix: support markdown preview comments (#1648)

* fix: support markdown preview comments

* fix: address markdown preview review feedback

* fix: address markdown preview comment scope

* fix: address markdown preview follow-up feedback

* fix: preserve markdown preview run action in file tabs

* fix: ignore svg clicks in markdown preview controls

* fix: ignore svg markdown preview selection targets
This commit is contained in:
José Almeida
2026-07-10 14:03:32 +01:00
committed by GitHub
parent 3cb965d0fd
commit 12ed7123ab
26 changed files with 1733 additions and 192 deletions
+47
View File
@@ -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;
@@ -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}
/>
)}
@@ -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,
};
}
@@ -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",
},
@@ -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<DragState | null>(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<HTMLDivElement | null>) {
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 (
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="px-2 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">
{formatLineRange(comment.startLine, comment.endLine)}
</span>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
onClick={() => onDelete(comment.id)}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
<div className="flex items-center gap-1">
{onUpdate && !isEditing && (
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:text-foreground"
aria-label="Edit comment"
title="Edit comment"
onClick={() => onStartEdit(comment.id)}
>
<IconEdit className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
aria-label="Delete comment"
title="Delete comment"
onClick={() => onDelete(comment.id)}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{comment.codeContent && (
<pre className="mb-2 p-2 rounded-md bg-muted/50 text-[10px] text-muted-foreground font-mono max-h-[80px] overflow-auto whitespace-pre-wrap">
{comment.codeContent}
</pre>
{isEditing ? (
<CommentForm
initialContent={comment.text}
onSubmit={handleUpdate}
onCancel={onCancelEdit}
isEditing
/>
) : (
<>
{comment.codeContent && (
<pre className="mb-2 p-2 rounded-md bg-muted/50 text-[10px] text-muted-foreground font-mono max-h-[80px] overflow-auto whitespace-pre-wrap">
{comment.codeContent}
</pre>
)}
<p className="text-xs text-foreground leading-relaxed whitespace-pre-wrap">
{comment.text}
</p>
</>
)}
<p className="text-xs text-foreground leading-relaxed">{comment.text}</p>
</div>
);
}
@@ -126,11 +101,13 @@ export function CommentViewPopover({
comments,
position,
onDelete,
onUpdate,
onClose,
}: CommentViewPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const { pos, onDragStart } = useDraggablePos(position, 350, 200);
usePopoverClose(onClose, popoverRef);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const { pos, onDragStart } = useDraggablePopover(position, 350, 200);
usePopoverDismiss(onClose, popoverRef);
return (
<div
@@ -152,7 +129,15 @@ export function CommentViewPopover({
</div>
<div className="divide-y divide-border/30">
{comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} onDelete={onDelete} />
<CommentItem
key={comment.id}
comment={comment}
onDelete={onDelete}
onUpdate={onUpdate}
isEditing={editingCommentId === comment.id}
onStartEdit={setEditingCommentId}
onCancelEdit={() => setEditingCommentId(null)}
/>
))}
</div>
</div>
@@ -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<DragState | null>(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<HTMLDivElement | null>,
) {
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<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(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({
<div
ref={popoverRef}
className={cn(
"fixed z-50 w-96 rounded-xl border border-border/50 bg-popover/95 backdrop-blur-sm shadow-xl",
"fixed z-50 w-[min(24rem,calc(100vw-2rem))] rounded-xl border border-border/50 bg-popover/95 backdrop-blur-sm shadow-xl",
"animate-in fade-in-0 zoom-in-95 duration-150",
)}
style={{ left: pos.left, top: pos.top }}
@@ -15,6 +15,8 @@ export type FileEditorContentProps = {
vcsDiff?: string;
isSaving: boolean;
sessionId?: string;
taskId?: string | null;
repositoryId?: string | null;
worktreePath?: string;
repo?: string;
enableComments?: boolean;
@@ -35,6 +37,10 @@ export const FileEditorContent = memo(function FileEditorContent(props: FileEdit
path={props.path}
content={props.content}
worktreePath={props.worktreePath}
sessionId={props.sessionId}
taskId={props.taskId}
repositoryId={props.repositoryId}
enableComments={props.enableComments}
onTogglePreview={props.onToggleMarkdownPreview}
/>
);
@@ -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}
@@ -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)}
@@ -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 = "<svg><path /></svg>";
expect(isInteractiveSourceClickTarget(button.querySelector("path"))).toBe(true);
});
it("ignores plain non-interactive elements", () => {
expect(isInteractiveSourceClickTarget(document.createElement("span"))).toBe(false);
});
});
@@ -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={
<div className="flex items-center gap-1">
{commentsEnabled && commentCount > 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>
{commentCount} comment{commentCount > 1 ? "s" : ""}
</span>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -54,29 +88,284 @@ interface MarkdownPreviewContentProps {
path: string;
content: string;
worktreePath?: string;
sessionId?: string;
taskId?: string | null;
repositoryId?: string | null;
enableComments?: boolean;
onTogglePreview: () => void;
}
type PositionedNode = {
position?: {
start?: { line?: number };
end?: { line?: number };
};
};
type SourceBlockProps = HTMLAttributes<HTMLElement> &
ExtraProps & {
children?: ReactNode;
node?: PositionedNode;
tag: keyof HTMLElementTagNameMap;
};
type MarkdownSourceBlockProps = Omit<SourceBlockProps, "tag">;
type PreviewCommentContextValue = {
comments: DiffComment[];
showCommentsForRange: (range: SourceLineRange, position: { x: number; y: number }) => void;
};
const PreviewCommentContext = createContext<PreviewCommentContextValue | null>(null);
function sourceRangeFromNode(node: PositionedNode | undefined): SourceLineRange | null {
const startLine = node?.position?.start?.line;
const endLine = node?.position?.end?.line ?? startLine;
if (!startLine || !endLine) return null;
return { startLine, endLine };
}
function sourceDataAttrs(range: SourceLineRange | null) {
if (!range) return {};
return {
[SOURCE_START_ATTR]: range.startLine,
[SOURCE_END_ATTR]: range.endLine,
};
}
export function isInteractiveSourceClickTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(
target.closest("a, button, input, textarea, select, [role='button'], [contenteditable]"),
);
}
function CommentBadge({
onClick,
}: {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
}) {
return (
<button
type="button"
className="markdown-preview-comment-badge"
data-testid="markdown-preview-comment-badge"
aria-label="Edit markdown comment"
onClick={onClick}
>
<IconMessagePlus className="h-3 w-3" />
</button>
);
}
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<HTMLElement>) => {
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<HTMLButtonElement>) => {
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" ? <CommentBadge onClick={handleBadgeClick} /> : null,
);
if (!hasCommentBadge || tag !== "pre") return element;
return (
<div className="markdown-preview-pre-comment-wrapper">
{element}
<CommentBadge onClick={handleBadgeClick} />
</div>
);
}
function MarkdownPreviewTable({ node, children }: MarkdownSourceBlockProps) {
return (
<SourceBlock tag="div" node={node} className="overflow-x-auto">
<table>{children}</table>
</SourceBlock>
);
}
function sourceComponent(tag: keyof HTMLElementTagNameMap) {
return function MarkdownPreviewSourceComponent(props: MarkdownSourceBlockProps) {
return <SourceBlock {...props} tag={tag} />;
};
}
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) => <MarkdownPreviewTable {...(props as MarkdownSourceBlockProps)} />,
};
type MarkdownPreviewCommentState = ReturnType<typeof useMarkdownPreviewComments>;
function MarkdownPreviewCommentOverlays({
commentsEnabled,
overlayRoot,
commentState,
}: {
commentsEnabled: boolean;
overlayRoot: HTMLElement | null;
commentState: MarkdownPreviewCommentState;
}) {
if (!commentsEnabled || !overlayRoot) return null;
return createPortal(
<>
{commentState.currentSelection && !commentState.textSelection && (
<Button
size="sm"
variant="secondary"
className="floating-comment-btn fixed z-50 min-h-11 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer sm:min-h-8"
style={{
left: commentState.currentSelection.position.x + 8,
top: commentState.currentSelection.position.y + 8,
}}
data-testid="markdown-preview-comment-button"
onMouseDown={(event) => event.stopPropagation()}
onClick={commentState.openComposer}
>
<IconMessagePlus className="h-3.5 w-3.5" />
Comment
</Button>
)}
{commentState.textSelection && (
<div data-markdown-comment-popover>
<EditorCommentPopover
selectedText={commentState.textSelection.selectedText}
lineRange={{
start: commentState.textSelection.startLine,
end: commentState.textSelection.endLine,
}}
position={commentState.textSelection.position}
onSubmit={commentState.submitComment}
onSubmitAndRun={commentState.submitAndRunComment}
onClose={commentState.closeComposer}
/>
</div>
)}
{commentState.commentView && (
<CommentViewPopover
comments={commentState.commentView.comments}
position={commentState.commentView.position}
onDelete={commentState.removeComment}
onUpdate={commentState.updateComment}
onClose={commentState.closeCommentView}
/>
)}
</>,
overlayRoot,
);
}
export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
path,
content,
worktreePath,
sessionId,
taskId,
repositoryId,
enableComments = false,
onTogglePreview,
}: MarkdownPreviewContentProps) {
const rootRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [overlayRoot, setOverlayRoot] = useState<HTMLElement | null>(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 (
<div className="flex h-full flex-col" data-testid="markdown-preview">
<div className="relative flex h-full flex-col" data-testid="markdown-preview">
<MarkdownPreviewToolbar
path={path}
worktreePath={worktreePath}
commentCount={commentState.comments.length}
commentsEnabled={commentsEnabled}
onTogglePreview={onTogglePreview}
/>
<div className="flex-1 overflow-auto p-6">
<div className="markdown-body max-w-3xl">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{content}
</ReactMarkdown>
<div ref={scrollRef} className="flex-1 overflow-auto p-6">
<div ref={rootRef} className="markdown-body max-w-3xl" tabIndex={commentsEnabled ? 0 : -1}>
<PreviewCommentContext.Provider value={previewCommentContextValue}>
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownPreviewComponents}>
{content}
</ReactMarkdown>
</PreviewCommentContext.Provider>
</div>
</div>
<MarkdownPreviewCommentOverlays
commentsEnabled={commentsEnabled}
overlayRoot={overlayRoot}
commentState={commentState}
/>
</div>
);
});
@@ -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)}
/>
) : (
@@ -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}
@@ -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 = "<label><textarea></textarea></label>";
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);
});
});
@@ -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<DragState | null>(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<HTMLDivElement | null>,
) {
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]);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Locator } from "@playwright/test";
export async function selectMarkdownPreviewText(target: Locator): Promise<void> {
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<void> {
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(),
);
}
@@ -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<void> {
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);
});
});
@@ -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 ({
@@ -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>): 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"]);
});
});
@@ -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<string, DiffComment[]> = {};
@@ -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 = "<svg><path /></svg>";
expect(isIgnoredTarget(button.querySelector("path"))).toBe(true);
});
it("does not ignore plain non-interactive elements", () => {
expect(isIgnoredTarget(document.createElement("span"))).toBe(false);
});
});
@@ -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<HTMLDivElement | null>;
};
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<HTMLDivElement | null>;
onSelectionStart: () => void;
}) {
const [currentSelection, setCurrentSelection] = useState<MarkdownPreviewSelection | null>(null);
const resolveSelectionRef = useRef<() => void>(() => {});
const timeoutRef = useRef<number | null>(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<HTMLDivElement | null>;
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<SetStateAction<MarkdownCommentView>>;
}) {
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<MarkdownPreviewSelection | null>(null);
const [commentView, setCommentView] = useState<MarkdownCommentView>(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,
};
}
@@ -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);
});
});
+37
View File
@@ -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,
);
}
@@ -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();
});
});
+161
View File
@@ -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),
};
}