diff --git a/apps/server/db/repositories/drizzle/review-comment.ts b/apps/server/db/repositories/drizzle/review-comment.ts index 7ac3b55..2ae3314 100644 --- a/apps/server/db/repositories/drizzle/review-comment.ts +++ b/apps/server/db/repositories/drizzle/review-comment.ts @@ -80,6 +80,19 @@ export class DrizzleReviewCommentRepository implements ReviewCommentRepository { .orderBy(asc(reviewComments.createdAt)); } + async update(id: string, body: string): Promise { + 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 { await this.db .update(reviewComments) diff --git a/apps/server/db/repositories/review-comment-repository.ts b/apps/server/db/repositories/review-comment-repository.ts index fff6f16..9ed7530 100644 --- a/apps/server/db/repositories/review-comment-repository.ts +++ b/apps/server/db/repositories/review-comment-repository.ts @@ -20,6 +20,7 @@ export interface ReviewCommentRepository { create(data: CreateReviewCommentData): Promise; createReply(parentCommentId: string, body: string, author?: string): Promise; findByPhaseId(phaseId: string): Promise; + update(id: string, body: string): Promise; resolve(id: string): Promise; unresolve(id: string): Promise; delete(id: string): Promise; diff --git a/apps/server/trpc/routers/phase.ts b/apps/server/trpc/routers/phase.ts index ef6608c..be59ef2 100644 --- a/apps/server/trpc/routers/phase.ts +++ b/apps/server/trpc/routers/phase.ts @@ -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 }) => { diff --git a/apps/web/src/components/review/CommentForm.tsx b/apps/web/src/components/review/CommentForm.tsx index 79e942a..0d1515d 100644 --- a/apps/web/src/components/review/CommentForm.tsx +++ b/apps/web/src/components/review/CommentForm.tsx @@ -7,14 +7,15 @@ interface CommentFormProps { onCancel: () => void; placeholder?: string; submitLabel?: string; + initialValue?: string; } export const CommentForm = forwardRef( 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(); diff --git a/apps/web/src/components/review/CommentThread.tsx b/apps/web/src/components/review/CommentThread.tsx index 52d575a..8a22dde 100644 --- a/apps/web/src/components/review/CommentThread.tsx +++ b/apps/web/src/components/review/CommentThread.tsx @@ -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(); @@ -33,6 +34,7 @@ export function CommentThread({ comments, onResolve, onUnresolve, onReply }: Com onResolve={onResolve} onUnresolve={onUnresolve} onReply={onReply} + onEdit={onEdit} /> ))} @@ -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(null); const replyRef = useRef(null); + const editRef = useRef(null); useEffect(() => { if (isReplying) replyRef.current?.focus(); }, [isReplying]); + useEffect(() => { + if (editingId) editRef.current?.focus(); + }, [editingId]); + + const isEditingRoot = editingId === comment.id; + return (
{/* Root comment */} @@ -75,6 +87,17 @@ function RootComment({ )}
+ {onEdit && comment.author !== "agent" && !comment.resolved && ( + + )} {onReply && !comment.resolved && (
-

{comment.body}

+ {isEditingRoot ? ( + { + onEdit!(comment.id, body); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + placeholder="Edit comment..." + submitLabel="Save" + /> + ) : ( +

{comment.body}

+ )} {/* Replies */} @@ -114,13 +151,40 @@ function RootComment({ : "border-l-muted-foreground/30" }`} > -
- - {reply.author} - - {formatTime(reply.createdAt)} +
+
+ + {reply.author} + + {formatTime(reply.createdAt)} +
+ {onEdit && reply.author !== "agent" && !comment.resolved && editingId !== reply.id && ( + + )}
-

{reply.body}

+ {editingId === reply.id ? ( + { + onEdit!(reply.id, body); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + placeholder="Edit reply..." + submitLabel="Save" + /> + ) : ( +

{reply.body}

+ )}
))} diff --git a/apps/web/src/components/review/DiffViewer.tsx b/apps/web/src/components/review/DiffViewer.tsx index 6fac668..5b9c1e2 100644 --- a/apps/web/src/components/review/DiffViewer.tsx +++ b/apps/web/src/components/review/DiffViewer.tsx @@ -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; 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)} /> diff --git a/apps/web/src/components/review/FileCard.tsx b/apps/web/src/components/review/FileCard.tsx index 87b2b5c..d7056c8 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -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} /> ))} diff --git a/apps/web/src/components/review/HunkRows.tsx b/apps/web/src/components/review/HunkRows.tsx index eaeeb5a..86cf6dd 100644 --- a/apps/web/src/components/review/HunkRows.tsx +++ b/apps/web/src/components/review/HunkRows.tsx @@ -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 diff --git a/apps/web/src/components/review/LineWithComments.tsx b/apps/web/src/components/review/LineWithComments.tsx index 579db1b..c5b8d12 100644 --- a/apps/web/src/components/review/LineWithComments.tsx +++ b/apps/web/src/components/review/LineWithComments.tsx @@ -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(null); @@ -144,6 +146,7 @@ export function LineWithComments({ onResolve={onResolveComment} onUnresolve={onUnresolveComment} onReply={onReplyComment} + onEdit={onEditComment} /> diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 0ce2079..099f380 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -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}