From 6d81019d3b66d8e444e5263b731cfbde8f5abeb4 Mon Sep 17 00:00:00 2001 From: Vaibhaav Date: Tue, 16 Jun 2026 20:08:21 +0530 Subject: [PATCH 1/4] feat(frontend): show reviewer worker controls --- .../src/renderer/components/CenterPane.tsx | 28 +- .../components/SessionInspector.test.tsx | 150 ++++++++++ .../renderer/components/SessionInspector.tsx | 270 +++++++++++++++++- .../src/renderer/components/SessionView.tsx | 23 +- .../src/renderer/components/TerminalPane.tsx | 19 +- frontend/src/renderer/styles.css | 245 ++++++++++++++++ frontend/src/renderer/types/terminal.ts | 7 + 7 files changed, 722 insertions(+), 20 deletions(-) create mode 100644 frontend/src/renderer/components/SessionInspector.test.tsx create mode 100644 frontend/src/renderer/types/terminal.ts diff --git a/frontend/src/renderer/components/CenterPane.tsx b/frontend/src/renderer/components/CenterPane.tsx index 96c058e4..613ca2dd 100644 --- a/frontend/src/renderer/components/CenterPane.tsx +++ b/frontend/src/renderer/components/CenterPane.tsx @@ -1,4 +1,6 @@ +import { ChevronLeft, Shield } from "lucide-react"; import type { Theme } from "../stores/ui-store"; +import type { TerminalTarget } from "../types/terminal"; import type { WorkspaceSession } from "../types/workspace"; import { TerminalPane } from "./TerminalPane"; @@ -6,13 +8,35 @@ type CenterPaneProps = { session?: WorkspaceSession; theme: Theme; daemonReady: boolean; + terminalTarget?: TerminalTarget; + onSelectWorkerTerminal?: () => void; }; -export function CenterPane({ session, theme, daemonReady }: CenterPaneProps) { +export function CenterPane({ session, theme, daemonReady, terminalTarget, onSelectWorkerTerminal }: CenterPaneProps) { + const target = terminalTarget ?? { kind: "worker" }; + return (
+ {target.kind === "reviewer" ? ( +
+ + + + {target.harness} +
+ ) : null}
- +
); diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx new file mode 100644 index 00000000..5a717a44 --- /dev/null +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -0,0 +1,150 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionInspector } from "./SessionInspector"; +import type { WorkspaceSession } from "../types/workspace"; + +const api = vi.hoisted(() => ({ + GET: vi.fn(), + POST: vi.fn(), +})); + +vi.mock("../lib/api-client", () => ({ + apiClient: api, + apiErrorMessage: (error: unknown, fallback = "Request failed") => + error instanceof Error ? error.message : fallback, +})); + +const session = { + id: "sess-1", + terminalHandleId: "worker-pane", + workspaceId: "proj-1", + workspaceName: "my-app", + title: "review me", + provider: "codex", + kind: "worker", + branch: "session/sess-1", + status: "working", + createdAt: "2026-06-16T10:00:00Z", + updatedAt: "2026-06-16T10:05:00Z", + pullRequest: { number: 3, state: "open" }, +} satisfies WorkspaceSession; + +function renderWithQuery(children: ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render({children}); +} + +function mockCommonGets(reviews: unknown[] = [], reviewerHandleId = "") { + api.GET.mockImplementation(async (path: string) => { + if (path === "/api/v1/sessions/{sessionId}/pr") { + return { + data: { + prs: [ + { + url: "https://github.com/aoagents/reverbcode/pull/3", + number: 3, + state: "open", + ci: "passing", + review: "required", + mergeability: "mergeable", + reviewComments: false, + updatedAt: "2026-06-16T10:05:00Z", + }, + ], + }, + }; + } + if (path === "/api/v1/sessions/{sessionId}/reviews") { + return { data: { reviewerHandleId, reviews } }; + } + if (path === "/api/v1/projects/{id}") { + return { + data: { + status: "ok", + project: { + id: "proj-1", + kind: "git", + name: "my-app", + path: "/repo", + repo: "my-app", + defaultBranch: "main", + config: { reviewers: [{ harness: "codex" }] }, + }, + }, + }; + } + return { data: undefined }; + }); +} + +describe("SessionInspector reviews", () => { + beforeEach(() => { + api.GET.mockReset(); + api.POST.mockReset(); + }); + + it("triggers a review and opens the returned reviewer terminal", async () => { + mockCommonGets(); + api.POST.mockResolvedValue({ + data: { + reviewerHandleId: "reviewer-pane", + review: { + id: "run-1", + reviewId: "review-1", + sessionId: "sess-1", + harness: "codex", + status: "running", + verdict: "", + body: "", + prUrl: "https://github.com/aoagents/reverbcode/pull/3", + targetSha: "abc123", + createdAt: "2026-06-16T10:06:00Z", + }, + }, + }); + const onOpenReviewerTerminal = vi.fn(); + + renderWithQuery(); + + fireEvent.click(await screen.findByRole("button", { name: /run review/i })); + + await waitFor(() => + expect(api.POST).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/reviews/trigger", { + params: { path: { sessionId: "sess-1" } }, + }), + ); + expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" }); + }); + + it("shows an approved review and opens its terminal", async () => { + mockCommonGets( + [ + { + id: "run-1", + reviewId: "review-1", + sessionId: "sess-1", + harness: "codex", + status: "complete", + verdict: "approved", + body: "Looks good.", + prUrl: "https://github.com/aoagents/reverbcode/pull/3", + targetSha: "abc123", + createdAt: "2026-06-16T10:06:00Z", + }, + ], + "reviewer-pane", + ); + const onOpenReviewerTerminal = vi.fn(); + + renderWithQuery(); + + await waitFor(() => expect(screen.getAllByText("Approved").length).toBeGreaterThan(0)); + fireEvent.click(screen.getByRole("button", { name: /open terminal/i })); + + expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" }); + }); +}); diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index dee4cf69..baabc562 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -1,18 +1,37 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState, type ReactNode } from "react"; -import { GitBranch, GitCommitHorizontal, GitPullRequest, Plus, Square, Trash2 } from "lucide-react"; +import { + AlertCircle, + CheckCircle2, + CircleMinus, + GitBranch, + GitCommitHorizontal, + GitPullRequest, + Play, + Plus, + Shield, + Square, + Terminal, + Trash2, +} from "lucide-react"; import type { components } from "../../api/schema"; -import { apiClient } from "../lib/api-client"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; import { formatTimeCompact } from "../lib/format-time"; import type { SessionStatus, WorkspaceSession } from "../types/workspace"; import { workerDisplayStatus } from "../types/workspace"; +import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { cn } from "../lib/utils"; type PRFacts = components["schemas"]["SessionPRFacts"]; +type ProjectConfig = components["schemas"]["ProjectConfig"]; +type ReviewRun = components["schemas"]["ReviewRun"]; +type ReviewsResponse = components["schemas"]["ListReviewsResponse"]; type InspectorView = "summary" | "changes" | "browser"; +type OpenReviewerTerminal = (target: { handleId: string; harness: string }) => void; + const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [ { id: "summary", @@ -65,7 +84,13 @@ const prStateTone: Record = { * Tabbed inspector rail beside the terminal — cloned from agent-orchestrator * SessionInspector (Summary · Changes · Browser). */ -export function SessionInspector({ session }: { session?: WorkspaceSession }) { +export function SessionInspector({ + session, + onOpenReviewerTerminal, +}: { + session?: WorkspaceSession; + onOpenReviewerTerminal?: OpenReviewerTerminal; +}) { const [view, setView] = useState("summary"); if (!session) { @@ -97,7 +122,9 @@ export function SessionInspector({ session }: { session?: WorkspaceSession }) {
- {view === "summary" ? : null} + {view === "summary" ? ( + + ) : null} {view === "changes" ? : null} {view === "browser" ? : null}
@@ -117,8 +144,15 @@ function Section({ title, action, children }: { title: string; action?: ReactNod ); } -function SummaryView({ session }: { session: WorkspaceSession }) { +function SummaryView({ + session, + onOpenReviewerTerminal, +}: { + session: WorkspaceSession; + onOpenReviewerTerminal?: OpenReviewerTerminal; +}) { const hasPr = Boolean(session.pullRequest); + const queryClient = useQueryClient(); const query = useQuery({ queryKey: ["session-pr", session.id], enabled: hasPr, @@ -130,8 +164,52 @@ function SummaryView({ session }: { session: WorkspaceSession }) { return data?.prs ?? []; }, }); + const reviewsQuery = useQuery({ + queryKey: ["session-reviews", session.id], + enabled: hasPr, + refetchInterval: (query) => { + const data = query.state.data as ReviewsResponse | undefined; + return data?.reviews.some((review) => review.status === "running") ? 2500 : false; + }, + queryFn: async () => { + const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/reviews", { + params: { path: { sessionId: session.id } }, + }); + if (error) throw new Error(apiErrorMessage(error, "Unable to load reviews")); + return data ?? ({ reviewerHandleId: "", reviews: [] } satisfies ReviewsResponse); + }, + }); + const projectConfigQuery = useQuery({ + queryKey: ["project-config", session.workspaceId], + enabled: hasPr, + queryFn: async () => { + const { data, error } = await apiClient.GET("/api/v1/projects/{id}", { + params: { path: { id: session.workspaceId } }, + }); + if (error) return undefined; + return projectConfig(data?.project); + }, + }); + const triggerReview = useMutation({ + mutationFn: async () => { + const { data, error } = await apiClient.POST("/api/v1/sessions/{sessionId}/reviews/trigger", { + params: { path: { sessionId: session.id } }, + }); + if (error) throw new Error(apiErrorMessage(error, "Unable to start review")); + return data; + }, + onSuccess: (data) => { + void queryClient.invalidateQueries({ queryKey: ["session-reviews", session.id] }); + void queryClient.invalidateQueries({ queryKey: ["session-pr", session.id] }); + void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + if (data?.reviewerHandleId) { + onOpenReviewerTerminal?.({ handleId: data.reviewerHandleId, harness: data.review.harness || "reviewer" }); + } + }, + }); const prFacts = query.data?.[0]; const branchLabel = session.branch || `session/${session.id}`; + const reviews = reviewsQuery.data?.reviews ?? []; return (
@@ -178,8 +256,22 @@ function SummaryView({ session }: { session: WorkspaceSession }) { )} +
+ triggerReview.mutate()} + reviewerHandleId={reviewsQuery.data?.reviewerHandleId ?? ""} + reviews={reviews} + session={session} + /> +
+
- +
@@ -194,9 +286,141 @@ function SummaryView({ session }: { session: WorkspaceSession }) { ); } -type TimelineTone = "now" | "good" | "warn" | "neutral"; +function projectConfig(project: components["schemas"]["ProjectOrDegraded"] | undefined): ProjectConfig | undefined { + if (!project || !("config" in project)) return undefined; + return project.config; +} + +function ReviewPanel({ + session, + config, + reviews, + reviewerHandleId, + isLoading, + isTriggering, + error, + onTrigger, + onOpenTerminal, +}: { + session: WorkspaceSession; + config?: ProjectConfig; + reviews: ReviewRun[]; + reviewerHandleId: string; + isLoading: boolean; + isTriggering: boolean; + error: unknown; + onTrigger: () => void; + onOpenTerminal?: OpenReviewerTerminal; +}) { + if (!session.pullRequest) { + return

No pull request opened yet.

; + } + if (isLoading) { + return

Loading reviews...

; + } + + const latest = latestReview(reviews); + const harness = latest?.harness || config?.reviewers?.[0]?.harness || session.provider || "reviewer"; + + return ( +
+ {error ?

{apiErrorMessage(error, "Review request failed")}

: null} + +
+ ); +} + +function latestReview(reviews: ReviewRun[]): ReviewRun | undefined { + return [...reviews].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; +} + +function ReviewerCard({ + harness, + review, + handleId, + isTriggering, + onTrigger, + onOpenTerminal, +}: { + harness: string; + review?: ReviewRun; + handleId: string; + isTriggering: boolean; + onTrigger: () => void; + onOpenTerminal?: OpenReviewerTerminal; +}) { + const status = reviewStatus(review); + const terminalEnabled = Boolean(handleId && onOpenTerminal); + const runLabel = review ? "Re-run review" : "Run review"; -function ActivityTimeline({ session }: { session: WorkspaceSession }) { + return ( +
+
+
+
+ + {status.icon} + {status.label} + +
+
+ + {review ? ( + + ) : null} +
+
+ ); +} + +function reviewStatus(review?: ReviewRun): { label: string; tone: "neutral" | "running" | "success" | "danger"; icon: ReactNode } { + if (!review) return { label: "Not run", tone: "neutral", icon: null }; + if (review.status === "running") { + return { label: "Running", tone: "running", icon:
- {view === "summary" ? ( - - ) : null} + {view === "summary" ? : null} {view === "changes" ? : null} {view === "browser" ? : null}
@@ -401,7 +399,11 @@ function ReviewerCard({ ); } -function reviewStatus(review?: ReviewRun): { label: string; tone: "neutral" | "running" | "success" | "danger"; icon: ReactNode } { +function reviewStatus(review?: ReviewRun): { + label: string; + tone: "neutral" | "running" | "success" | "danger"; + icon: ReactNode; +} { if (!review) return { label: "Not run", tone: "neutral", icon: null }; if (review.status === "running") { return { label: "Running", tone: "running", icon: