Skip to content

Commit 2bf91d4

Browse files
feat: show multi-pr review status
1 parent e9a97f3 commit 2bf91d4

2 files changed

Lines changed: 126 additions & 87 deletions

File tree

frontend/src/renderer/components/SessionInspector.test.tsx

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ function renderWithQuery(children: ReactNode) {
5656
return render(<QueryClientProvider client={client}>{children}</QueryClientProvider>);
5757
}
5858

59-
function mockCommonGets(reviews: unknown[] = [], reviewerHandleId = "") {
59+
function mockCommonGets(reviews: unknown[] = [], reviewerHandleId = "", items: unknown[] = []) {
6060
getMock.mockImplementation(async (path: string) => {
6161
if (path === "/api/v1/sessions/{sessionId}/reviews") {
62-
return { data: { reviewerHandleId, reviews } };
62+
return { data: { reviewerHandleId, reviews, reviewItems: items, items } };
6363
}
6464
if (path === "/api/v1/projects/{id}") {
6565
return {
@@ -94,10 +94,18 @@ const approvedReview = {
9494
createdAt: "2026-06-16T10:06:00Z",
9595
};
9696

97+
const reviewItem = (n: number, status: string, targetSha = `sha-${n}`) => ({
98+
prUrl: `https://example.com/pr/${n}`,
99+
prNumber: n,
100+
targetSha,
101+
status,
102+
latestRun: status === "up_to_date" ? { ...approvedReview, prUrl: `https://example.com/pr/${n}`, targetSha } : undefined,
103+
});
104+
97105
beforeEach(() => {
98106
getMock.mockReset();
99107
postMock.mockReset();
100-
getMock.mockResolvedValue({ data: { reviewerHandleId: "", reviews: [] }, error: undefined });
108+
getMock.mockResolvedValue({ data: { reviewerHandleId: "", reviews: [], reviewItems: [], items: [] }, error: undefined });
101109
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
102110
});
103111

@@ -155,12 +163,16 @@ describe("SessionInspector reviews tab", () => {
155163
const openReviewsTab = async () => userEvent.click(screen.getByRole("tab", { name: /Reviews/ }));
156164

157165
it("triggers a review and opens the returned reviewer terminal", async () => {
158-
mockCommonGets();
166+
mockCommonGets([], "", [reviewItem(3, "needs_review")]);
167+
const runningReview = { ...approvedReview, status: "running", verdict: "", body: "" };
159168
postMock.mockResolvedValue({
160169
response: { status: 201 },
161170
data: {
162171
reviewerHandleId: "reviewer-pane",
163-
review: { ...approvedReview, status: "running", verdict: "", body: "" },
172+
review: runningReview,
173+
reviews: [runningReview],
174+
reviewItems: [{ ...reviewItem(3, "running"), latestRun: runningReview }],
175+
items: [{ ...reviewItem(3, "running"), latestRun: runningReview }],
164176
},
165177
});
166178
const onOpenReviewerTerminal = vi.fn();
@@ -170,7 +182,7 @@ describe("SessionInspector reviews tab", () => {
170182
);
171183
await openReviewsTab();
172184

173-
await userEvent.click(await screen.findByRole("button", { name: /run review/i }));
185+
await userEvent.click(await screen.findByRole("button", { name: /run needed reviews/i }));
174186

175187
await waitFor(() =>
176188
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/reviews/trigger", {
@@ -180,11 +192,32 @@ describe("SessionInspector reviews tab", () => {
180192
expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" });
181193
});
182194

183-
it("shows an up-to-date notice instead of opening the terminal when the backend reuses a run", async () => {
184-
mockCommonGets([approvedReview], "reviewer-pane");
195+
it("shows eligible and up-to-date PR review rows", async () => {
196+
mockCommonGets([approvedReview], "reviewer-pane", [
197+
reviewItem(3, "needs_review", "abc123"),
198+
reviewItem(4, "up_to_date", "def456"),
199+
]);
200+
201+
renderWithQuery(<SessionInspector session={session([pr(3, "open"), pr(4, "open")])} />);
202+
await openReviewsTab();
203+
204+
expect(await screen.findByText("PR #3")).toBeInTheDocument();
205+
expect(screen.getByText("Needs review")).toBeInTheDocument();
206+
expect(screen.getByText("PR #4")).toBeInTheDocument();
207+
expect(screen.getByText("Up to date")).toBeInTheDocument();
208+
});
209+
210+
it("shows a no-needed-reviews notice instead of opening the terminal when the backend reuses runs", async () => {
211+
mockCommonGets([approvedReview], "reviewer-pane", [reviewItem(3, "up_to_date")]);
185212
postMock.mockResolvedValue({
186213
response: { status: 200 },
187-
data: { reviewerHandleId: "reviewer-pane", review: approvedReview },
214+
data: {
215+
reviewerHandleId: "reviewer-pane",
216+
review: approvedReview,
217+
reviews: [],
218+
reviewItems: [reviewItem(3, "up_to_date")],
219+
items: [reviewItem(3, "up_to_date")],
220+
},
188221
});
189222
const onOpenReviewerTerminal = vi.fn();
190223

@@ -193,22 +226,25 @@ describe("SessionInspector reviews tab", () => {
193226
);
194227
await openReviewsTab();
195228

196-
await userEvent.click(await screen.findByRole("button", { name: /re-run review/i }));
229+
await userEvent.click(await screen.findByRole("button", { name: /run needed reviews/i }));
197230

198-
expect(await screen.findByText("Review is already up to date for this commit.")).toBeInTheDocument();
231+
expect(await screen.findByText("No needed reviews were started.")).toBeInTheDocument();
199232
expect(onOpenReviewerTerminal).not.toHaveBeenCalled();
200233
});
201234

202-
it("shows an approved review and opens its terminal", async () => {
203-
mockCommonGets([approvedReview], "reviewer-pane");
235+
it("shows one shared terminal action", async () => {
236+
mockCommonGets([approvedReview], "reviewer-pane", [
237+
reviewItem(3, "running", "abc123"),
238+
reviewItem(4, "up_to_date", "def456"),
239+
]);
204240
const onOpenReviewerTerminal = vi.fn();
205241

206242
renderWithQuery(
207243
<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session([pr(3, "open")])} />,
208244
);
209245
await openReviewsTab();
210246

211-
await waitFor(() => expect(screen.getAllByText("Approved").length).toBeGreaterThan(0));
247+
await waitFor(() => expect(screen.getAllByText("Open terminal")).toHaveLength(1));
212248
await userEvent.click(screen.getByRole("button", { name: /open terminal/i }));
213249

214250
expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" });

frontend/src/renderer/components/SessionInspector.tsx

Lines changed: 76 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { PRAttentionPanel, PRSummaryMeta } from "./PRSummaryDisplay";
2727

2828
type ProjectConfig = components["schemas"]["ProjectConfig"];
2929
type ReviewRun = components["schemas"]["ReviewRun"];
30+
type PRReviewItem = components["schemas"]["PRReviewItem"];
3031
type ReviewsResponse = components["schemas"]["ListReviewsResponse"];
3132
type OpenReviewerTerminal = (target: { handleId: string; harness: string }) => void;
3233

@@ -387,14 +388,17 @@ function ReviewsView({
387388
enabled: hasPr,
388389
refetchInterval: (query) => {
389390
const data = query.state.data as ReviewsResponse | undefined;
390-
return data?.reviews.some((review) => review.status === "running") ? 2500 : false;
391+
const reviewItems = data?.reviewItems ?? data?.items ?? [];
392+
return reviewItems.some((item) => item.status === "running") || data?.reviews.some((review) => review.status === "running")
393+
? 2500
394+
: false;
391395
},
392396
queryFn: async () => {
393397
const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/reviews", {
394398
params: { path: { sessionId: session.id } },
395399
});
396400
if (error) throw new Error(apiErrorMessage(error, "Unable to load reviews"));
397-
return data ?? ({ reviewerHandleId: "", reviews: [] } satisfies ReviewsResponse);
401+
return data ?? ({ reviewerHandleId: "", reviews: [], reviewItems: [], items: [] } satisfies ReviewsResponse);
398402
},
399403
});
400404
const projectConfigQuery = useQuery({
@@ -422,16 +426,18 @@ function ReviewsView({
422426
onSuccess: ({ data, reused }) => {
423427
void queryClient.invalidateQueries({ queryKey: ["session-reviews", session.id] });
424428
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
425-
if (reused) {
426-
setReviewNotice("Review is already up to date for this commit.");
429+
if (reused || !data?.reviews?.length) {
430+
setReviewNotice("No needed reviews were started.");
427431
return;
428432
}
429433
if (data?.reviewerHandleId) {
430-
onOpenReviewerTerminal?.({ handleId: data.reviewerHandleId, harness: data.review.harness || "reviewer" });
434+
const harness = data.reviews[0]?.harness || data.review.harness || "reviewer";
435+
onOpenReviewerTerminal?.({ handleId: data.reviewerHandleId, harness });
431436
}
432437
},
433438
});
434439
const reviews = reviewsQuery.data?.reviews ?? [];
440+
const items = reviewsQuery.data?.reviewItems ?? reviewsQuery.data?.items ?? [];
435441

436442
return (
437443
<div role="tabpanel">
@@ -445,6 +451,7 @@ function ReviewsView({
445451
onTrigger={() => triggerReview.mutate()}
446452
reviewerHandleId={reviewsQuery.data?.reviewerHandleId ?? ""}
447453
reviews={reviews}
454+
items={items}
448455
notice={reviewNotice}
449456
session={session}
450457
/>
@@ -462,6 +469,7 @@ function ReviewPanel({
462469
session,
463470
config,
464471
reviews,
472+
items,
465473
reviewerHandleId,
466474
isLoading,
467475
isTriggering,
@@ -473,6 +481,7 @@ function ReviewPanel({
473481
session: WorkspaceSession;
474482
config?: ProjectConfig;
475483
reviews: ReviewRun[];
484+
items: PRReviewItem[];
476485
reviewerHandleId: string;
477486
isLoading: boolean;
478487
isTriggering: boolean;
@@ -490,19 +499,50 @@ function ReviewPanel({
490499

491500
const latest = latestReview(reviews);
492501
const harness = latest?.harness || config?.reviewers?.[0]?.harness || session.provider || "reviewer";
502+
const terminalEnabled = Boolean(reviewerHandleId && onOpenTerminal);
493503

494504
return (
495505
<div className="reviewer-list">
496506
{error ? <p className="reviewer-error">{apiErrorMessage(error, "Review request failed")}</p> : null}
497507
{notice ? <p className="reviewer-notice">{notice}</p> : null}
498-
<ReviewerCard
499-
handleId={reviewerHandleId}
500-
harness={harness}
501-
isTriggering={isTriggering}
502-
onOpenTerminal={onOpenTerminal}
503-
onTrigger={onTrigger}
504-
review={latest}
505-
/>
508+
<div className="reviewer-card">
509+
<div className="reviewer-card__top">
510+
<div className="reviewer-card__name">
511+
<Shield aria-hidden="true" />
512+
<span>{harness}</span>
513+
</div>
514+
<span className="reviewer-status reviewer-status--neutral">{items.length} PRs</span>
515+
</div>
516+
<div className="reviewer-card__actions">
517+
<button
518+
className="reviewer-card__action reviewer-card__action--primary"
519+
disabled={isTriggering}
520+
onClick={onTrigger}
521+
type="button"
522+
>
523+
<Play aria-hidden="true" />
524+
{isTriggering ? "Starting..." : "Run needed reviews"}
525+
</button>
526+
<button
527+
className="reviewer-card__action"
528+
disabled={!terminalEnabled}
529+
onClick={() => {
530+
if (!terminalEnabled) return;
531+
onOpenTerminal?.({ handleId: reviewerHandleId, harness });
532+
}}
533+
type="button"
534+
>
535+
<Terminal aria-hidden="true" />
536+
Open terminal
537+
</button>
538+
</div>
539+
</div>
540+
<div className="flex flex-col gap-2">
541+
{items.length === 0 ? <p className="inspector-empty">No review state loaded yet.</p> : null}
542+
{items.map((item) => (
543+
<ReviewItemCard key={`${item.prUrl}:${item.targetSha}`} item={item} />
544+
))}
545+
</div>
506546
</div>
507547
);
508548
}
@@ -511,85 +551,48 @@ function latestReview(reviews: ReviewRun[]): ReviewRun | undefined {
511551
return [...reviews].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0];
512552
}
513553

514-
function ReviewerCard({
515-
harness,
516-
review,
517-
handleId,
518-
isTriggering,
519-
onTrigger,
520-
onOpenTerminal,
521-
}: {
522-
harness: string;
523-
review?: ReviewRun;
524-
handleId: string;
525-
isTriggering: boolean;
526-
onTrigger: () => void;
527-
onOpenTerminal?: OpenReviewerTerminal;
528-
}) {
529-
const status = reviewStatus(review);
530-
const terminalEnabled = Boolean(handleId && onOpenTerminal);
531-
const runLabel = review ? "Re-run review" : "Run review";
532-
554+
function ReviewItemCard({ item }: { item: PRReviewItem }) {
555+
const status = reviewItemStatus(item);
533556
return (
534-
<div className={cn("reviewer-card", status.tone && `reviewer-card--${status.tone}`)}>
557+
<div className={cn("reviewer-card", status.tone && `reviewer-card--${status.tone}`, item.status === "ineligible" && "opacity-70")}>
535558
<div className="reviewer-card__top">
536559
<div className="reviewer-card__name">
537-
<Shield aria-hidden="true" />
538-
<span>{harness}</span>
560+
<GitPullRequest aria-hidden="true" />
561+
<span>PR #{item.prNumber}</span>
539562
</div>
540563
<span className={cn("reviewer-status", `reviewer-status--${status.tone}`)}>
541564
{status.icon}
542565
{status.label}
543566
</span>
544567
</div>
545-
<div className="reviewer-card__actions">
546-
<button
547-
className="reviewer-card__action reviewer-card__action--primary"
548-
disabled={isTriggering}
549-
onClick={onTrigger}
550-
type="button"
551-
>
552-
<Play aria-hidden="true" />
553-
{isTriggering ? "Starting..." : runLabel}
554-
</button>
555-
{review ? (
556-
<button
557-
className="reviewer-card__action"
558-
disabled={!terminalEnabled}
559-
onClick={() => {
560-
if (!terminalEnabled) return;
561-
onOpenTerminal?.({ handleId, harness });
562-
}}
563-
type="button"
564-
>
565-
<Terminal aria-hidden="true" />
566-
Open terminal
567-
</button>
568-
) : null}
568+
<div className="mt-2 min-w-0 truncate font-mono text-[10.5px] text-passive">
569+
<a href={item.prUrl} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
570+
{item.prUrl}
571+
</a>
572+
{item.targetSha ? <span className="ml-2">{item.targetSha.slice(0, 7)}</span> : null}
569573
</div>
570574
</div>
571575
);
572576
}
573577

574-
function reviewStatus(review?: ReviewRun): {
578+
function reviewItemStatus(item: PRReviewItem): {
575579
label: string;
576580
tone: "neutral" | "running" | "success" | "danger";
577581
icon: ReactNode;
578582
} {
579-
if (!review) return { label: "Not run", tone: "neutral", icon: null };
580-
if (review.status === "running") {
581-
return { label: "Running", tone: "running", icon: <Play aria-hidden="true" /> };
582-
}
583-
if (review.status === "failed") {
584-
return { label: "Failed", tone: "danger", icon: <AlertCircle aria-hidden="true" /> };
585-
}
586-
if (review.verdict === "approved") {
587-
return { label: "Approved", tone: "success", icon: <CheckCircle2 aria-hidden="true" /> };
588-
}
589-
if (review.verdict === "changes_requested") {
590-
return { label: "Changes requested", tone: "danger", icon: <CircleMinus aria-hidden="true" /> };
583+
switch (item.status) {
584+
case "needs_review":
585+
return { label: "Needs review", tone: "neutral", icon: null };
586+
case "running":
587+
return { label: "Running", tone: "running", icon: <Play aria-hidden="true" /> };
588+
case "up_to_date":
589+
return { label: "Up to date", tone: "success", icon: <CheckCircle2 aria-hidden="true" /> };
590+
case "changes_requested":
591+
return { label: "Changes requested", tone: "danger", icon: <CircleMinus aria-hidden="true" /> };
592+
case "ineligible":
593+
return { label: "Ineligible", tone: "neutral", icon: <AlertCircle aria-hidden="true" /> };
591594
}
592-
return { label: "Complete", tone: "success", icon: <CheckCircle2 aria-hidden="true" /> };
595+
return { label: item.status, tone: "neutral", icon: null };
593596
}
594597

595598
function BrowserView({

0 commit comments

Comments
 (0)