feat(frontend): show reviewer worker controls (#255)

* feat(frontend): show reviewer worker controls

* chore: format with prettier [skip ci]

* fix(frontend): surface reviewer config and reused reviews

* chore: format with prettier [skip ci]

* chore: format with prettier [skip ci]

---------

Co-authored-by: Vaibhaav <user@example.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Vaibhaav Tiwari 2026-06-17 21:46:09 +05:30 committed by GitHub
parent 4bbcf9e925
commit ee044e4edb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 780 additions and 25 deletions

View File

@ -1,4 +1,6 @@
import { ChevronLeft, Shield } from "lucide-react";
import type { Theme } from "../stores/ui-store"; import type { Theme } from "../stores/ui-store";
import type { TerminalTarget } from "../types/terminal";
import type { WorkspaceSession } from "../types/workspace"; import type { WorkspaceSession } from "../types/workspace";
import { TerminalPane } from "./TerminalPane"; import { TerminalPane } from "./TerminalPane";
@ -6,13 +8,35 @@ type CenterPaneProps = {
session?: WorkspaceSession; session?: WorkspaceSession;
theme: Theme; theme: Theme;
daemonReady: boolean; 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 ( return (
<div className="flex h-full min-h-0 min-w-0 flex-col bg-background"> <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"> <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>
</div> </div>
); );

View File

@ -77,6 +77,7 @@ describe("ProjectSettingsForm", () => {
model: "claude-opus-4-5", model: "claude-opus-4-5",
permissions: "auto", permissions: "auto",
}, },
reviewers: [{ harness: "claude-code" }],
}, },
}, },
}, },
@ -93,9 +94,11 @@ describe("ProjectSettingsForm", () => {
const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" }); const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" });
const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" }); const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" });
const permissionMode = screen.getByRole("combobox", { name: "Permission mode" }); const permissionMode = screen.getByRole("combobox", { name: "Permission mode" });
const reviewerAgent = screen.getByRole("combobox", { name: "Default reviewer agent" });
expect(workerAgent).toHaveTextContent("codex"); expect(workerAgent).toHaveTextContent("codex");
expect(orchestratorAgent).toHaveTextContent("claude-code"); expect(orchestratorAgent).toHaveTextContent("claude-code");
expect(permissionMode).toHaveTextContent("Auto"); expect(permissionMode).toHaveTextContent("Auto");
expect(reviewerAgent).toHaveTextContent("claude-code");
await userEvent.clear(screen.getByLabelText("Default branch")); await userEvent.clear(screen.getByLabelText("Default branch"));
await userEvent.type(screen.getByLabelText("Default branch"), "release"); await userEvent.type(screen.getByLabelText("Default branch"), "release");
@ -128,11 +131,12 @@ describe("ProjectSettingsForm", () => {
model: "gpt-5-codex", model: "gpt-5-codex",
permissions: "bypass-permissions", permissions: "bypass-permissions",
}, },
reviewers: [{ harness: "claude-code" }],
}, },
}, },
}); });
expect(await screen.findByText("Saved.")).toBeInTheDocument(); expect(await screen.findByText("Saved.")).toBeInTheDocument();
}); }, 10_000);
it("shows the daemon validation message when save fails", async () => { it("shows the daemon validation message when save fails", async () => {
getMock.mockResolvedValue({ getMock.mockResolvedValue({

View File

@ -22,6 +22,8 @@ const PERMISSION_MODE_OPTIONS = [
{ value: "bypass-permissions", label: "Bypass permissions" }, { value: "bypass-permissions", label: "Bypass permissions" },
] as const; ] as const;
const REVIEWER_OPTIONS = ["claude-code"] as const;
const projectQueryKey = (id: string) => ["project", id] as const; const projectQueryKey = (id: string) => ["project", id] as const;
export function ProjectSettingsForm({ projectId }: { projectId: string }) { export function ProjectSettingsForm({ projectId }: { projectId: string }) {
@ -73,6 +75,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
orchestratorAgent: config.orchestrator?.agent ?? "", orchestratorAgent: config.orchestrator?.agent ?? "",
model: config.agentConfig?.model ?? "", model: config.agentConfig?.model ?? "",
permissions: config.agentConfig?.permissions ?? "", permissions: config.agentConfig?.permissions ?? "",
reviewerHarness: config.reviewers?.[0]?.harness ?? "",
}); });
const [savedAt, setSavedAt] = useState<number | null>(null); const [savedAt, setSavedAt] = useState<number | null>(null);
@ -91,6 +94,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
model: form.model || undefined, model: form.model || undefined,
permissions: form.permissions || undefined, permissions: form.permissions || undefined,
}), }),
reviewers: form.reviewerHarness ? [{ harness: form.reviewerHarness }] : undefined,
}; };
const { error } = await apiClient.PUT("/api/v1/projects/{id}/config", { const { error } = await apiClient.PUT("/api/v1/projects/{id}/config", {
params: { path: { id: projectId } }, params: { path: { id: projectId } },
@ -188,6 +192,21 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
</CardContent> </CardContent>
</Card> </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"> <div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={mutation.isPending}> <Button type="submit" variant="primary" disabled={mutation.isPending}>
{mutation.isPending ? "Saving…" : "Save changes"} {mutation.isPending ? "Saving…" : "Save changes"}
@ -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 }) { function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: React.ReactNode }) {
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">

View File

@ -1,8 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkspaceSession } from "../types/workspace"; import type { WorkspaceSession } from "../types/workspace";
import { SessionInspector } from "./SessionInspector";
const { getMock, postMock } = vi.hoisted(() => ({ const { getMock, postMock } = vi.hoisted(() => ({
getMock: vi.fn(), getMock: vi.fn(),
@ -14,17 +16,15 @@ vi.mock("../lib/api-client", () => ({
GET: getMock, GET: getMock,
POST: postMock, POST: postMock,
}, },
apiErrorMessage: (error: unknown) => { apiErrorMessage: (error: unknown, fallback = "Request failed") => {
if (error instanceof Error) return error.message; if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error) { if (typeof error === "object" && error !== null && "message" in error) {
return String((error as { message: unknown }).message); return String((error as { message: unknown }).message);
} }
return "Request failed"; return fallback;
}, },
})); }));
import { SessionInspector } from "./SessionInspector";
const worker: WorkspaceSession = { const worker: WorkspaceSession = {
id: "sess-1", id: "sess-1",
workspaceId: "proj-1", workspaceId: "proj-1",
@ -37,7 +37,21 @@ const worker: WorkspaceSession = {
updatedAt: "2026-06-10T00:00:00Z", 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({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: false }, queries: { retry: false },
@ -46,12 +60,75 @@ function renderInspector(session: WorkspaceSession = worker) {
}); });
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<SessionInspector session={session} /> <SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} />
</QueryClientProvider>, </QueryClientProvider>,
); );
return queryClient; 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(() => { beforeEach(() => {
getMock.mockReset(); getMock.mockReset();
postMock.mockReset(); postMock.mockReset();
@ -59,6 +136,67 @@ beforeEach(() => {
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, 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", () => { describe("SessionInspector kill button", () => {
it("arms a confirmation before killing an active session", async () => { it("arms a confirmation before killing an active session", async () => {
renderInspector(); renderInspector();

View File

@ -1,6 +1,19 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type ReactNode } from "react"; 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 type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client"; import { apiClient, apiErrorMessage } from "../lib/api-client";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
@ -12,8 +25,13 @@ import { Button } from "./ui/button";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
type PRFacts = components["schemas"]["SessionPRFacts"]; 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 InspectorView = "summary" | "changes" | "browser";
type OpenReviewerTerminal = (target: { handleId: string; harness: string }) => void;
const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
{ {
id: "summary", id: "summary",
@ -66,7 +84,13 @@ const prStateTone: Record<PRFacts["state"], string> = {
* Tabbed inspector rail beside the terminal cloned from agent-orchestrator * Tabbed inspector rail beside the terminal cloned from agent-orchestrator
* SessionInspector (Summary · Changes · Browser). * SessionInspector (Summary · Changes · Browser).
*/ */
export function SessionInspector({ session }: { session?: WorkspaceSession }) { export function SessionInspector({
session,
onOpenReviewerTerminal,
}: {
session?: WorkspaceSession;
onOpenReviewerTerminal?: OpenReviewerTerminal;
}) {
const [view, setView] = useState<InspectorView>("summary"); const [view, setView] = useState<InspectorView>("summary");
if (!session) { if (!session) {
@ -98,7 +122,7 @@ export function SessionInspector({ session }: { session?: WorkspaceSession }) {
</div> </div>
<div className="session-inspector__body"> <div className="session-inspector__body">
{view === "summary" ? <SummaryView session={session} /> : null} {view === "summary" ? <SummaryView onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} /> : null}
{view === "changes" ? <ChangesView session={session} /> : null} {view === "changes" ? <ChangesView session={session} /> : null}
{view === "browser" ? <BrowserView /> : null} {view === "browser" ? <BrowserView /> : null}
</div> </div>
@ -118,8 +142,16 @@ 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 hasPr = Boolean(session.pullRequest);
const queryClient = useQueryClient();
const [reviewNotice, setReviewNotice] = useState<string | null>(null);
const query = useQuery({ const query = useQuery({
queryKey: ["session-pr", session.id], queryKey: ["session-pr", session.id],
enabled: hasPr, enabled: hasPr,
@ -131,8 +163,59 @@ function SummaryView({ session }: { session: WorkspaceSession }) {
return data?.prs ?? []; 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, response } = 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, reused: response?.status === 200 };
},
onMutate: () => {
setReviewNotice(null);
},
onSuccess: ({ data, reused }) => {
void queryClient.invalidateQueries({ queryKey: ["session-reviews", session.id] });
void queryClient.invalidateQueries({ queryKey: ["session-pr", session.id] });
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
if (reused) {
setReviewNotice("Review is already up to date for this commit.");
return;
}
if (data?.reviewerHandleId) {
onOpenReviewerTerminal?.({ handleId: data.reviewerHandleId, harness: data.review.harness || "reviewer" });
}
},
});
const prFacts = query.data?.[0]; const prFacts = query.data?.[0];
const branchLabel = session.branch || `session/${session.id}`; const branchLabel = session.branch || `session/${session.id}`;
const reviews = reviewsQuery.data?.reviews ?? [];
return ( return (
<div role="tabpanel"> <div role="tabpanel">
@ -179,8 +262,23 @@ function SummaryView({ session }: { session: WorkspaceSession }) {
)} )}
</Section> </Section>
<Section title="Reviews">
<ReviewPanel
config={projectConfigQuery.data}
error={reviewsQuery.error ?? triggerReview.error}
isLoading={reviewsQuery.isLoading}
isTriggering={triggerReview.isPending}
onOpenTerminal={onOpenReviewerTerminal}
onTrigger={() => triggerReview.mutate()}
reviewerHandleId={reviewsQuery.data?.reviewerHandleId ?? ""}
reviews={reviews}
notice={reviewNotice}
session={session}
/>
</Section>
<Section title="Activity"> <Section title="Activity">
<ActivityTimeline session={session} /> <ActivityTimeline reviews={reviews} session={session} />
</Section> </Section>
<Section title="Overview"> <Section title="Overview">
@ -259,9 +357,148 @@ function KillSessionButton({ 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 ActivityTimeline({ session }: { session: WorkspaceSession }) { function ReviewPanel({
session,
config,
reviews,
reviewerHandleId,
isLoading,
isTriggering,
error,
notice,
onTrigger,
onOpenTerminal,
}: {
session: WorkspaceSession;
config?: ProjectConfig;
reviews: ReviewRun[];
reviewerHandleId: string;
isLoading: boolean;
isTriggering: boolean;
error: unknown;
notice: string | null;
onTrigger: () => void;
onOpenTerminal?: OpenReviewerTerminal;
}) {
if (!session.pullRequest) {
return <p className="inspector-empty">No pull request opened yet.</p>;
}
if (isLoading) {
return <p className="inspector-empty">Loading reviews...</p>;
}
const latest = latestReview(reviews);
const harness = latest?.harness || config?.reviewers?.[0]?.harness || session.provider || "reviewer";
return (
<div className="reviewer-list">
{error ? <p className="reviewer-error">{apiErrorMessage(error, "Review request failed")}</p> : null}
{notice ? <p className="reviewer-notice">{notice}</p> : null}
<ReviewerCard
handleId={reviewerHandleId}
harness={harness}
isTriggering={isTriggering}
onOpenTerminal={onOpenTerminal}
onTrigger={onTrigger}
review={latest}
/>
</div>
);
}
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";
return (
<div className={cn("reviewer-card", status.tone && `reviewer-card--${status.tone}`)}>
<div className="reviewer-card__top">
<div className="reviewer-card__name">
<Shield aria-hidden="true" />
<span>{harness}</span>
</div>
<span className={cn("reviewer-status", `reviewer-status--${status.tone}`)}>
{status.icon}
{status.label}
</span>
</div>
<div className="reviewer-card__actions">
<button
className="reviewer-card__action reviewer-card__action--primary"
disabled={isTriggering}
onClick={onTrigger}
type="button"
>
<Play aria-hidden="true" />
{isTriggering ? "Starting..." : runLabel}
</button>
{review ? (
<button
className="reviewer-card__action"
disabled={!terminalEnabled}
onClick={() => {
if (!terminalEnabled) return;
onOpenTerminal?.({ handleId, harness });
}}
type="button"
>
<Terminal aria-hidden="true" />
Open terminal
</button>
) : null}
</div>
</div>
);
}
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: <Play aria-hidden="true" /> };
}
if (review.status === "failed") {
return { label: "Failed", tone: "danger", icon: <AlertCircle aria-hidden="true" /> };
}
if (review.verdict === "approved") {
return { label: "Approved", tone: "success", icon: <CheckCircle2 aria-hidden="true" /> };
}
if (review.verdict === "changes_requested") {
return { label: "Changes requested", tone: "danger", icon: <CircleMinus aria-hidden="true" /> };
}
return { label: "Complete", tone: "success", icon: <CheckCircle2 aria-hidden="true" /> };
}
type TimelineTone = "now" | "good" | "warn" | "bad" | "neutral";
function ActivityTimeline({ session, reviews }: { session: WorkspaceSession; reviews: ReviewRun[] }) {
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = []; const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
const detail = activityDetail(session.status); const detail = activityDetail(session.status);
@ -290,6 +527,18 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) {
}); });
} }
for (const review of reviews.slice(0, 4)) {
events.push({
tone: reviewTimelineTone(review),
node: (
<>
{reviewTimelineLabel(review)} <span className="inspector-timeline__detail">- {review.harness}</span>
</>
),
ts: formatTimeCompact(review.createdAt),
});
}
events.push({ events.push({
tone: "neutral", tone: "neutral",
node: <>Created worktree &amp; branch</>, node: <>Created worktree &amp; branch</>,
@ -306,6 +555,7 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) {
event.tone === "now" && "inspector-timeline__ev--now", event.tone === "now" && "inspector-timeline__ev--now",
event.tone === "good" && "inspector-timeline__ev--good", event.tone === "good" && "inspector-timeline__ev--good",
event.tone === "warn" && "inspector-timeline__ev--warn", event.tone === "warn" && "inspector-timeline__ev--warn",
event.tone === "bad" && "inspector-timeline__ev--bad",
)} )}
> >
<span className="inspector-timeline__node" aria-hidden="true" /> <span className="inspector-timeline__node" aria-hidden="true" />
@ -317,6 +567,21 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) {
); );
} }
function reviewTimelineTone(review: ReviewRun): TimelineTone {
if (review.status === "failed" || review.verdict === "changes_requested") return "bad";
if (review.status === "running") return "warn";
if (review.verdict === "approved") return "good";
return "neutral";
}
function reviewTimelineLabel(review: ReviewRun): string {
if (review.status === "running") return "Review requested";
if (review.status === "failed") return "Review failed";
if (review.verdict === "approved") return "Approved";
if (review.verdict === "changes_requested") return "Changes requested";
return "Review complete";
}
function activityDetail(status: SessionStatus): string | null { function activityDetail(status: SessionStatus): string | null {
switch (status) { switch (status) {
case "idle": case "idle":

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { PanelImperativeHandle, PanelSize } from "react-resizable-panels"; import type { PanelImperativeHandle, PanelSize } from "react-resizable-panels";
import { CenterPane } from "./CenterPane"; import { CenterPane } from "./CenterPane";
import { SessionInspector } from "./SessionInspector"; import { SessionInspector } from "./SessionInspector";
@ -7,6 +7,7 @@ import { useUiStore } from "../stores/ui-store";
import { useShell } from "../lib/shell-context"; import { useShell } from "../lib/shell-context";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery"; import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { isOrchestratorSession } from "../types/workspace"; import { isOrchestratorSession } from "../types/workspace";
import type { TerminalTarget } from "../types/terminal";
const INSPECTOR_MIN_PERCENT = 22; const INSPECTOR_MIN_PERCENT = 22;
const INSPECTOR_MAX_PERCENT = 45; const INSPECTOR_MAX_PERCENT = 45;
@ -45,10 +46,15 @@ export function SessionView({ sessionId }: SessionViewProps) {
const { daemonStatus } = useShell(); const { daemonStatus } = useShell();
const inspectorRef = useRef<PanelImperativeHandle | null>(null); const inspectorRef = useRef<PanelImperativeHandle | null>(null);
const inspectorSeparatorRef = useRef<HTMLDivElement | null>(null); const inspectorSeparatorRef = useRef<HTMLDivElement | null>(null);
const [terminalTarget, setTerminalTarget] = useState<TerminalTarget>({ kind: "worker" });
const session = workspaces.flatMap((workspace) => workspace.sessions).find((s) => s.id === sessionId); const session = workspaces.flatMap((workspace) => workspace.sessions).find((s) => s.id === sessionId);
const isOrchestrator = session ? isOrchestratorSession(session) : false; const isOrchestrator = session ? isOrchestratorSession(session) : false;
useEffect(() => {
setTerminalTarget({ kind: "worker" });
}, [sessionId]);
// Orchestrator sessions are terminal-only; only worker sessions have the rail. // Orchestrator sessions are terminal-only; only worker sessions have the rail.
const hasInspector = !isOrchestrator; const hasInspector = !isOrchestrator;
// Computed when the inspector panel mounts and frozen while it stays // Computed when the inspector panel mounts and frozen while it stays
@ -148,7 +154,13 @@ export function SessionView({ sessionId }: SessionViewProps) {
{/* react-resizable-panels v4: bare numbers are PIXELS; percentages must {/* react-resizable-panels v4: bare numbers are PIXELS; percentages must
be strings. Numeric sizes here once clamped the inspector to 45px. */} be strings. Numeric sizes here once clamped the inspector to 45px. */}
<ResizablePanel defaultSize="72%" id="terminal" minSize="45%"> <ResizablePanel defaultSize="72%" id="terminal" minSize="45%">
<CenterPane daemonReady={daemonStatus.state === "ready"} session={session} theme={theme} /> <CenterPane
daemonReady={daemonStatus.state === "ready"}
onSelectWorkerTerminal={() => setTerminalTarget({ kind: "worker" })}
session={session}
terminalTarget={terminalTarget}
theme={theme}
/>
</ResizablePanel> </ResizablePanel>
{hasInspector ? ( {hasInspector ? (
<> <>
@ -171,7 +183,12 @@ export function SessionView({ sessionId }: SessionViewProps) {
{/* Stable content width while the panel animates (yyork pattern): {/* Stable content width while the panel animates (yyork pattern):
the pane clips instead of reflowing the inspector mid-collapse. */} the pane clips instead of reflowing the inspector mid-collapse. */}
<div className="h-full min-w-[280px]"> <div className="h-full min-w-[280px]">
<SessionInspector session={session} /> <SessionInspector
onOpenReviewerTerminal={({ handleId, harness }) =>
setTerminalTarget({ kind: "reviewer", handleId, harness })
}
session={session}
/>
</div> </div>
</ResizablePanel> </ResizablePanel>
</> </>

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { TerminalTarget } from "../types/terminal";
import type { WorkspaceSession } from "../types/workspace"; import type { WorkspaceSession } from "../types/workspace";
import type { Theme } from "../stores/ui-store"; import type { Theme } from "../stores/ui-store";
import { useTerminalSession, type AttachableTerminal, type TerminalSessionState } from "../hooks/useTerminalSession"; import { useTerminalSession, type AttachableTerminal, type TerminalSessionState } from "../hooks/useTerminalSession";
@ -8,14 +9,16 @@ type TerminalPaneProps = {
session?: WorkspaceSession; session?: WorkspaceSession;
theme: Theme; theme: Theme;
daemonReady: boolean; daemonReady: boolean;
terminalTarget?: TerminalTarget;
}; };
export function TerminalPane({ session, theme, daemonReady }: TerminalPaneProps) { export function TerminalPane({ session, theme, daemonReady, terminalTarget }: TerminalPaneProps) {
if (!window.ao) { if (!window.ao) {
const provider = terminalTarget?.kind === "reviewer" ? terminalTarget.harness : (session?.provider ?? "claude");
return ( return (
<pre className="h-full overflow-auto bg-terminal p-4 font-mono text-[13px] leading-relaxed text-[var(--term-fg)]"> <pre className="h-full overflow-auto bg-terminal p-4 font-mono text-[13px] leading-relaxed text-[var(--term-fg)]">
<span className="text-[var(--term-dim)]">~/{session?.workspaceName ?? "reverbcode"}</span>{" "} <span className="text-[var(--term-dim)]">~/{session?.workspaceName ?? "reverbcode"}</span>{" "}
<span className="text-[var(--term-blue)]">{session?.branch || "main"}</span> $ {session?.provider ?? "claude"} <span className="text-[var(--term-blue)]">{session?.branch || "main"}</span> $ {provider}
{"\n"} {"\n"}
<span className="text-[var(--term-green)]"> Welcome to the agent CLI</span> <span className="text-[var(--term-green)]"> Welcome to the agent CLI</span>
{"\n\n"} {"\n\n"}
@ -26,7 +29,7 @@ export function TerminalPane({ session, theme, daemonReady }: TerminalPaneProps)
); );
} }
return <AttachedTerminal session={session} theme={theme} daemonReady={daemonReady} />; return <AttachedTerminal session={session} theme={theme} daemonReady={daemonReady} terminalTarget={terminalTarget} />;
} }
function bannerText(state: TerminalSessionState, error?: string): string | undefined { function bannerText(state: TerminalSessionState, error?: string): string | undefined {
@ -35,15 +38,19 @@ function bannerText(state: TerminalSessionState, error?: string): string | undef
return undefined; return undefined;
} }
function AttachedTerminal({ session, theme, daemonReady }: TerminalPaneProps) { function AttachedTerminal({ session, theme, daemonReady, terminalTarget }: TerminalPaneProps) {
const attachSession =
session && terminalTarget?.kind === "reviewer"
? { ...session, terminalHandleId: terminalTarget.handleId }
: session;
// One terminal instance per pane lifetime (yyork's core rule): switching // One terminal instance per pane lifetime (yyork's core rule): switching
// sessions never remounts XtermTerminal — the attachment effect re-points // sessions never remounts XtermTerminal — the attachment effect re-points
// the mux and clears the screen instead. A keyed remount would tear down the // the mux and clears the screen instead. A keyed remount would tear down the
// renderer mid-switch and lose the warm GPU surface. // renderer mid-switch and lose the warm GPU surface.
const [terminal, setTerminal] = useState<AttachableTerminal | null>(null); const [terminal, setTerminal] = useState<AttachableTerminal | null>(null);
const [initFailed, setInitFailed] = useState(false); const [initFailed, setInitFailed] = useState(false);
const { attach, state, error } = useTerminalSession(session, { daemonReady }); const { attach, state, error } = useTerminalSession(attachSession, { daemonReady });
const handleId = session?.terminalHandleId; const handleId = attachSession?.terminalHandleId;
const hadAttachmentRef = useRef(false); const hadAttachmentRef = useRef(false);
const handleReady = useCallback((handle: AttachableTerminal) => setTerminal(handle), []); const handleReady = useCallback((handle: AttachableTerminal) => setTerminal(handle), []);

View File

@ -575,6 +575,77 @@ body.is-resizing-x [data-slot="sidebar-container"] {
background: var(--border); background: var(--border);
} }
.reviewer-terminal-header {
display: flex;
height: 36px;
flex-shrink: 0;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
background: var(--term-bg);
padding: 0 14px;
font-family: var(--font-mono);
}
.reviewer-terminal-header__back {
display: inline-flex;
height: 24px;
flex-shrink: 0;
align-items: center;
gap: 5px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-1);
padding: 0 8px;
font-size: 11px;
color: var(--fg-muted);
transition:
background 0.12s ease,
color 0.12s ease;
}
.reviewer-terminal-header__back:hover {
background: var(--interactive-hover);
color: var(--fg);
}
.reviewer-terminal-header__back svg,
.reviewer-terminal-header__role svg {
width: 13px;
height: 13px;
flex-shrink: 0;
}
.reviewer-terminal-header__role {
display: inline-flex;
height: 24px;
flex-shrink: 0;
align-items: center;
gap: 6px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--green) 34%, transparent);
background: color-mix(in srgb, var(--green) 9%, transparent);
padding: 0 9px;
font-size: 11px;
font-weight: 700;
color: #8bdc75;
text-transform: uppercase;
}
:root[data-theme="light"] .reviewer-terminal-header__role {
color: var(--green);
}
.reviewer-terminal-header__harness {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
color: var(--fg);
}
/* Collapse/expand animation for the inspector panel: rrp v4 drives panel /* Collapse/expand animation for the inspector panel: rrp v4 drives panel
* sizes via flex-grow, which is animatable. Suppress it while the separator * sizes via flex-grow, which is animatable. Suppress it while the separator
* is actively dragged so resizing tracks the pointer 1:1. */ * is actively dragged so resizing tracks the pointer 1:1. */
@ -620,6 +691,187 @@ body.is-resizing-x [data-slot="sidebar-container"] {
line-height: 1.5; line-height: 1.5;
} }
.reviewer-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.reviewer-error {
margin: 0;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--red) 28%, transparent);
background: color-mix(in srgb, var(--red) 8%, transparent);
padding: 8px 10px;
font-size: 11.5px;
line-height: 1.45;
color: var(--red);
}
.reviewer-notice {
margin: 0;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--green) 28%, transparent);
background: color-mix(in srgb, var(--green) 8%, transparent);
padding: 8px 10px;
font-size: 11.5px;
line-height: 1.45;
color: var(--green);
}
.reviewer-card {
display: flex;
flex-direction: column;
gap: 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-1);
padding: 14px;
}
.reviewer-card--success {
border-color: color-mix(in srgb, var(--green) 25%, var(--border));
}
.reviewer-card--danger {
border-color: color-mix(in srgb, var(--red) 22%, var(--border));
}
.reviewer-card__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.reviewer-card__name {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 9px;
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--fg);
}
.reviewer-card__name svg {
width: 15px;
height: 15px;
flex-shrink: 0;
color: var(--fg-passive);
}
.reviewer-card__name span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reviewer-status {
display: inline-flex;
height: 24px;
max-width: 58%;
flex-shrink: 0;
align-items: center;
gap: 5px;
overflow: hidden;
border-radius: 7px;
padding: 0 9px;
font-size: 11.5px;
font-weight: 650;
line-height: 1;
white-space: nowrap;
text-overflow: ellipsis;
}
.reviewer-status svg {
width: 13px;
height: 13px;
flex-shrink: 0;
}
.reviewer-status--neutral {
background: var(--bg-2);
color: var(--fg-muted);
}
.reviewer-status--running {
background: color-mix(in srgb, var(--orange) 12%, transparent);
color: var(--orange);
}
.reviewer-status--success {
background: color-mix(in srgb, var(--green) 14%, transparent);
color: var(--green);
}
.reviewer-status--danger {
background: color-mix(in srgb, var(--red) 14%, transparent);
color: var(--red);
}
.reviewer-card__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.reviewer-card__actions:has(.reviewer-card__action:only-child) {
grid-template-columns: 1fr;
}
.reviewer-card__action {
display: inline-flex;
height: 38px;
min-width: 0;
align-items: center;
justify-content: center;
gap: 7px;
overflow: hidden;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--bg-2);
padding: 0 10px;
font-size: 12px;
font-weight: 650;
color: var(--fg-muted);
white-space: nowrap;
text-overflow: ellipsis;
transition:
background 0.12s ease,
border-color 0.12s ease,
color 0.12s ease;
}
.reviewer-card__action svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.reviewer-card__action:hover:not(:disabled) {
background: var(--interactive-hover);
color: var(--fg);
}
.reviewer-card__action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.reviewer-card__action--primary {
border-color: color-mix(in srgb, var(--green) 42%, transparent);
background: color-mix(in srgb, var(--green) 10%, transparent);
color: #8bdc75;
}
:root[data-theme="light"] .reviewer-card__action--primary {
color: var(--green);
}
.inspector-empty--center { .inspector-empty--center {
padding: 24px 8px; padding: 24px 8px;
text-align: center; text-align: center;
@ -734,6 +986,10 @@ body.is-resizing-x [data-slot="sidebar-container"] {
background: var(--amber); background: var(--amber);
} }
.inspector-timeline__ev--bad .inspector-timeline__node {
background: var(--red);
}
.inspector-timeline__et { .inspector-timeline__et {
font-size: 12px; font-size: 12px;
color: var(--fg); color: var(--fg);

View File

@ -0,0 +1,7 @@
export type TerminalTarget =
| { kind: "worker" }
| {
kind: "reviewer";
handleId: string;
harness: string;
};