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)); .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> { async resolve(id: string): Promise<ReviewComment | null> {
await this.db await this.db
.update(reviewComments) .update(reviewComments)

View File

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

View File

@@ -346,6 +346,20 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return repo.create(input); 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 resolveReviewComment: publicProcedure
.input(z.object({ id: z.string().min(1) })) .input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {

View File

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

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react"; 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 { Button } from "@/components/ui/button";
import { CommentForm } from "./CommentForm"; import { CommentForm } from "./CommentForm";
import type { ReviewComment } from "./types"; import type { ReviewComment } from "./types";
@@ -9,9 +9,10 @@ interface CommentThreadProps {
onResolve: (commentId: string) => void; onResolve: (commentId: string) => void;
onUnresolve: (commentId: string) => void; onUnresolve: (commentId: string) => void;
onReply?: (parentCommentId: string, body: 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 // Group: root comments (no parentCommentId) and their replies
const rootComments = comments.filter((c) => !c.parentCommentId); const rootComments = comments.filter((c) => !c.parentCommentId);
const repliesByParent = new Map<string, ReviewComment[]>(); const repliesByParent = new Map<string, ReviewComment[]>();
@@ -33,6 +34,7 @@ export function CommentThread({ comments, onResolve, onUnresolve, onReply }: Com
onResolve={onResolve} onResolve={onResolve}
onUnresolve={onUnresolve} onUnresolve={onUnresolve}
onReply={onReply} onReply={onReply}
onEdit={onEdit}
/> />
))} ))}
</div> </div>
@@ -45,20 +47,30 @@ function RootComment({
onResolve, onResolve,
onUnresolve, onUnresolve,
onReply, onReply,
onEdit,
}: { }: {
comment: ReviewComment; comment: ReviewComment;
replies: ReviewComment[]; replies: ReviewComment[];
onResolve: (id: string) => void; onResolve: (id: string) => void;
onUnresolve: (id: string) => void; onUnresolve: (id: string) => void;
onReply?: (parentCommentId: string, body: string) => void; onReply?: (parentCommentId: string, body: string) => void;
onEdit?: (commentId: string, body: string) => void;
}) { }) {
const [isReplying, setIsReplying] = useState(false); const [isReplying, setIsReplying] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const replyRef = useRef<HTMLTextAreaElement>(null); const replyRef = useRef<HTMLTextAreaElement>(null);
const editRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { useEffect(() => {
if (isReplying) replyRef.current?.focus(); if (isReplying) replyRef.current?.focus();
}, [isReplying]); }, [isReplying]);
useEffect(() => {
if (editingId) editRef.current?.focus();
}, [editingId]);
const isEditingRoot = editingId === comment.id;
return ( return (
<div className={`rounded border ${comment.resolved ? "border-status-success-border bg-status-success-bg/50" : "border-border bg-card"}`}> <div className={`rounded border ${comment.resolved ? "border-status-success-border bg-status-success-bg/50" : "border-border bg-card"}`}>
{/* Root comment */} {/* Root comment */}
@@ -75,6 +87,17 @@ function RootComment({
)} )}
</div> </div>
<div className="flex items-center gap-0.5"> <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 && ( {onReply && !comment.resolved && (
<Button <Button
variant="ghost" variant="ghost"
@@ -99,7 +122,21 @@ function RootComment({
)} )}
</div> </div>
</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> <p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{comment.body}</p>
)}
</div> </div>
{/* Replies */} {/* Replies */}
@@ -114,13 +151,40 @@ function RootComment({
: "border-l-muted-foreground/30" : "border-l-muted-foreground/30"
}`} }`}
> >
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className={`font-semibold ${reply.author === "agent" ? "text-primary" : "text-foreground"}`}> <span className={`font-semibold ${reply.author === "agent" ? "text-primary" : "text-foreground"}`}>
{reply.author} {reply.author}
</span> </span>
<span className="text-muted-foreground">{formatTime(reply.createdAt)}</span> <span className="text-muted-foreground">{formatTime(reply.createdAt)}</span>
</div> </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> <p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
)}
</div> </div>
))} ))}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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