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:
parent
4bbcf9e925
commit
ee044e4edb
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 & branch</>,
|
node: <>Created worktree & 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":
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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), []);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export type TerminalTarget =
|
||||||
|
| { kind: "worker" }
|
||||||
|
| {
|
||||||
|
kind: "reviewer";
|
||||||
|
handleId: string;
|
||||||
|
harness: string;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue