feat: Add threaded review comments + agent comment responses
Introduces GitHub-style threaded comments via parentCommentId self-reference. Users and agents can reply within comment threads, and review agents receive comment IDs so they can post targeted responses via comment-responses.json. - Migration 0032: parentCommentId column + index on review_comments - Repository: createReply() copies parent context, default author 'you' → 'user' - tRPC: replyToReviewComment procedure, requestPhaseChanges passes threaded comments - Orchestrator: formats [comment:ID] tags with full reply threads in task description - Agent IO: readCommentResponses() reads .cw/output/comment-responses.json - OutputHandler: processes agent comment responses (creates replies, resolves threads) - Execute prompt: conditional <review_comments> block when task has [comment:] markers - Frontend: CommentThread renders root+replies with agent-specific styling + reply form - Sidebar/ReviewTab: root-only comment counts, reply mutation plumbing through DiffViewer chain
This commit is contained in:
@@ -1,71 +1,150 @@
|
||||
import { Check, RotateCcw } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Check, RotateCcw, Reply } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CommentForm } from "./CommentForm";
|
||||
import type { ReviewComment } from "./types";
|
||||
|
||||
interface CommentThreadProps {
|
||||
comments: ReviewComment[];
|
||||
onResolve: (commentId: string) => void;
|
||||
onUnresolve: (commentId: string) => void;
|
||||
onReply?: (parentCommentId: string, body: string) => void;
|
||||
}
|
||||
|
||||
export function CommentThread({ comments, onResolve, onUnresolve }: CommentThreadProps) {
|
||||
export function CommentThread({ comments, onResolve, onUnresolve, onReply }: CommentThreadProps) {
|
||||
// Group: root comments (no parentCommentId) and their replies
|
||||
const rootComments = comments.filter((c) => !c.parentCommentId);
|
||||
const repliesByParent = new Map<string, ReviewComment[]>();
|
||||
for (const c of comments) {
|
||||
if (c.parentCommentId) {
|
||||
const arr = repliesByParent.get(c.parentCommentId) ?? [];
|
||||
arr.push(c);
|
||||
repliesByParent.set(c.parentCommentId, arr);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
{rootComments.map((comment) => (
|
||||
<RootComment
|
||||
key={comment.id}
|
||||
className={`rounded border p-2.5 text-xs space-y-1.5 ${
|
||||
comment.resolved
|
||||
? "border-status-success-border bg-status-success-bg/50"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-foreground">{comment.author}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTime(comment.createdAt)}
|
||||
</span>
|
||||
{comment.resolved && (
|
||||
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
|
||||
<Check className="h-3 w-3" />
|
||||
Resolved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{comment.resolved ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => onUnresolve(comment.id)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-0.5" />
|
||||
Reopen
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => onResolve(comment.id)}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-0.5" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{comment.body}
|
||||
</p>
|
||||
</div>
|
||||
comment={comment}
|
||||
replies={repliesByParent.get(comment.id) ?? []}
|
||||
onResolve={onResolve}
|
||||
onUnresolve={onUnresolve}
|
||||
onReply={onReply}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootComment({
|
||||
comment,
|
||||
replies,
|
||||
onResolve,
|
||||
onUnresolve,
|
||||
onReply,
|
||||
}: {
|
||||
comment: ReviewComment;
|
||||
replies: ReviewComment[];
|
||||
onResolve: (id: string) => void;
|
||||
onUnresolve: (id: string) => void;
|
||||
onReply?: (parentCommentId: string, body: string) => void;
|
||||
}) {
|
||||
const [isReplying, setIsReplying] = useState(false);
|
||||
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReplying) replyRef.current?.focus();
|
||||
}, [isReplying]);
|
||||
|
||||
return (
|
||||
<div className={`rounded border ${comment.resolved ? "border-status-success-border bg-status-success-bg/50" : "border-border bg-card"}`}>
|
||||
{/* Root comment */}
|
||||
<div className="p-2.5 text-xs space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-foreground">{comment.author}</span>
|
||||
<span className="text-muted-foreground">{formatTime(comment.createdAt)}</span>
|
||||
{comment.resolved && (
|
||||
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
|
||||
<Check className="h-3 w-3" />
|
||||
Resolved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{onReply && !comment.resolved && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setIsReplying(!isReplying)}
|
||||
>
|
||||
<Reply className="h-3 w-3 mr-0.5" />
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
{comment.resolved ? (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onUnresolve(comment.id)}>
|
||||
<RotateCcw className="h-3 w-3 mr-0.5" />
|
||||
Reopen
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onResolve(comment.id)}>
|
||||
<Check className="h-3 w-3 mr-0.5" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{comment.body}</p>
|
||||
</div>
|
||||
|
||||
{/* Replies */}
|
||||
{replies.length > 0 && (
|
||||
<div className="border-t border-border/50">
|
||||
{replies.map((reply) => (
|
||||
<div
|
||||
key={reply.id}
|
||||
className={`px-2.5 py-2 text-xs border-l-2 ml-3 space-y-1 ${
|
||||
reply.author === "agent"
|
||||
? "border-l-primary bg-primary/5"
|
||||
: "border-l-muted-foreground/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`font-semibold ${reply.author === "agent" ? "text-primary" : "text-foreground"}`}>
|
||||
{reply.author}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{formatTime(reply.createdAt)}</span>
|
||||
</div>
|
||||
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply form */}
|
||||
{isReplying && onReply && (
|
||||
<div className="border-t border-border/50 p-2.5">
|
||||
<CommentForm
|
||||
ref={replyRef}
|
||||
onSubmit={(body) => {
|
||||
onReply(comment.id, body);
|
||||
setIsReplying(false);
|
||||
}}
|
||||
onCancel={() => setIsReplying(false)}
|
||||
placeholder="Write a reply..."
|
||||
submitLabel="Reply"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
@@ -12,6 +12,7 @@ interface DiffViewerProps {
|
||||
) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||
viewedFiles?: Set<string>;
|
||||
onToggleViewed?: (filePath: string) => void;
|
||||
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
|
||||
@@ -23,6 +24,7 @@ export function DiffViewer({
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
onReplyComment,
|
||||
viewedFiles,
|
||||
onToggleViewed,
|
||||
onRegisterRef,
|
||||
@@ -37,6 +39,7 @@ export function DiffViewer({
|
||||
onAddComment={onAddComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
onReplyComment={onReplyComment}
|
||||
isViewed={viewedFiles?.has(file.newPath) ?? false}
|
||||
onToggleViewed={() => onToggleViewed?.(file.newPath)}
|
||||
/>
|
||||
|
||||
@@ -52,6 +52,7 @@ interface FileCardProps {
|
||||
) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||
isViewed?: boolean;
|
||||
onToggleViewed?: () => void;
|
||||
}
|
||||
@@ -62,6 +63,7 @@ export function FileCard({
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
onReplyComment,
|
||||
isViewed = false,
|
||||
onToggleViewed = () => {},
|
||||
}: FileCardProps) {
|
||||
@@ -157,6 +159,7 @@ export function FileCard({
|
||||
onAddComment={onAddComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
onReplyComment={onReplyComment}
|
||||
tokenMap={tokenMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface HunkRowsProps {
|
||||
) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||
tokenMap?: LineTokenMap | null;
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ export function HunkRows({
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
onReplyComment,
|
||||
tokenMap,
|
||||
}: HunkRowsProps) {
|
||||
const [commentingLine, setCommentingLine] = useState<{
|
||||
@@ -98,6 +100,7 @@ export function HunkRows({
|
||||
onSubmitComment={handleSubmitComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
onReplyComment={onReplyComment}
|
||||
tokens={
|
||||
line.newLineNumber !== null
|
||||
? tokenMap?.get(line.newLineNumber) ?? undefined
|
||||
|
||||
@@ -15,6 +15,7 @@ interface LineWithCommentsProps {
|
||||
onSubmitComment: (body: string) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||
/** Syntax-highlighted tokens for this line (if available) */
|
||||
tokens?: TokenizedLine;
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export function LineWithComments({
|
||||
onSubmitComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
onReplyComment,
|
||||
tokens,
|
||||
}: LineWithCommentsProps) {
|
||||
const formRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -141,6 +143,7 @@ export function LineWithComments({
|
||||
comments={lineComments}
|
||||
onResolve={onResolveComment}
|
||||
onUnresolve={onUnresolveComment}
|
||||
onReply={onReplyComment}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -183,8 +183,8 @@ function FilesView({
|
||||
activeFiles: FileDiff[];
|
||||
viewedFiles: Set<string>;
|
||||
}) {
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
||||
const resolvedCount = comments.filter((c) => c.resolved).length;
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
||||
const resolvedCount = comments.filter((c) => c.resolved && !c.parentCommentId).length;
|
||||
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
|
||||
|
||||
const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]);
|
||||
@@ -263,7 +263,7 @@ function FilesView({
|
||||
<div className="space-y-0.5">
|
||||
{group.files.map((file) => {
|
||||
const fileCommentCount = comments.filter(
|
||||
(c) => c.filePath === file.newPath,
|
||||
(c) => c.filePath === file.newPath && !c.parentCommentId,
|
||||
).length;
|
||||
const isInView = activeFilePaths.has(file.newPath);
|
||||
const dimmed = selectedCommit && !isInView;
|
||||
|
||||
@@ -153,6 +153,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
author: c.author,
|
||||
createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt),
|
||||
resolved: c.resolved,
|
||||
parentCommentId: c.parentCommentId ?? null,
|
||||
}));
|
||||
}, [commentsQuery.data]);
|
||||
|
||||
@@ -175,6 +176,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const replyToCommentMutation = trpc.replyToReviewComment.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to post reply: ${err.message}`),
|
||||
});
|
||||
|
||||
const approveMutation = trpc.approvePhaseReview.useMutation({
|
||||
onSuccess: () => {
|
||||
setStatus("approved");
|
||||
@@ -221,6 +229,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
unresolveCommentMutation.mutate({ id: commentId });
|
||||
}, [unresolveCommentMutation]);
|
||||
|
||||
const handleReplyComment = useCallback((parentCommentId: string, body: string) => {
|
||||
replyToCommentMutation.mutate({ parentCommentId, body });
|
||||
}, [replyToCommentMutation]);
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (!activePhaseId) return;
|
||||
approveMutation.mutate({ phaseId: activePhaseId });
|
||||
@@ -256,7 +268,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
setViewedFiles(new Set());
|
||||
}, []);
|
||||
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
||||
|
||||
const activePhaseName =
|
||||
diffQuery.data?.phaseName ??
|
||||
@@ -350,6 +362,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
onAddComment={handleAddComment}
|
||||
onResolveComment={handleResolveComment}
|
||||
onUnresolveComment={handleUnresolveComment}
|
||||
onReplyComment={handleReplyComment}
|
||||
viewedFiles={viewedFiles}
|
||||
onToggleViewed={toggleViewed}
|
||||
onRegisterRef={registerFileRef}
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ReviewComment {
|
||||
author: string;
|
||||
createdAt: string;
|
||||
resolved: boolean;
|
||||
parentCommentId?: string | null;
|
||||
}
|
||||
|
||||
export type ReviewStatus = "pending" | "approved" | "changes_requested";
|
||||
|
||||
Reference in New Issue
Block a user