"use client"; import { useCallback, useEffect, useState } from "react"; import { setPanelTitle } from "@/lib/layout/panel-portal-manager"; import { IconRefresh, IconPlus, IconMinus, IconAlertTriangle, IconGitMerge, IconCheck, IconLoader2, } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Separator } from "@kandev/ui/separator"; import { ScrollArea } from "@kandev/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { prPanelLabel } from "@/components/github/pr-utils"; import { usePRFeedback } from "@/hooks/domains/github/use-pr-feedback"; import { useCommentsStore } from "@/lib/state/slices/comments"; import type { PRFeedbackComment } from "@/lib/state/slices/comments"; import { useToast } from "@/components/toast-provider"; import { submitPRReview } from "@/lib/api/domains/github-api"; import type { TaskPR, PRFeedback } from "@/lib/types/github"; import { formatTimeAgo, AuthorLink, getTimeAgoColor, CollapsibleSection, PRMarkdownBody, } from "./pr-shared"; import { ReviewStateBadge } from "./pr-reviews-section"; import { ChecksSection } from "./pr-checks-section"; import { ReviewsSection } from "./pr-reviews-section"; import { CommentsSection } from "./pr-comments-section"; // --- Dockview panel wrapper --- type PRDetailPanelProps = { panelId: string; /** Per-PR params; multi-repo panels carry prKey="//". */ params?: { prKey?: string }; }; export function PRDetailPanelComponent({ panelId, params }: PRDetailPanelProps) { const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); const { prs } = useTaskPR(activeTaskId); const activePR = useActiveTaskPR(); const sessionId = useAppStore((s) => s.tasks.activeSessionId); // Multi-repo: when the panel was opened with a prKey, render the matching // TaskPR. Falls back to the active (primary) PR for legacy single-repo // panels that pre-date the prKey param. const pr = (params?.prKey ? prs.find((p) => prTaskKey(p) === params.prKey) : null) ?? activePR; useEffect(() => { const title = pr ? prPanelLabel(pr.pr_number) : "Pull Request"; setPanelTitle(panelId, title); }, [pr, panelId]); if (!pr || !sessionId) { return (
No pull request linked to this session.
); } return (
); } /** Stable per-PR key used by addPRPanel and the multi-PR topbar buttons. */ export function prTaskKey(pr: TaskPR): string { return `${pr.owner}/${pr.repo}/${pr.pr_number}`; } // --- Add PR feedback as chat context --- function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) { const { toast } = useToast(); const addComment = useCommentsStore((s) => s.addComment); const addAsContext = useCallback( (feedbackType: PRFeedbackComment["feedbackType"], content: string) => { const comment: PRFeedbackComment = { id: `pr-feedback-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, sessionId, text: content, createdAt: new Date().toISOString(), status: "pending", source: "pr-feedback", prNumber, feedbackType, content, }; addComment(comment); toast({ description: "Added to chat context" }); }, [sessionId, prNumber, addComment, toast], ); return { addAsContext }; } type PRPanelMetrics = { reviewCount: number; pendingReviewCount: number; commentCount: number; reviewState: TaskPR["review_state"]; }; function computeLiveReviewState(feedback: PRFeedback, fallbackState: TaskPR["review_state"]) { const requestedReviewers = feedback.pr.requested_reviewers ?? []; const reviews = feedback.reviews ?? []; if (reviews.length === 0) { return requestedReviewers.length > 0 ? "pending" : fallbackState || ""; } const latestByAuthor = new Map(); for (const review of reviews) { const current = latestByAuthor.get(review.author); const createdAt = new Date(review.created_at).getTime(); if (!current || createdAt > current.createdAt) { latestByAuthor.set(review.author, { state: review.state, createdAt }); } } let hasChangesRequested = false; let allApproved = true; for (const review of latestByAuthor.values()) { if (review.state === "CHANGES_REQUESTED") hasChangesRequested = true; if (review.state !== "APPROVED") allApproved = false; } if (hasChangesRequested) return "changes_requested"; if (allApproved) return "approved"; return "pending"; } function derivePanelMetrics(taskPR: TaskPR, feedback: PRFeedback | null): PRPanelMetrics { if (!feedback) { return { reviewCount: taskPR.review_count, pendingReviewCount: taskPR.pending_review_count, commentCount: taskPR.comment_count, reviewState: taskPR.review_state, }; } const pendingReviewCount = feedback.pr.requested_reviewers?.length ?? taskPR.pending_review_count; return { reviewCount: (feedback.reviews ?? []).length, pendingReviewCount, commentCount: (feedback.comments ?? []).length, reviewState: computeLiveReviewState(feedback, taskPR.review_state), }; } // --- Main content --- function DescriptionSection({ body }: { body: string }) { if (!body) return null; return (
); } // GitHub logins are case-insensitive; normalize before comparing. function shouldHideApproveButton( taskPR: TaskPR, feedback: PRFeedback | null, currentUser: string | null, ): boolean { const liveState = feedback?.pr.state ?? taskPR.state; if (liveState !== "open") return true; const normalizedUser = currentUser?.trim().toLowerCase(); if (!normalizedUser) return false; const prAuthor = feedback?.pr.author_login ?? taskPR.author_login; if (prAuthor?.trim().toLowerCase() === normalizedUser) return true; return ( feedback?.reviews?.some( (r) => r.state === "APPROVED" && r.author?.trim().toLowerCase() === normalizedUser, ) ?? false ); } function ApproveButton({ taskPR, feedback, onRefresh, }: { taskPR: TaskPR; feedback: PRFeedback | null; onRefresh: () => void; }) { const { toast } = useToast(); const [submitting, setSubmitting] = useState(false); const currentUser = useAppStore((s) => s.githubStatus.status?.username ?? null); if (shouldHideApproveButton(taskPR, feedback, currentUser)) return null; const handleApprove = async () => { setSubmitting(true); try { await submitPRReview(taskPR.owner, taskPR.repo, taskPR.pr_number, "APPROVE"); toast({ description: "PR approved", variant: "success" }); onRefresh(); } catch (e) { toast({ title: "Failed to approve", description: e instanceof Error ? e.message : "An error occurred", variant: "error", }); } finally { setSubmitting(false); } }; return ( ); } export function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: string }) { const { feedback, loading, refresh } = usePRFeedback(taskPR.owner, taskPR.repo, taskPR.pr_number); const { addAsContext } = useAddPRFeedbackAsContext(sessionId, taskPR.pr_number); const setTaskPR = useAppStore((s) => s.setTaskPR); // Sync live feedback data back to the store so topbar/other consumers stay up to date. // Use primitive deps to avoid re-render loops from object reference changes. // Guard: never regress the store to a less-terminal state (e.g. merged → open) // because the feedback fetch may return stale data from before a backend poll update. const prState = taskPR.state; const prMergedAt = taskPR.merged_at ?? null; const prClosedAt = taskPR.closed_at ?? null; const prAdditions = taskPR.additions; const prDeletions = taskPR.deletions; const prTaskId = taskPR.task_id; useEffect(() => { if (!feedback) return; const livePR = feedback.pr; // State priority: merged > closed > open. Never regress to a less-terminal state. const stateRank = (s: string) => { if (s === "merged") return 2; if (s === "closed") return 1; return 0; }; const effectiveState = stateRank(livePR.state) >= stateRank(prState) ? livePR.state : prState; const effectiveMergedAt = effectiveState === prState ? prMergedAt : (livePR.merged_at ?? null); const effectiveClosedAt = effectiveState === prState ? prClosedAt : (livePR.closed_at ?? null); if ( effectiveState !== prState || effectiveMergedAt !== prMergedAt || effectiveClosedAt !== prClosedAt || livePR.additions !== prAdditions || livePR.deletions !== prDeletions ) { setTaskPR(prTaskId, { ...taskPR, state: effectiveState as TaskPR["state"], additions: livePR.additions, deletions: livePR.deletions, merged_at: effectiveMergedAt, closed_at: effectiveClosedAt, }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [feedback, prState, prMergedAt, prClosedAt, prAdditions, prDeletions, prTaskId, setTaskPR]); const metrics = derivePanelMetrics(taskPR, feedback); return (
{loading && !feedback && (
)} {feedback && ( <> addAsContext("review", msg)} /> addAsContext("check", msg)} /> addAsContext("comment", msg)} /> )}
{taskPR.last_synced_at && ( <>
Last synced {formatTimeAgo(taskPR.last_synced_at)}
)}
); } // --- Header --- function StateBadge({ state }: { state: string }) { const styles: Record = { open: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", draft: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400", merged: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", closed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", }; return ( {state} ); } function HeaderTitleRow({ taskPR, loading, onRefresh, }: { taskPR: TaskPR; loading: boolean; onRefresh: () => void; }) { return ( ); } function HeaderDateLine({ taskPR }: { taskPR: TaskPR }) { return (
by · opened {formatTimeAgo(taskPR.created_at)} {taskPR.merged_at && ( <> · merged {formatTimeAgo(taskPR.merged_at)} )} {taskPR.closed_at && !taskPR.merged_at && ( <> · closed {formatTimeAgo(taskPR.closed_at)} )}
); } function HeaderStatsLine({ taskPR, metrics }: { taskPR: TaskPR; metrics: PRPanelMetrics }) { return (
{taskPR.additions} {taskPR.deletions} · {metrics.reviewCount} review{metrics.reviewCount !== 1 ? "s" : ""} {metrics.pendingReviewCount > 0 && ( {" "} ({metrics.pendingReviewCount} pending) )} · {metrics.commentCount} comment{metrics.commentCount !== 1 ? "s" : ""} {metrics.reviewState && }
); } function PRHeader({ taskPR, feedback, metrics, loading, onRefresh, }: { taskPR: TaskPR; feedback: PRFeedback | null; metrics: PRPanelMetrics; loading: boolean; onRefresh: () => void; }) { const liveState = feedback?.pr.state ?? taskPR.state; const isDraft = feedback?.pr.draft ?? false; const isMergeable = feedback?.pr.mergeable ?? true; const showWarnings = !isDraft && !isMergeable && liveState === "open"; return (
#{taskPR.pr_number} {taskPR.head_branch} → {taskPR.base_branch}
{showWarnings && (
Not mergeable
)}
); }