Skip to content

Commit 6070b3a

Browse files
authored
feat(code-reviews): add review detail page (#974)
## Summary - Add `/code-reviews/[reviewId]` detail page that shows review metadata, status, error messages, and a live stream view for active reviews. - Update the jobs list so the PR title links to the new detail page, the repo name links to the repo on GitHub/GitLab, and the PR number links to the external PR. ## Verification - [x] `pnpm typecheck` passes ## Visual Changes <img width="1141" height="539" alt="Screenshot 2026-03-10 at 14 16 05" src="https://github.com/user-attachments/assets/0e50e7e5-b2fa-465b-ae70-b6398cd4b255" /> ## Reviewer Notes N/A
2 parents d47f4f6 + d766475 commit 6070b3a

6 files changed

Lines changed: 727 additions & 14 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
'use client';
2+
3+
import { Badge } from '@/components/ui/badge';
4+
import { Button } from '@/components/ui/button';
5+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
6+
import { PageContainer } from '@/components/layouts/PageContainer';
7+
import { CodeReviewStreamView } from '@/components/code-reviews/CodeReviewStreamView';
8+
import {
9+
ExternalLink,
10+
GitPullRequest,
11+
Loader2,
12+
CheckCircle2,
13+
XCircle,
14+
Clock,
15+
AlertCircle,
16+
ArrowLeft,
17+
RotateCcw,
18+
Ban,
19+
} from 'lucide-react';
20+
import { useTRPC } from '@/lib/trpc/utils';
21+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
22+
import { formatDistanceToNow, format } from 'date-fns';
23+
import { TRPCClientError } from '@trpc/client';
24+
import { toast } from 'sonner';
25+
import Link from 'next/link';
26+
import { notFound } from 'next/navigation';
27+
28+
type CodeReviewStatus =
29+
| 'pending'
30+
| 'queued'
31+
| 'running'
32+
| 'completed'
33+
| 'failed'
34+
| 'cancelled'
35+
| 'interrupted';
36+
37+
const statusConfig: Record<
38+
CodeReviewStatus,
39+
{
40+
icon: React.ComponentType<{ className?: string }>;
41+
variant: 'default' | 'secondary' | 'destructive' | 'outline';
42+
label: string;
43+
}
44+
> = {
45+
pending: { icon: Clock, variant: 'secondary', label: 'Pending' },
46+
queued: { icon: Clock, variant: 'secondary', label: 'Queued' },
47+
running: { icon: Loader2, variant: 'default', label: 'Running' },
48+
completed: { icon: CheckCircle2, variant: 'default', label: 'Completed' },
49+
failed: { icon: XCircle, variant: 'destructive', label: 'Failed' },
50+
cancelled: { icon: Ban, variant: 'outline', label: 'Cancelled' },
51+
interrupted: { icon: AlertCircle, variant: 'outline', label: 'Interrupted' },
52+
};
53+
54+
type CodeReviewDetailClientProps = {
55+
reviewId: string;
56+
};
57+
58+
export function CodeReviewDetailClient({ reviewId }: CodeReviewDetailClientProps) {
59+
const trpc = useTRPC();
60+
const queryClient = useQueryClient();
61+
62+
const { data, isLoading, error } = useQuery({
63+
...trpc.codeReviews.get.queryOptions({ reviewId }),
64+
refetchInterval: query => {
65+
const result = query.state.data;
66+
if (!result?.success) return false;
67+
const status = result.review.status;
68+
return ['pending', 'queued', 'running'].includes(status) ? 5000 : false;
69+
},
70+
});
71+
72+
const retriggerMutation = useMutation(
73+
trpc.codeReviews.retrigger.mutationOptions({
74+
onSuccess: async () => {
75+
toast.success('Code review retriggered', {
76+
description: 'The code review has been queued for processing.',
77+
});
78+
await queryClient.invalidateQueries({
79+
queryKey: trpc.codeReviews.get.queryKey({ reviewId }),
80+
});
81+
},
82+
onError: err => {
83+
toast.error('Failed to retrigger code review', { description: err.message });
84+
},
85+
})
86+
);
87+
88+
const cancelMutation = useMutation(
89+
trpc.codeReviews.cancel.mutationOptions({
90+
onSuccess: async () => {
91+
toast.success('Code review cancelled');
92+
await queryClient.invalidateQueries({
93+
queryKey: trpc.codeReviews.get.queryKey({ reviewId }),
94+
});
95+
},
96+
onError: err => {
97+
toast.error('Failed to cancel code review', { description: err.message });
98+
},
99+
})
100+
);
101+
102+
if (isLoading) {
103+
return (
104+
<PageContainer>
105+
<div className="flex items-center justify-center py-20">
106+
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
107+
</div>
108+
</PageContainer>
109+
);
110+
}
111+
112+
if (error || !data?.success) {
113+
if (error instanceof TRPCClientError && error.data?.code === 'NOT_FOUND') {
114+
return notFound();
115+
}
116+
return (
117+
<PageContainer>
118+
<div className="py-20 text-center">
119+
<h2 className="text-xl font-semibold">Code review not found</h2>
120+
<p className="text-muted-foreground mt-2">
121+
This code review may have been deleted or you don&apos;t have access to it.
122+
</p>
123+
<Link href="/code-reviews" className="mt-4 inline-block">
124+
<Button variant="outline">
125+
<ArrowLeft className="mr-2 h-4 w-4" />
126+
Back to Code Reviews
127+
</Button>
128+
</Link>
129+
</div>
130+
</PageContainer>
131+
);
132+
}
133+
134+
const review = data.review;
135+
const status = review.status as CodeReviewStatus;
136+
const statusInfo = statusConfig[status] ?? {
137+
icon: AlertCircle,
138+
variant: 'outline' as const,
139+
label: review.status,
140+
};
141+
const StatusIcon = statusInfo.icon;
142+
const showStreamView = status !== 'pending';
143+
const canRetry = ['failed', 'cancelled', 'interrupted'].includes(status);
144+
const canCancel = ['pending', 'queued', 'running'].includes(status);
145+
const prLabel = review.platform === 'gitlab' ? 'MR' : 'PR';
146+
147+
return (
148+
<PageContainer>
149+
{/* Back link */}
150+
<Link
151+
href="/code-reviews"
152+
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm transition-colors"
153+
>
154+
<ArrowLeft className="h-4 w-4" />
155+
Back to Code Reviews
156+
</Link>
157+
158+
{/* Header */}
159+
<div className="flex items-start justify-between gap-4">
160+
<div className="min-w-0 flex-1 space-y-1">
161+
<div className="flex items-center gap-3">
162+
<GitPullRequest className="text-muted-foreground h-6 w-6 shrink-0" />
163+
<h1 className="text-2xl font-bold">{review.pr_title}</h1>
164+
</div>
165+
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
166+
<span>{review.repo_full_name}</span>
167+
<span>&middot;</span>
168+
<a
169+
href={review.pr_url}
170+
target="_blank"
171+
rel="noopener noreferrer"
172+
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
173+
>
174+
{prLabel} #{review.pr_number}
175+
<ExternalLink className="h-3 w-3" />
176+
</a>
177+
<span>&middot;</span>
178+
<span>by @{review.pr_author}</span>
179+
</div>
180+
</div>
181+
<div className="flex items-center gap-2">
182+
{review.agent_version && (
183+
<Badge variant="outline" className="mt-1 text-xs whitespace-nowrap">
184+
{review.agent_version}
185+
</Badge>
186+
)}
187+
<Badge variant={statusInfo.variant} className="mt-1 gap-1.5 text-sm whitespace-nowrap">
188+
<StatusIcon className={`h-4 w-4 ${status === 'running' ? 'animate-spin' : ''}`} />
189+
{statusInfo.label}
190+
</Badge>
191+
</div>
192+
</div>
193+
194+
{/* Details card */}
195+
<Card>
196+
<CardHeader>
197+
<CardTitle className="text-base">Review Details</CardTitle>
198+
</CardHeader>
199+
<CardContent>
200+
<dl className="grid grid-cols-1 gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
201+
<div>
202+
<dt className="text-muted-foreground">Branch</dt>
203+
<dd className="font-medium">
204+
{review.head_ref} &rarr; {review.base_ref}
205+
</dd>
206+
</div>
207+
<div>
208+
<dt className="text-muted-foreground">Commit</dt>
209+
<dd className="font-mono text-xs">{review.head_sha.slice(0, 12)}</dd>
210+
</div>
211+
<div>
212+
<dt className="text-muted-foreground">Platform</dt>
213+
<dd className="capitalize">{review.platform}</dd>
214+
</div>
215+
{review.model && (
216+
<div>
217+
<dt className="text-muted-foreground">Model</dt>
218+
<dd>{review.model}</dd>
219+
</div>
220+
)}
221+
<div>
222+
<dt className="text-muted-foreground">Created</dt>
223+
<dd>
224+
{format(new Date(review.created_at), 'MMM d, yyyy HH:mm:ss')}
225+
<span className="text-muted-foreground ml-1 text-xs">
226+
({formatDistanceToNow(new Date(review.created_at), { addSuffix: true })})
227+
</span>
228+
</dd>
229+
</div>
230+
{review.started_at && (
231+
<div>
232+
<dt className="text-muted-foreground">Started</dt>
233+
<dd>
234+
{format(new Date(review.started_at), 'MMM d, yyyy HH:mm:ss')}
235+
<span className="text-muted-foreground ml-1 text-xs">
236+
({formatDistanceToNow(new Date(review.started_at), { addSuffix: true })})
237+
</span>
238+
</dd>
239+
</div>
240+
)}
241+
{review.completed_at && (
242+
<div>
243+
<dt className="text-muted-foreground">Completed</dt>
244+
<dd>
245+
{format(new Date(review.completed_at), 'MMM d, yyyy HH:mm:ss')}
246+
<span className="text-muted-foreground ml-1 text-xs">
247+
({formatDistanceToNow(new Date(review.completed_at), { addSuffix: true })})
248+
</span>
249+
</dd>
250+
</div>
251+
)}
252+
{review.total_cost_musd != null && review.total_cost_musd > 0 && (
253+
<div>
254+
<dt className="text-muted-foreground">Cost</dt>
255+
<dd>${(review.total_cost_musd / 1_000_000).toFixed(4)}</dd>
256+
</div>
257+
)}
258+
{(review.total_tokens_in != null || review.total_tokens_out != null) && (
259+
<div>
260+
<dt className="text-muted-foreground">Tokens</dt>
261+
<dd>
262+
{review.total_tokens_in?.toLocaleString() ?? '—'} in /{' '}
263+
{review.total_tokens_out?.toLocaleString() ?? '—'} out
264+
</dd>
265+
</div>
266+
)}
267+
</dl>
268+
269+
{/* Error message */}
270+
{review.error_message && (
271+
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
272+
<strong>Error:</strong> {review.error_message}
273+
</div>
274+
)}
275+
276+
{/* Action buttons */}
277+
{(canCancel || canRetry) && (
278+
<div className="mt-4 flex gap-2">
279+
{canCancel && (
280+
<Button
281+
variant="outline"
282+
size="sm"
283+
onClick={() => cancelMutation.mutate({ reviewId })}
284+
disabled={cancelMutation.isPending}
285+
className="gap-2"
286+
>
287+
<Ban className={`h-3 w-3 ${cancelMutation.isPending ? 'animate-spin' : ''}`} />
288+
{cancelMutation.isPending ? 'Cancelling...' : 'Cancel'}
289+
</Button>
290+
)}
291+
{canRetry && (
292+
<Button
293+
variant="outline"
294+
size="sm"
295+
onClick={() => retriggerMutation.mutate({ reviewId })}
296+
disabled={retriggerMutation.isPending}
297+
className="gap-2"
298+
>
299+
<RotateCcw
300+
className={`h-3 w-3 ${retriggerMutation.isPending ? 'animate-spin' : ''}`}
301+
/>
302+
{retriggerMutation.isPending ? 'Retrying...' : 'Retry'}
303+
</Button>
304+
)}
305+
</div>
306+
)}
307+
</CardContent>
308+
</Card>
309+
310+
{/* Session log / live stream */}
311+
{showStreamView && (
312+
<CodeReviewStreamView
313+
reviewId={reviewId}
314+
onComplete={() => {
315+
void queryClient.invalidateQueries({
316+
queryKey: trpc.codeReviews.get.queryKey({ reviewId }),
317+
});
318+
}}
319+
/>
320+
)}
321+
</PageContainer>
322+
);
323+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { getUserFromAuthOrRedirect } from '@/lib/user.server';
2+
import { CodeReviewDetailClient } from './CodeReviewDetailClient';
3+
4+
export default async function CodeReviewDetailPage({
5+
params,
6+
}: {
7+
params: Promise<{ reviewId: string }>;
8+
}) {
9+
const { reviewId } = await params;
10+
await getUserFromAuthOrRedirect(`/users/sign_in?callbackPath=/code-reviews/${reviewId}`);
11+
12+
return <CodeReviewDetailClient reviewId={reviewId} />;
13+
}

src/components/code-reviews/CodeReviewJobsCard.tsx

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { useState } from 'react';
4+
import Link from 'next/link';
45
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
56
import { Badge } from '@/components/ui/badge';
67
import { Button } from '@/components/ui/button';
@@ -243,20 +244,36 @@ export function CodeReviewJobsCard({
243244
<div className="min-w-0 flex-1 space-y-1">
244245
<div className="flex items-start justify-between gap-2">
245246
<div className="min-w-0 flex-1">
246-
<a
247-
href={review.pr_url}
248-
target="_blank"
249-
rel="noopener noreferrer"
250-
className="text-foreground hover:text-primary inline-flex items-center gap-1 text-sm font-medium transition-colors hover:underline"
247+
<Link
248+
href={`/code-reviews/${review.id}`}
249+
className="text-foreground hover:text-primary text-sm font-medium transition-colors hover:underline"
251250
>
252251
{review.pr_title}
253-
<ExternalLink className="h-3 w-3" />
254-
</a>
252+
</Link>
255253
<div className="text-muted-foreground mt-0.5 flex items-center gap-2 text-xs">
256-
<span>{review.repo_full_name}</span>
257-
<span></span>
258-
<span>#{review.pr_number}</span>
259-
<span></span>
254+
<a
255+
href={
256+
review.platform === 'gitlab'
257+
? review.pr_url.replace(/\/-\/merge_requests\/\d+$/, '')
258+
: review.pr_url.replace(/\/pull\/\d+$/, '')
259+
}
260+
target="_blank"
261+
rel="noopener noreferrer"
262+
className="hover:text-primary transition-colors hover:underline"
263+
>
264+
{review.repo_full_name}
265+
</a>
266+
<span>&middot;</span>
267+
<a
268+
href={review.pr_url}
269+
target="_blank"
270+
rel="noopener noreferrer"
271+
className="hover:text-primary inline-flex items-center gap-1 transition-colors hover:underline"
272+
>
273+
#{review.pr_number}
274+
<ExternalLink className="h-3 w-3" />
275+
</a>
276+
<span>&middot;</span>
260277
<span>by @{review.pr_author}</span>
261278
</div>
262279
</div>

0 commit comments

Comments
 (0)