Files
Codewalkers/apps/web/src/components/review/CommentThread.tsx
Lukas May 1e723611e7 feat: Allow editing review comments
Add update method to ReviewCommentRepository, updateReviewComment tRPC
procedure, and inline edit UI in CommentThread. Edit button appears on
user-authored comments (not agent comments) when unresolved. Uses the
existing CommentForm with a new initialValue prop.
2026-03-06 11:58:08 +01:00

216 lines
7.3 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import { Check, RotateCcw, Reply, Pencil } 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;
onEdit?: (commentId: string, body: string) => void;
}
export function CommentThread({ comments, onResolve, onUnresolve, onReply, onEdit }: 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">
{rootComments.map((comment) => (
<RootComment
key={comment.id}
comment={comment}
replies={repliesByParent.get(comment.id) ?? []}
onResolve={onResolve}
onUnresolve={onUnresolve}
onReply={onReply}
onEdit={onEdit}
/>
))}
</div>
);
}
function RootComment({
comment,
replies,
onResolve,
onUnresolve,
onReply,
onEdit,
}: {
comment: ReviewComment;
replies: ReviewComment[];
onResolve: (id: string) => void;
onUnresolve: (id: string) => void;
onReply?: (parentCommentId: string, body: string) => void;
onEdit?: (commentId: string, body: string) => void;
}) {
const [isReplying, setIsReplying] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const replyRef = useRef<HTMLTextAreaElement>(null);
const editRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (isReplying) replyRef.current?.focus();
}, [isReplying]);
useEffect(() => {
if (editingId) editRef.current?.focus();
}, [editingId]);
const isEditingRoot = editingId === comment.id;
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">
{onEdit && comment.author !== "agent" && !comment.resolved && (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => setEditingId(isEditingRoot ? null : comment.id)}
>
<Pencil className="h-3 w-3 mr-0.5" />
Edit
</Button>
)}
{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>
{isEditingRoot ? (
<CommentForm
ref={editRef}
initialValue={comment.body}
onSubmit={(body) => {
onEdit!(comment.id, body);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
placeholder="Edit comment..."
submitLabel="Save"
/>
) : (
<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 justify-between gap-1.5">
<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>
{onEdit && reply.author !== "agent" && !comment.resolved && editingId !== reply.id && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-[10px]"
onClick={() => setEditingId(reply.id)}
>
<Pencil className="h-2.5 w-2.5 mr-0.5" />
Edit
</Button>
)}
</div>
{editingId === reply.id ? (
<CommentForm
ref={editRef}
initialValue={reply.body}
onSubmit={(body) => {
onEdit!(reply.id, body);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
placeholder="Edit reply..."
submitLabel="Save"
/>
) : (
<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" });
}