When all phases complete, the initiative now transitions to pending_review status instead of silently stopping. The user reviews the full initiative diff and chooses: - Push Branch: push cw/<name> to remote for PR workflows - Merge & Push: merge into default branch and push Changes: - Schema: Add pending_review to initiative status enum - BranchManager: Add pushBranch port + SimpleGit adapter - Events: initiative:pending_review, initiative:review_approved - Orchestrator: checkInitiativeCompletion + approveInitiative - tRPC: getInitiativeReviewDiff, getInitiativeReviewCommits, getInitiativeCommitDiff, approveInitiativeReview - Frontend: InitiativeReview component in ReviewTab - Subscriptions: Add initiative events + missing preview/conversation event types and subscription procedures
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { useCallback, 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 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 pendingReviewPhases = useMemo(
|
|
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"),
|
|
[phasesQuery.data],
|
|
);
|
|
|
|
// Select first pending review phase
|
|
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
|
|
const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null;
|
|
|
|
// 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 },
|
|
{ refetchInterval: 3000 },
|
|
);
|
|
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),
|
|
refetchInterval: 3000,
|
|
},
|
|
);
|
|
const preview = previewStatusQuery.data ?? existingPreview;
|
|
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
|
|
|
|
const startPreview = trpc.startPreview.useMutation({
|
|
onSuccess: (data) => {
|
|
setActivePreviewId(data.id);
|
|
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: startPreview.isPending
|
|
? ("building" as const)
|
|
: preview?.status === "running"
|
|
? ("running" as const)
|
|
: preview?.status === "building"
|
|
? ("building" as const)
|
|
: preview?.status === "failed"
|
|
? ("failed" 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,
|
|
}));
|
|
}, [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 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 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;
|
|
const summary = window.prompt("Optional: describe what needs to change (leave blank for comments only)");
|
|
if (summary === null) return; // cancelled
|
|
requestChangesMutation.mutate({ phaseId: activePhaseId, summary: summary || undefined });
|
|
}, [activePhaseId, requestChangesMutation]);
|
|
|
|
const handleFileClick = useCallback((filePath: string) => {
|
|
const el = fileRefs.current.get(filePath);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
}, []);
|
|
|
|
const handlePhaseSelect = useCallback((id: string) => {
|
|
setSelectedPhaseId(id);
|
|
setSelectedCommit(null);
|
|
setStatus("pending");
|
|
setViewedFiles(new Set());
|
|
}, []);
|
|
|
|
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
|
|
|
// Initiative-level review takes priority
|
|
if (isInitiativePendingReview) {
|
|
return (
|
|
<InitiativeReview
|
|
initiativeId={initiativeId}
|
|
onCompleted={() => {
|
|
initiativeQuery.refetch();
|
|
phasesQuery.refetch();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (pendingReviewPhases.length === 0) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
|
<p>No phases pending review</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const activePhaseName =
|
|
diffQuery.data?.phaseName ??
|
|
pendingReviewPhases.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]);
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border overflow-hidden bg-card">
|
|
{/* Header: phase selector + toolbar */}
|
|
<ReviewHeader
|
|
phases={pendingReviewPhases.map((p) => ({ id: p.id, name: p.name }))}
|
|
activePhaseId={activePhaseId}
|
|
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}
|
|
/>
|
|
|
|
{/* Main content area — sidebar always rendered to preserve state */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
|
|
{/* Left: Sidebar — sticky so icon strip stays visible */}
|
|
<div className="border-r border-border">
|
|
<div className="sticky top-0 h-[calc(100vh-12rem)]">
|
|
<ReviewSidebar
|
|
files={allFiles}
|
|
comments={comments}
|
|
onFileClick={handleFileClick}
|
|
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}
|
|
viewedFiles={viewedFiles}
|
|
onToggleViewed={toggleViewed}
|
|
onRegisterRef={registerFileRef}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|