gitdataai/src/components/repository/PRConversation.tsx
ZhenYi 99bc4eeb80 chore: API and frontend UI adjustments
- API: issue label bulk add, search messages, room WS push, openapi
- Frontend: notify page, issue detail AI triage banner, search page,
  repository settings, preferences, PR components, file browser
- Room: DiscordChannelSidebar, RoomPinPanel, RoomMessageActions,
  RoomThreadPanel, MessageContent, repository-context
- Frontend SDK regenerated from openapi.json
2026-04-25 09:54:05 +08:00

459 lines
18 KiB
TypeScript

import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
reviewCommentList,
reviewList,
reviewRequestList,
reviewRequestCreate,
reviewRequestDelete,
type ReviewCommentListResponse,
type ReviewResponse,
type ReviewRequestResponse,
} from "@/client";
import { PRCommentInput } from "./PRCommentInput";
import { PRInlineComment } from "./PRInlineComment";
import { ContentRenderer } from "@/components/shared/ContentRenderer";
import { useTypingIndicator } from "@/hooks/useTypingIndicator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import {
Check,
GitPullRequest,
MessageSquare,
UserPlus,
Users,
X,
} from "lucide-react";
interface PRConversationProps {
namespace: string;
repoName: string;
prNumber: number;
/** Current user UID (for permission checks) */
currentUserUid?: string;
/** Whether the current user can modify comments */
canModify?: boolean;
}
const getRelativeTime = (dateString: string | null | undefined) => {
if (!dateString) return "";
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) return "just now";
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)}d ago`;
return `${Math.floor(diffInSeconds / 2592000)}mo ago`;
};
export function PRConversation({
namespace,
repoName,
prNumber,
canModify = false,
}: PRConversationProps) {
const queryClient = useQueryClient();
const [reviewState, setReviewState] = useState<string>("");
const [_reviewBody, setReviewBody] = useState("");
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
const [addReviewerOpen, setAddReviewerOpen] = useState(false);
const [newReviewerUid, setNewReviewerUid] = useState("");
// Typing indicator for comment input
const { typingUsers, sendTypingStart, sendTypingStop } = useTypingIndicator({});
// Fetch review comments (general comments only — no path)
const { data: commentsData, isLoading: commentsLoading } = useQuery({
queryKey: ["pr-comments-general", namespace, repoName, prNumber],
queryFn: async () => {
const resp = await reviewCommentList({
path: { namespace, repo: repoName, pr_number: prNumber },
query: { file_only: false },
});
return resp.data?.data as ReviewCommentListResponse;
},
enabled: !!namespace && !!repoName && !!prNumber,
staleTime: 30 * 1000,
});
// Fetch reviews
const { data: reviewsData } = useQuery({
queryKey: ["pr-reviews", namespace, repoName, prNumber],
queryFn: async () => {
const resp = await reviewList({
path: { namespace, repo: repoName, pr_number: prNumber },
});
return resp.data?.data;
},
enabled: !!namespace && !!repoName && !!prNumber,
staleTime: 30 * 1000,
});
// Fetch review requests
const { data: reviewRequestsData } = useQuery({
queryKey: ["pr-review-requests", namespace, repoName, prNumber],
queryFn: async () => {
const resp = await reviewRequestList({
path: { namespace, repo: repoName, pr_number: prNumber },
});
return resp.data?.data;
},
enabled: !!namespace && !!repoName && !!prNumber,
staleTime: 30 * 1000,
});
// Create general comment
const createCommentMutation = useMutation({
mutationFn: async (body: string) => {
await reviewCommentList /* reuse endpoint */ ({
path: { namespace, repo: repoName, pr_number: prNumber },
query: {},
});
// Use reviewCommentCreate for general comments
const { reviewCommentCreate } = await import("@/client");
await reviewCommentCreate({
path: { namespace, repo: repoName, pr_number: prNumber },
body: { body, path: null, side: null, line: null, old_line: null },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["pr-comments-general", namespace, repoName, prNumber] });
toast.success("Comment posted");
},
onError: () => toast.error("Failed to post comment"),
});
// Submit review
const submitReviewMutation = useMutation({
mutationFn: async ({ state, body }: { state: string; body: string }) => {
const { reviewSubmit } = await import("@/client");
await reviewSubmit({
path: { namespace, repo: repoName, pr_number: prNumber },
body: { state, body: body || undefined },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["pr-reviews", namespace, repoName, prNumber] });
queryClient.invalidateQueries({ queryKey: ["pr-comments-general", namespace, repoName, prNumber] });
setReviewDialogOpen(false);
setReviewState("");
setReviewBody("");
toast.success("Review submitted");
},
onError: () => toast.error("Failed to submit review"),
});
// Add reviewer
const addReviewerMutation = useMutation({
mutationFn: async (reviewerUid: string) => {
await reviewRequestCreate({
path: { namespace, repo: repoName, pr_number: prNumber },
body: { reviewer: reviewerUid },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["pr-review-requests", namespace, repoName, prNumber] });
setAddReviewerOpen(false);
setNewReviewerUid("");
toast.success("Review requested");
},
onError: () => toast.error("Failed to request review"),
});
// Remove reviewer
const removeReviewerMutation = useMutation({
mutationFn: async (reviewerUid: string) => {
await reviewRequestDelete({
path: { namespace, repo: repoName, pr_number: prNumber, reviewer: reviewerUid },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["pr-review-requests", namespace, repoName, prNumber] });
toast.success("Review request removed");
},
onError: () => toast.error("Failed to remove review request"),
});
// Separate general comments (no path) from file comments
const comments = commentsData?.comments;
const generalComments = Array.isArray(comments) ? comments.filter(
(c) => !c.path
) : [];
const reviews = reviewsData?.reviews ?? [];
const reviewRequests = reviewRequestsData?.requests ?? [];
return (
<div className="space-y-6">
{/* Review requests section */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Users className="h-4 w-4" />
Review Requests
</h3>
<Dialog open={addReviewerOpen} onOpenChange={setAddReviewerOpen}>
<DialogTrigger className="shrink-0 justify-center border border-transparent bg-clip-padding font-medium inline-flex items-center rounded-lg text-sm whitespace-nowrap transition-all outline-none select-none h-7 gap-1 px-2.5 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground border-input bg-transparent">
<UserPlus className="h-4 w-4 mr-1" />
Request review
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Request a review</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<Label>Reviewer User ID</Label>
<Input
placeholder="Enter user ID (UUID)"
value={newReviewerUid}
onChange={(e) => setNewReviewerUid(e.target.value)}
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setAddReviewerOpen(false)}
>
Cancel
</Button>
<Button
onClick={() => {
if (newReviewerUid.trim()) {
addReviewerMutation.mutate(newReviewerUid.trim());
}
}}
disabled={!newReviewerUid.trim() || addReviewerMutation.isPending}
>
Request Review
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{reviewRequests.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No review requests.</p>
) : (
<div className="space-y-1">
{reviewRequests.map((req: ReviewRequestResponse) => (
<div
key={req.reviewer}
className="flex items-center justify-between p-2 rounded-md border bg-card"
>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs">
{req.reviewer_username?.[0]?.toUpperCase() ?? "?"}
</div>
<div>
<span className="text-sm font-medium">
{req.reviewer_username ?? req.reviewer}
</span>
<span className="text-xs text-muted-foreground ml-2">
requested by {req.requested_by_username ?? req.requested_by}
{" · "}
{getRelativeTime(req.requested_at)}
</span>
</div>
{req.dismissed_at && (
<span className="text-xs bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
dismissed
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-red-600"
onClick={() => removeReviewerMutation.mutate(req.reviewer)}
disabled={removeReviewerMutation.isPending}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Reviews section */}
{reviews.length > 0 && (
<div>
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
<GitPullRequest className="h-4 w-4" />
Reviews
</h3>
<div className="space-y-3">
{reviews.map((review: ReviewResponse) => (
<div key={review.reviewer} className="p-3 border rounded-md bg-card">
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs">
{review.reviewer_username?.[0]?.toUpperCase() ?? "?"}
</div>
<span className="text-sm font-medium">
{review.reviewer_username ?? review.reviewer}
</span>
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${
review.state === "approved"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: review.state === "changes_requested"
? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
: "bg-muted text-muted-foreground"
}`}
>
{review.state}
</span>
<span className="text-xs text-muted-foreground">
{getRelativeTime(review.submitted_at ?? review.created_at)}
</span>
</div>
{review.body && (
<ContentRenderer content={review.body} className="text-sm text-muted-foreground" />
)}
</div>
))}
</div>
</div>
)}
{/* General comments section */}
<div>
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
<MessageSquare className="h-4 w-4" />
Comments ({generalComments.length})
</h3>
{/* Add general comment */}
<div className="mb-4">
<PRCommentInput
placeholder="Leave a comment..."
buttonLabel="Comment"
onSubmit={(body) => createCommentMutation.mutate(body)}
onTypingStart={sendTypingStart}
onTypingStop={sendTypingStop}
/>
{typingUsers.length > 0 && (
<div className="mt-1.5 flex items-center gap-1.5 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="flex -space-x-1.5">
{typingUsers.slice(0, 3).map((u) => (
<Avatar key={u.userId} className="h-5 w-5 border border-background">
{u.avatarUrl ? (
<img src={u.avatarUrl} alt={u.username} className="h-5 w-5 rounded-full object-cover" />
) : (
<AvatarFallback className="text-[10px]">{u.username[0]?.toUpperCase()}</AvatarFallback>
)}
</Avatar>
))}
</div>
<span className="text-xs text-muted-foreground">
{typingUsers.length === 1
? `${typingUsers[0].username} is typing…`
: typingUsers.length === 2
? `${typingUsers[0].username} and ${typingUsers[1].username} are typing…`
: `${typingUsers.length} people are typing…`}
</span>
<span className="flex gap-0.5 ml-1">
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
)}
</div>
{/* Comment list */}
{commentsLoading ? (
<p className="text-sm text-muted-foreground">Loading comments...</p>
) : generalComments.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No comments yet.
</p>
) : (
<div className="space-y-4">
{generalComments.map((comment) => (
<PRInlineComment
key={comment.id}
comment={comment}
namespace={namespace}
repoName={repoName}
prNumber={prNumber}
canModify={canModify}
onRefresh={() =>
queryClient.invalidateQueries({
queryKey: ["pr-comments-general", namespace, repoName, prNumber],
})
}
/>
))}
</div>
)}
</div>
{/* Submit review button */}
<div className="border-t pt-4">
<Dialog open={reviewDialogOpen} onOpenChange={setReviewDialogOpen}>
<DialogTrigger className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none h-8 gap-1.5 px-2.5 hover:bg-primary/80 bg-primary text-primary-foreground">
Submit Review
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Submit Review</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Review state buttons */}
<div className="flex gap-2">
{[
{ value: "approved", label: "Approve", icon: Check, color: "text-green-600" },
{ value: "changes_requested", label: "Request changes", icon: X, color: "text-red-600" },
{ value: "comment", label: "Comment only", icon: MessageSquare, color: "text-muted-foreground" },
].map(({ value, label, icon: Icon, color }) => (
<Button
key={value}
type="button"
variant={reviewState === value ? "default" : "outline"}
size="sm"
onClick={() => setReviewState(value)}
className={`flex-1 ${reviewState === value ? "" : color}`}
>
<Icon className="h-4 w-4 mr-1" />
{label}
</Button>
))}
</div>
{/* Review body */}
<PRCommentInput
placeholder="Leave a comment about this pull request..."
buttonLabel="Submit Review"
onSubmit={async (body) => {
await submitReviewMutation.mutateAsync({
state: reviewState || "comment",
body,
});
}}
onCancel={() => setReviewDialogOpen(false)}
loading={submitReviewMutation.isPending}
showPreviewToggle={false}
minRows={4}
/>
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}