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.
This commit is contained in:
Lukas May
2026-03-06 11:58:08 +01:00
parent 49970eb1d7
commit 1e723611e7
10 changed files with 128 additions and 11 deletions

View File

@@ -80,6 +80,19 @@ export class DrizzleReviewCommentRepository implements ReviewCommentRepository {
.orderBy(asc(reviewComments.createdAt));
}
async update(id: string, body: string): Promise<ReviewComment | null> {
await this.db
.update(reviewComments)
.set({ body, updatedAt: new Date() })
.where(eq(reviewComments.id, id));
const rows = await this.db
.select()
.from(reviewComments)
.where(eq(reviewComments.id, id))
.limit(1);
return rows[0] ?? null;
}
async resolve(id: string): Promise<ReviewComment | null> {
await this.db
.update(reviewComments)

View File

@@ -20,6 +20,7 @@ export interface ReviewCommentRepository {
create(data: CreateReviewCommentData): Promise<ReviewComment>;
createReply(parentCommentId: string, body: string, author?: string): Promise<ReviewComment>;
findByPhaseId(phaseId: string): Promise<ReviewComment[]>;
update(id: string, body: string): Promise<ReviewComment | null>;
resolve(id: string): Promise<ReviewComment | null>;
unresolve(id: string): Promise<ReviewComment | null>;
delete(id: string): Promise<void>;

View File

@@ -346,6 +346,20 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return repo.create(input);
}),
updateReviewComment: publicProcedure
.input(z.object({
id: z.string().min(1),
body: z.string().trim().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireReviewCommentRepository(ctx);
const comment = await repo.update(input.id, input.body);
if (!comment) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Review comment '${input.id}' not found` });
}
return comment;
}),
resolveReviewComment: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {

View File

@@ -7,14 +7,15 @@ interface CommentFormProps {
onCancel: () => void;
placeholder?: string;
submitLabel?: string;
initialValue?: string;
}
export const CommentForm = forwardRef<HTMLTextAreaElement, CommentFormProps>(
function CommentForm(
{ onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment" },
{ onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment", initialValue = "" },
ref
) {
const [body, setBody] = useState("");
const [body, setBody] = useState(initialValue);
const handleSubmit = useCallback(() => {
const trimmed = body.trim();

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { Check, RotateCcw, Reply } from "lucide-react";
import { Check, RotateCcw, Reply, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CommentForm } from "./CommentForm";
import type { ReviewComment } from "./types";
@@ -9,9 +9,10 @@ interface CommentThreadProps {
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 }: CommentThreadProps) {
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[]>();
@@ -33,6 +34,7 @@ export function CommentThread({ comments, onResolve, onUnresolve, onReply }: Com
onResolve={onResolve}
onUnresolve={onUnresolve}
onReply={onReply}
onEdit={onEdit}
/>
))}
</div>
@@ -45,20 +47,30 @@ function RootComment({
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 */}
@@ -75,6 +87,17 @@ function RootComment({
)}
</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"
@@ -99,7 +122,21 @@ function RootComment({
)}
</div>
</div>
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{comment.body}</p>
{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 */}
@@ -114,13 +151,40 @@ function RootComment({
: "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 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>
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
{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>

View File

@@ -13,6 +13,7 @@ interface DiffViewerProps {
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
onEditComment?: (commentId: string, body: string) => void;
viewedFiles?: Set<string>;
onToggleViewed?: (filePath: string) => void;
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
@@ -25,6 +26,7 @@ export function DiffViewer({
onResolveComment,
onUnresolveComment,
onReplyComment,
onEditComment,
viewedFiles,
onToggleViewed,
onRegisterRef,
@@ -40,6 +42,7 @@ export function DiffViewer({
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
isViewed={viewedFiles?.has(file.newPath) ?? false}
onToggleViewed={() => onToggleViewed?.(file.newPath)}
/>

View File

@@ -53,6 +53,7 @@ interface FileCardProps {
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
onEditComment?: (commentId: string, body: string) => void;
isViewed?: boolean;
onToggleViewed?: () => void;
}
@@ -64,6 +65,7 @@ export function FileCard({
onResolveComment,
onUnresolveComment,
onReplyComment,
onEditComment,
isViewed = false,
onToggleViewed = () => {},
}: FileCardProps) {
@@ -161,6 +163,7 @@ export function FileCard({
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
tokenMap={tokenMap}
/>
))}

View File

@@ -16,6 +16,7 @@ interface HunkRowsProps {
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
onEditComment?: (commentId: string, body: string) => void;
tokenMap?: LineTokenMap | null;
}
@@ -27,6 +28,7 @@ export function HunkRows({
onResolveComment,
onUnresolveComment,
onReplyComment,
onEditComment,
tokenMap,
}: HunkRowsProps) {
const [commentingLine, setCommentingLine] = useState<{
@@ -101,6 +103,7 @@ export function HunkRows({
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
tokens={
line.newLineNumber !== null
? tokenMap?.get(line.newLineNumber) ?? undefined

View File

@@ -16,6 +16,7 @@ interface LineWithCommentsProps {
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
onEditComment?: (commentId: string, body: string) => void;
/** Syntax-highlighted tokens for this line (if available) */
tokens?: TokenizedLine;
}
@@ -31,6 +32,7 @@ export function LineWithComments({
onResolveComment,
onUnresolveComment,
onReplyComment,
onEditComment,
tokens,
}: LineWithCommentsProps) {
const formRef = useRef<HTMLTextAreaElement>(null);
@@ -144,6 +146,7 @@ export function LineWithComments({
onResolve={onResolveComment}
onUnresolve={onUnresolveComment}
onReply={onReplyComment}
onEdit={onEditComment}
/>
</td>
</tr>

View File

@@ -195,6 +195,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onError: (err) => toast.error(`Failed to post reply: ${err.message}`),
});
const editCommentMutation = trpc.updateReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
onError: (err) => toast.error(`Failed to update comment: ${err.message}`),
});
const approveMutation = trpc.approvePhaseReview.useMutation({
onSuccess: () => {
setStatus("approved");
@@ -245,6 +252,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
replyToCommentMutation.mutate({ parentCommentId, body });
}, [replyToCommentMutation]);
const handleEditComment = useCallback((commentId: string, body: string) => {
editCommentMutation.mutate({ id: commentId, body });
}, [editCommentMutation]);
const handleApprove = useCallback(() => {
if (!activePhaseId) return;
approveMutation.mutate({ phaseId: activePhaseId });
@@ -394,6 +405,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
onReplyComment={handleReplyComment}
onEditComment={handleEditComment}
viewedFiles={viewedFiles}
onToggleViewed={toggleViewed}
onRegisterRef={registerFileRef}