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:
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user