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:
@@ -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)
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user