Files
Codewalkers/apps/web/src/components/review/ReviewTab.tsx

405 lines
14 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { parseUnifiedDiff } from "./parse-diff";
import { DiffViewer } from "./DiffViewer";
import { ReviewSidebar } from "./ReviewSidebar";
import { ReviewHeader } from "./ReviewHeader";
import { InitiativeReview } from "./InitiativeReview";
import type { ReviewStatus, DiffLine } from "./types";
interface ReviewTabProps {
initiativeId: string;
}
export function ReviewTab({ initiativeId }: ReviewTabProps) {
const [status, setStatus] = useState<ReviewStatus>("pending");
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const headerRef = useRef<HTMLDivElement>(null);
const [headerHeight, setHeaderHeight] = useState(0);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setHeaderHeight(entry.contentRect.height);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const toggleViewed = useCallback((filePath: string) => {
setViewedFiles(prev => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const registerFileRef = useCallback((filePath: string, el: HTMLDivElement | null) => {
if (el) {
fileRefs.current.set(filePath, el);
} else {
fileRefs.current.delete(filePath);
}
}, []);
// Fetch initiative to check for initiative-level pending_review
const initiativeQuery = trpc.getInitiative.useQuery({ id: initiativeId });
const isInitiativePendingReview = initiativeQuery.data?.status === "pending_review";
// Fetch phases for this initiative
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
const reviewablePhases = useMemo(
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review" || p.status === "completed"),
[phasesQuery.data],
);
// Select first pending review phase, falling back to completed phases
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const defaultPhaseId = reviewablePhases.find((p) => p.status === "pending_review")?.id ?? reviewablePhases[0]?.id ?? null;
const activePhaseId = selectedPhaseId ?? defaultPhaseId;
const activePhase = reviewablePhases.find((p) => p.id === activePhaseId);
const isActivePhaseCompleted = activePhase?.status === "completed";
// Fetch projects for this initiative (needed for preview)
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
// Fetch full branch diff for active phase
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
{ phaseId: activePhaseId! },
{ enabled: !!activePhaseId && !isInitiativePendingReview },
);
// Fetch commits for active phase
const commitsQuery = trpc.getPhaseReviewCommits.useQuery(
{ phaseId: activePhaseId! },
{ enabled: !!activePhaseId && !isInitiativePendingReview },
);
const commits = commitsQuery.data?.commits ?? [];
// Fetch single-commit diff when a commit is selected
const commitDiffQuery = trpc.getCommitDiff.useQuery(
{ phaseId: activePhaseId!, commitHash: selectedCommit! },
{ enabled: !!activePhaseId && !!selectedCommit && !isInitiativePendingReview },
);
// Preview state
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
const existingPreview = previewsQuery.data?.find(
(p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
);
const preview = previewStatusQuery.data ?? existingPreview;
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
const startPreview = trpc.startPreview.useMutation({
onSuccess: (data) => {
setActivePreviewId(data.id);
previewsQuery.refetch();
toast.success(`Preview running at ${data.url}`);
},
onError: (err) => toast.error(`Preview failed: ${err.message}`),
});
const stopPreview = trpc.stopPreview.useMutation({
onSuccess: () => {
setActivePreviewId(null);
toast.success("Preview stopped");
previewsQuery.refetch();
},
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
});
const previewState = firstProjectId && sourceBranch
? {
status: preview?.status === "running"
? ("running" as const)
: preview?.status === "failed"
? ("failed" as const)
: (startPreview.isPending || preview?.status === "building")
? ("building" as const)
: ("idle" as const),
url: preview?.url ?? undefined,
onStart: () =>
startPreview.mutate({
initiativeId,
phaseId: activePhaseId ?? undefined,
projectId: firstProjectId,
branch: sourceBranch,
}),
onStop: () => {
const id = activePreviewId ?? existingPreview?.id;
if (id) stopPreview.mutate({ previewId: id });
},
isStarting: startPreview.isPending,
isStopping: stopPreview.isPending,
}
: null;
// Review comments — persisted to DB
const utils = trpc.useUtils();
const commentsQuery = trpc.listReviewComments.useQuery(
{ phaseId: activePhaseId! },
{ enabled: !!activePhaseId && !isInitiativePendingReview },
);
const comments = useMemo(() => {
return (commentsQuery.data ?? []).map((c) => ({
id: c.id,
filePath: c.filePath,
lineNumber: c.lineNumber,
lineType: c.lineType as "added" | "removed" | "context",
body: c.body,
author: c.author,
createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt),
resolved: c.resolved,
parentCommentId: c.parentCommentId ?? null,
}));
}, [commentsQuery.data]);
const createCommentMutation = trpc.createReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
onError: (err) => toast.error(`Failed to save comment: ${err.message}`),
});
const resolveCommentMutation = trpc.resolveReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
});
const unresolveCommentMutation = trpc.unresolveReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
});
const replyToCommentMutation = trpc.replyToReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
onError: (err) => toast.error(`Failed to post reply: ${err.message}`),
});
const approveMutation = trpc.approvePhaseReview.useMutation({
onSuccess: () => {
setStatus("approved");
toast.success("Phase approved and merged");
phasesQuery.refetch();
},
onError: (err) => toast.error(err.message),
});
// Determine which diff to display
const activeDiffRaw = selectedCommit
? commitDiffQuery.data?.rawDiff
: diffQuery.data?.rawDiff;
const files = useMemo(() => {
if (!activeDiffRaw) return [];
return parseUnifiedDiff(activeDiffRaw);
}, [activeDiffRaw]);
const isDiffLoading = selectedCommit
? commitDiffQuery.isLoading
: diffQuery.isLoading;
const handleAddComment = useCallback(
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
if (!activePhaseId) return;
createCommentMutation.mutate({
phaseId: activePhaseId,
filePath,
lineNumber,
lineType,
body,
});
toast.success("Comment added");
},
[activePhaseId, createCommentMutation],
);
const handleResolveComment = useCallback((commentId: string) => {
resolveCommentMutation.mutate({ id: commentId });
}, [resolveCommentMutation]);
const handleUnresolveComment = useCallback((commentId: string) => {
unresolveCommentMutation.mutate({ id: commentId });
}, [unresolveCommentMutation]);
const handleReplyComment = useCallback((parentCommentId: string, body: string) => {
replyToCommentMutation.mutate({ parentCommentId, body });
}, [replyToCommentMutation]);
const handleApprove = useCallback(() => {
if (!activePhaseId) return;
approveMutation.mutate({ phaseId: activePhaseId });
}, [activePhaseId, approveMutation]);
const requestChangesMutation = trpc.requestPhaseChanges.useMutation({
onSuccess: () => {
setStatus("changes_requested");
toast.success("Changes requested — revision task dispatched");
phasesQuery.refetch();
},
onError: (err) => toast.error(err.message),
});
const handleRequestChanges = useCallback(() => {
if (!activePhaseId) return;
requestChangesMutation.mutate({ phaseId: activePhaseId });
}, [activePhaseId, requestChangesMutation]);
const handleFileClick = useCallback((filePath: string) => {
const el = fileRefs.current.get(filePath);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
const handleCommentClick = useCallback((commentId: string) => {
const el = document.querySelector(`[data-comment-id="${commentId}"]`);
if (el) {
el.scrollIntoView({ behavior: "instant", block: "center" });
// Brief highlight flash
el.classList.add("ring-2", "ring-primary/50");
setTimeout(() => el.classList.remove("ring-2", "ring-primary/50"), 1500);
}
}, []);
const handlePhaseSelect = useCallback((id: string) => {
setSelectedPhaseId(id);
setSelectedCommit(null);
setStatus("pending");
setViewedFiles(new Set());
}, []);
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
const activePhaseName =
diffQuery.data?.phaseName ??
reviewablePhases.find((p) => p.id === activePhaseId)?.name ??
"Phase";
// All files from the full branch diff (for sidebar file list)
const allFiles = useMemo(() => {
if (!diffQuery.data?.rawDiff) return [];
return parseUnifiedDiff(diffQuery.data.rawDiff);
}, [diffQuery.data?.rawDiff]);
// Initiative-level review takes priority
if (isInitiativePendingReview) {
return (
<InitiativeReview
initiativeId={initiativeId}
onCompleted={() => {
initiativeQuery.refetch();
phasesQuery.refetch();
}}
/>
);
}
if (reviewablePhases.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-muted-foreground">
<p>No phases pending review</p>
</div>
);
}
return (
<div className="rounded-lg border border-border bg-card">
{/* Header: phase selector + toolbar */}
<div ref={headerRef} className="sticky top-0 z-20">
<ReviewHeader
phases={reviewablePhases.map((p) => ({ id: p.id, name: p.name, status: p.status }))}
activePhaseId={activePhaseId}
isReadOnly={isActivePhaseCompleted}
onPhaseSelect={handlePhaseSelect}
phaseName={activePhaseName}
sourceBranch={sourceBranch}
targetBranch={diffQuery.data?.targetBranch ?? commitsQuery.data?.targetBranch ?? ""}
files={allFiles}
status={status}
unresolvedCount={unresolvedCount}
onApprove={handleApprove}
onRequestChanges={handleRequestChanges}
isRequestingChanges={requestChangesMutation.isPending}
preview={previewState}
viewedCount={viewedFiles.size}
totalCount={allFiles.length}
/>
</div>
{/* Main content area — sidebar always rendered to preserve state */}
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] rounded-b-lg">
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
<div className="border-r border-border">
<div
className="sticky overflow-hidden"
style={{
top: `${headerHeight}px`,
maxHeight: `calc(100vh - ${headerHeight}px)`,
}}
>
<ReviewSidebar
files={allFiles}
comments={comments}
onFileClick={handleFileClick}
onCommentClick={handleCommentClick}
selectedCommit={selectedCommit}
activeFiles={files}
commits={commits}
onSelectCommit={setSelectedCommit}
viewedFiles={viewedFiles}
/>
</div>
</div>
{/* Right: Diff */}
<div className="min-w-0 p-4">
{isDiffLoading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading diff...
</div>
) : files.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
{selectedCommit
? "No changes in this commit"
: "No changes in this phase"}
</div>
) : (
<DiffViewer
files={files}
comments={comments}
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
onReplyComment={handleReplyComment}
viewedFiles={viewedFiles}
onToggleViewed={toggleViewed}
onRegisterRef={registerFileRef}
/>
)}
</div>
</div>
</div>
);
}