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