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:
Lukas May
2026-03-06 10:21:22 +01:00
parent 2da6632298
commit 7695604da2
22 changed files with 336 additions and 78 deletions

View File

@@ -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" });

View File

@@ -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)}
/>

View File

@@ -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}
/>
))}

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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}

View File

@@ -34,6 +34,7 @@ export interface ReviewComment {
author: string;
createdAt: string;
resolved: boolean;
parentCommentId?: string | null;
}
export type ReviewStatus = "pending" | "approved" | "changes_requested";