Files
Codewalkers/apps/web/src/components/review/ReviewTab.tsx
Lukas May 865e8bffa0 feat: Add initiative review gate before push
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
2026-03-05 17:02:17 +01:00

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>
);
}