Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions frontend/src/renderer/components/CenterPane.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,42 @@
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";

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 (
<div className="flex h-full min-h-0 min-w-0 flex-col bg-background">
{target.kind === "reviewer" ? (
<div className="reviewer-terminal-header">
<button
aria-label="Back to agent terminal"
className="reviewer-terminal-header__back"
onClick={onSelectWorkerTerminal}
type="button"
>
<ChevronLeft aria-hidden="true" />
<span>agent</span>
</button>
<span className="reviewer-terminal-header__role">
<Shield aria-hidden="true" />
Reviewer
</span>
<span className="reviewer-terminal-header__harness">{target.harness}</span>
</div>
) : null}
<div className="min-h-0 flex-1">
<TerminalPane session={session} theme={theme} daemonReady={daemonReady} />
<TerminalPane daemonReady={daemonReady} session={session} terminalTarget={target} theme={theme} />
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ describe("ProjectSettingsForm", () => {
model: "claude-opus-4-5",
permissions: "auto",
},
reviewers: [{ harness: "claude-code" }],
},
},
},
Expand All @@ -93,9 +94,11 @@ describe("ProjectSettingsForm", () => {
const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" });
const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" });
const permissionMode = screen.getByRole("combobox", { name: "Permission mode" });
const reviewerAgent = screen.getByRole("combobox", { name: "Default reviewer agent" });
expect(workerAgent).toHaveTextContent("codex");
expect(orchestratorAgent).toHaveTextContent("claude-code");
expect(permissionMode).toHaveTextContent("Auto");
expect(reviewerAgent).toHaveTextContent("claude-code");

await userEvent.clear(screen.getByLabelText("Default branch"));
await userEvent.type(screen.getByLabelText("Default branch"), "release");
Expand Down Expand Up @@ -128,11 +131,12 @@ describe("ProjectSettingsForm", () => {
model: "gpt-5-codex",
permissions: "bypass-permissions",
},
reviewers: [{ harness: "claude-code" }],
},
},
});
expect(await screen.findByText("Saved.")).toBeInTheDocument();
});
}, 10_000);

it("shows the daemon validation message when save fails", async () => {
getMock.mockResolvedValue({
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/renderer/components/ProjectSettingsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const PERMISSION_MODE_OPTIONS = [
{ value: "bypass-permissions", label: "Bypass permissions" },
] as const;

const REVIEWER_OPTIONS = ["claude-code"] as const;

const projectQueryKey = (id: string) => ["project", id] as const;

export function ProjectSettingsForm({ projectId }: { projectId: string }) {
Expand Down Expand Up @@ -73,6 +75,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
orchestratorAgent: config.orchestrator?.agent ?? "",
model: config.agentConfig?.model ?? "",
permissions: config.agentConfig?.permissions ?? "",
reviewerHarness: config.reviewers?.[0]?.harness ?? "",
});
const [savedAt, setSavedAt] = useState<number | null>(null);

Expand All @@ -91,6 +94,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
model: form.model || undefined,
permissions: form.permissions || undefined,
}),
reviewers: form.reviewerHarness ? [{ harness: form.reviewerHarness }] : undefined,
};
const { error } = await apiClient.PUT("/api/v1/projects/{id}/config", {
params: { path: { id: projectId } },
Expand Down Expand Up @@ -188,6 +192,21 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle className="text-[13px]">Reviewers</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field label="Default reviewer agent" htmlFor="reviewerHarness">
<ReviewerSelect
id="reviewerHarness"
value={form.reviewerHarness}
onChange={(v) => setForm((f) => ({ ...f, reviewerHarness: v }))}
/>
</Field>
</CardContent>
</Card>

<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={mutation.isPending}>
{mutation.isPending ? "Saving…" : "Save changes"}
Expand Down Expand Up @@ -250,6 +269,24 @@ function AgentSelect({ id, value, onChange }: { id: string; value: string; onCha
);
}

function ReviewerSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
return (
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
<SelectTrigger id={id} className="h-8 w-full text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Project default</SelectItem>
{REVIEWER_OPTIONS.map((reviewer) => (
<SelectItem key={reviewer} value={reviewer}>
{reviewer}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
Expand Down
150 changes: 144 additions & 6 deletions frontend/src/renderer/components/SessionInspector.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkspaceSession } from "../types/workspace";
import { SessionInspector } from "./SessionInspector";

const { getMock, postMock } = vi.hoisted(() => ({
getMock: vi.fn(),
Expand All @@ -14,17 +16,15 @@ vi.mock("../lib/api-client", () => ({
GET: getMock,
POST: postMock,
},
apiErrorMessage: (error: unknown) => {
apiErrorMessage: (error: unknown, fallback = "Request failed") => {
if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error) {
return String((error as { message: unknown }).message);
}
return "Request failed";
return fallback;
},
}));

import { SessionInspector } from "./SessionInspector";

const worker: WorkspaceSession = {
id: "sess-1",
workspaceId: "proj-1",
Expand All @@ -37,7 +37,21 @@ const worker: WorkspaceSession = {
updatedAt: "2026-06-10T00:00:00Z",
};

function renderInspector(session: WorkspaceSession = worker) {
const reviewSession = {
...worker,
terminalHandleId: "worker-pane",
title: "review me",
provider: "codex",
branch: "session/sess-1",
createdAt: "2026-06-16T10:00:00Z",
updatedAt: "2026-06-16T10:05:00Z",
pullRequest: { number: 3, state: "open" },
} satisfies WorkspaceSession;

function renderInspector(
session: WorkspaceSession = worker,
onOpenReviewerTerminal?: Parameters<typeof SessionInspector>[0]["onOpenReviewerTerminal"],
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
Expand All @@ -46,19 +60,143 @@ function renderInspector(session: WorkspaceSession = worker) {
});
render(
<QueryClientProvider client={queryClient}>
<SessionInspector session={session} />
<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} />
</QueryClientProvider>,
);
return queryClient;
}

function renderWithQuery(children: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(<QueryClientProvider client={client}>{children}</QueryClientProvider>);
}

function mockCommonGets(reviews: unknown[] = [], reviewerHandleId = "") {
getMock.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 };
});
}

const approvedReview = {
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",
};

beforeEach(() => {
getMock.mockReset();
postMock.mockReset();
getMock.mockResolvedValue({ data: { prs: [] }, error: undefined });
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
});

describe("SessionInspector reviews", () => {
it("triggers a review and opens the returned reviewer terminal", async () => {
mockCommonGets();
postMock.mockResolvedValue({
response: { status: 201 },
data: {
reviewerHandleId: "reviewer-pane",
review: {
...approvedReview,
status: "running",
verdict: "",
body: "",
},
},
});
const onOpenReviewerTerminal = vi.fn();

renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);

await userEvent.click(await screen.findByRole("button", { name: /run review/i }));

await waitFor(() =>
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/reviews/trigger", {
params: { path: { sessionId: "sess-1" } },
}),
);
expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" });
});

it("shows an up-to-date notice instead of opening the terminal when the backend reuses a run", async () => {
mockCommonGets([approvedReview], "reviewer-pane");
postMock.mockResolvedValue({
response: { status: 200 },
data: {
reviewerHandleId: "reviewer-pane",
review: approvedReview,
},
});
const onOpenReviewerTerminal = vi.fn();

renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);

await userEvent.click(await screen.findByRole("button", { name: /re-run review/i }));

expect(await screen.findByText("Review is already up to date for this commit.")).toBeInTheDocument();
expect(onOpenReviewerTerminal).not.toHaveBeenCalled();
});

it("shows an approved review and opens its terminal", async () => {
mockCommonGets([approvedReview], "reviewer-pane");
const onOpenReviewerTerminal = vi.fn();

renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);

await waitFor(() => expect(screen.getAllByText("Approved").length).toBeGreaterThan(0));
await userEvent.click(screen.getByRole("button", { name: /open terminal/i }));

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

describe("SessionInspector kill button", () => {
it("arms a confirmation before killing an active session", async () => {
renderInspector();
Expand Down
Loading