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 { TerminalTarget } from "../types/terminal";
|
||||
import type { WorkspaceSession } from "../types/workspace";
|
||||
import { TerminalPane } from "./TerminalPane";
|
||||
|
||||
|
|
@ -6,13 +8,35 @@ type CenterPaneProps = {
|
|||
session?: WorkspaceSession;
|
||||
theme: Theme;
|
||||
daemonReady: boolean;
|
||||
terminalTarget?: TerminalTarget;
|
||||
onSelectWorkerTerminal?: () => void;
|
||||
};
|
||||
|
||||
export function CenterPane({ session, theme, daemonReady }: CenterPaneProps) {
|
||||
export function CenterPane({ session, theme, daemonReady, terminalTarget, onSelectWorkerTerminal }: CenterPaneProps) {
|
||||
const target = terminalTarget ?? { kind: "worker" };
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col bg-background">
|
||||
{target.kind === "reviewer" ? (
|
||||
<div className="reviewer-terminal-header">
|
||||
<button
|
||||
aria-label="Back to agent terminal"
|
||||
className="reviewer-terminal-header__back"
|
||||
onClick={onSelectWorkerTerminal}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
<span>agent</span>
|
||||
</button>
|
||||
<span className="reviewer-terminal-header__role">
|
||||
<Shield aria-hidden="true" />
|
||||
Reviewer
|
||||
</span>
|
||||
<span className="reviewer-terminal-header__harness">{target.harness}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1">
|
||||
<TerminalPane session={session} theme={theme} daemonReady={daemonReady} />
|
||||
<TerminalPane daemonReady={daemonReady} session={session} terminalTarget={target} theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ describe("ProjectSettingsForm", () => {
|
|||
model: "claude-opus-4-5",
|
||||
permissions: "auto",
|
||||
},
|
||||
reviewers: [{ harness: "claude-code" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -93,9 +94,11 @@ describe("ProjectSettingsForm", () => {
|
|||
const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" });
|
||||
const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" });
|
||||
const permissionMode = screen.getByRole("combobox", { name: "Permission mode" });
|
||||
const reviewerAgent = screen.getByRole("combobox", { name: "Default reviewer agent" });
|
||||
expect(workerAgent).toHaveTextContent("codex");
|
||||
expect(orchestratorAgent).toHaveTextContent("claude-code");
|
||||
expect(permissionMode).toHaveTextContent("Auto");
|
||||
expect(reviewerAgent).toHaveTextContent("claude-code");
|
||||
|
||||
await userEvent.clear(screen.getByLabelText("Default branch"));
|
||||
await userEvent.type(screen.getByLabelText("Default branch"), "release");
|
||||
|
|
@ -128,11 +131,12 @@ describe("ProjectSettingsForm", () => {
|
|||
model: "gpt-5-codex",
|
||||
permissions: "bypass-permissions",
|
||||
},
|
||||
reviewers: [{ harness: "claude-code" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await screen.findByText("Saved.")).toBeInTheDocument();
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("shows the daemon validation message when save fails", async () => {
|
||||
getMock.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ const PERMISSION_MODE_OPTIONS = [
|
|||
{ value: "bypass-permissions", label: "Bypass permissions" },
|
||||
] as const;
|
||||
|
||||
const REVIEWER_OPTIONS = ["claude-code"] as const;
|
||||
|
||||
const projectQueryKey = (id: string) => ["project", id] as const;
|
||||
|
||||
export function ProjectSettingsForm({ projectId }: { projectId: string }) {
|
||||
|
|
@ -73,6 +75,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
orchestratorAgent: config.orchestrator?.agent ?? "",
|
||||
model: config.agentConfig?.model ?? "",
|
||||
permissions: config.agentConfig?.permissions ?? "",
|
||||
reviewerHarness: config.reviewers?.[0]?.harness ?? "",
|
||||
});
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null);
|
||||
|
||||
|
|
@ -91,6 +94,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
model: form.model || undefined,
|
||||
permissions: form.permissions || undefined,
|
||||
}),
|
||||
reviewers: form.reviewerHarness ? [{ harness: form.reviewerHarness }] : undefined,
|
||||
};
|
||||
const { error } = await apiClient.PUT("/api/v1/projects/{id}/config", {
|
||||
params: { path: { id: projectId } },
|
||||
|
|
@ -188,6 +192,21 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[13px]">Reviewers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Field label="Default reviewer agent" htmlFor="reviewerHarness">
|
||||
<ReviewerSelect
|
||||
id="reviewerHarness"
|
||||
value={form.reviewerHarness}
|
||||
onChange={(v) => setForm((f) => ({ ...f, reviewerHarness: v }))}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Saving…" : "Save changes"}
|
||||
|
|
@ -250,6 +269,24 @@ function AgentSelect({ id, value, onChange }: { id: string; value: string; onCha
|
|||
);
|
||||
}
|
||||
|
||||
function ReviewerSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
||||
<SelectTrigger id={id} className="h-8 w-full text-[13px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Project default</SelectItem>
|
||||
{REVIEWER_OPTIONS.map((reviewer) => (
|
||||
<SelectItem key={reviewer} value={reviewer}>
|
||||
{reviewer}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkspaceSession } from "../types/workspace";
|
||||
import { SessionInspector } from "./SessionInspector";
|
||||
|
||||
const { getMock, postMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
|
|
@ -14,17 +16,15 @@ vi.mock("../lib/api-client", () => ({
|
|||
GET: getMock,
|
||||
POST: postMock,
|
||||
},
|
||||
apiErrorMessage: (error: unknown) => {
|
||||
apiErrorMessage: (error: unknown, fallback = "Request failed") => {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
return String((error as { message: unknown }).message);
|
||||
}
|
||||
return "Request failed";
|
||||
return fallback;
|
||||
},
|
||||
}));
|
||||
|
||||
import { SessionInspector } from "./SessionInspector";
|
||||
|
||||
const worker: WorkspaceSession = {
|
||||
id: "sess-1",
|
||||
workspaceId: "proj-1",
|
||||
|
|
@ -37,7 +37,21 @@ const worker: WorkspaceSession = {
|
|||
updatedAt: "2026-06-10T00:00:00Z",
|
||||
};
|
||||
|
||||
function renderInspector(session: WorkspaceSession = worker) {
|
||||
const reviewSession = {
|
||||
...worker,
|
||||
terminalHandleId: "worker-pane",
|
||||
title: "review me",
|
||||
provider: "codex",
|
||||
branch: "session/sess-1",
|
||||
createdAt: "2026-06-16T10:00:00Z",
|
||||
updatedAt: "2026-06-16T10:05:00Z",
|
||||
pullRequest: { number: 3, state: "open" },
|
||||
} satisfies WorkspaceSession;
|
||||
|
||||
function renderInspector(
|
||||
session: WorkspaceSession = worker,
|
||||
onOpenReviewerTerminal?: Parameters<typeof SessionInspector>[0]["onOpenReviewerTerminal"],
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
|
|
@ -46,12 +60,75 @@ function renderInspector(session: WorkspaceSession = worker) {
|
|||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionInspector session={session} />
|
||||
<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
function renderWithQuery(children: ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(<QueryClientProvider client={client}>{children}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function mockCommonGets(reviews: unknown[] = [], reviewerHandleId = "") {
|
||||
getMock.mockImplementation(async (path: string) => {
|
||||
if (path === "/api/v1/sessions/{sessionId}/pr") {
|
||||
return {
|
||||
data: {
|
||||
prs: [
|
||||
{
|
||||
url: "https://github.com/aoagents/reverbcode/pull/3",
|
||||
number: 3,
|
||||
state: "open",
|
||||
ci: "passing",
|
||||
review: "required",
|
||||
mergeability: "mergeable",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-16T10:05:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/api/v1/sessions/{sessionId}/reviews") {
|
||||
return { data: { reviewerHandleId, reviews } };
|
||||
}
|
||||
if (path === "/api/v1/projects/{id}") {
|
||||
return {
|
||||
data: {
|
||||
status: "ok",
|
||||
project: {
|
||||
id: "proj-1",
|
||||
kind: "git",
|
||||
name: "my-app",
|
||||
path: "/repo",
|
||||
repo: "my-app",
|
||||
defaultBranch: "main",
|
||||
config: { reviewers: [{ harness: "codex" }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: undefined };
|
||||
});
|
||||
}
|
||||
|
||||
const approvedReview = {
|
||||
id: "run-1",
|
||||
reviewId: "review-1",
|
||||
sessionId: "sess-1",
|
||||
harness: "codex",
|
||||
status: "complete",
|
||||
verdict: "approved",
|
||||
body: "Looks good.",
|
||||
prUrl: "https://github.com/aoagents/reverbcode/pull/3",
|
||||
targetSha: "abc123",
|
||||
createdAt: "2026-06-16T10:06:00Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getMock.mockReset();
|
||||
postMock.mockReset();
|
||||
|
|
@ -59,6 +136,67 @@ beforeEach(() => {
|
|||
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
|
||||
});
|
||||
|
||||
describe("SessionInspector reviews", () => {
|
||||
it("triggers a review and opens the returned reviewer terminal", async () => {
|
||||
mockCommonGets();
|
||||
postMock.mockResolvedValue({
|
||||
response: { status: 201 },
|
||||
data: {
|
||||
reviewerHandleId: "reviewer-pane",
|
||||
review: {
|
||||
...approvedReview,
|
||||
status: "running",
|
||||
verdict: "",
|
||||
body: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
const onOpenReviewerTerminal = vi.fn();
|
||||
|
||||
renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /run review/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/reviews/trigger", {
|
||||
params: { path: { sessionId: "sess-1" } },
|
||||
}),
|
||||
);
|
||||
expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" });
|
||||
});
|
||||
|
||||
it("shows an up-to-date notice instead of opening the terminal when the backend reuses a run", async () => {
|
||||
mockCommonGets([approvedReview], "reviewer-pane");
|
||||
postMock.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
data: {
|
||||
reviewerHandleId: "reviewer-pane",
|
||||
review: approvedReview,
|
||||
},
|
||||
});
|
||||
const onOpenReviewerTerminal = vi.fn();
|
||||
|
||||
renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /re-run review/i }));
|
||||
|
||||
expect(await screen.findByText("Review is already up to date for this commit.")).toBeInTheDocument();
|
||||
expect(onOpenReviewerTerminal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an approved review and opens its terminal", async () => {
|
||||
mockCommonGets([approvedReview], "reviewer-pane");
|
||||
const onOpenReviewerTerminal = vi.fn();
|
||||
|
||||
renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText("Approved").length).toBeGreaterThan(0));
|
||||
await userEvent.click(screen.getByRole("button", { name: /open terminal/i }));
|
||||
|
||||
expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionInspector kill button", () => {
|
||||
it("arms a confirmation before killing an active session", async () => {
|
||||
renderInspector();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { GitBranch, GitCommitHorizontal, GitPullRequest, Plus, Square, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
CircleMinus,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
GitPullRequest,
|
||||
Play,
|
||||
Plus,
|
||||
Shield,
|
||||
Square,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { components } from "../../api/schema";
|
||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||
|
|
@ -12,8 +25,13 @@ import { Button } from "./ui/button";
|
|||
import { cn } from "../lib/utils";
|
||||
|
||||
type PRFacts = components["schemas"]["SessionPRFacts"];
|
||||
type ProjectConfig = components["schemas"]["ProjectConfig"];
|
||||
type ReviewRun = components["schemas"]["ReviewRun"];
|
||||
type ReviewsResponse = components["schemas"]["ListReviewsResponse"];
|
||||
type InspectorView = "summary" | "changes" | "browser";
|
||||
|
||||
type OpenReviewerTerminal = (target: { handleId: string; harness: string }) => void;
|
||||
|
||||
const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
|
||||
{
|
||||
id: "summary",
|
||||
|
|
@ -66,7 +84,13 @@ const prStateTone: Record<PRFacts["state"], string> = {
|
|||
* Tabbed inspector rail beside the terminal — cloned from agent-orchestrator
|
||||
* SessionInspector (Summary · Changes · Browser).
|
||||
*/
|
||||
export function SessionInspector({ session }: { session?: WorkspaceSession }) {
|
||||
export function SessionInspector({
|
||||
session,
|
||||
onOpenReviewerTerminal,
|
||||
}: {
|
||||
session?: WorkspaceSession;
|
||||
onOpenReviewerTerminal?: OpenReviewerTerminal;
|
||||
}) {
|
||||
const [view, setView] = useState<InspectorView>("summary");
|
||||
|
||||
if (!session) {
|
||||
|
|
@ -98,7 +122,7 @@ export function SessionInspector({ session }: { session?: WorkspaceSession }) {
|
|||
</div>
|
||||
|
||||
<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 === "browser" ? <BrowserView /> : null}
|
||||
</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 queryClient = useQueryClient();
|
||||
const [reviewNotice, setReviewNotice] = useState<string | null>(null);
|
||||
const query = useQuery({
|
||||
queryKey: ["session-pr", session.id],
|
||||
enabled: hasPr,
|
||||
|
|
@ -131,8 +163,59 @@ function SummaryView({ session }: { session: WorkspaceSession }) {
|
|||
return data?.prs ?? [];
|
||||
},
|
||||
});
|
||||
const reviewsQuery = useQuery({
|
||||
queryKey: ["session-reviews", session.id],
|
||||
enabled: hasPr,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as ReviewsResponse | undefined;
|
||||
return data?.reviews.some((review) => review.status === "running") ? 2500 : false;
|
||||
},
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/reviews", {
|
||||
params: { path: { sessionId: session.id } },
|
||||
});
|
||||
if (error) throw new Error(apiErrorMessage(error, "Unable to load reviews"));
|
||||
return data ?? ({ reviewerHandleId: "", reviews: [] } satisfies ReviewsResponse);
|
||||
},
|
||||
});
|
||||
const projectConfigQuery = useQuery({
|
||||
queryKey: ["project-config", session.workspaceId],
|
||||
enabled: hasPr,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.GET("/api/v1/projects/{id}", {
|
||||
params: { path: { id: session.workspaceId } },
|
||||
});
|
||||
if (error) return undefined;
|
||||
return projectConfig(data?.project);
|
||||
},
|
||||
});
|
||||
const triggerReview = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data, error, 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 branchLabel = session.branch || `session/${session.id}`;
|
||||
const reviews = reviewsQuery.data?.reviews ?? [];
|
||||
|
||||
return (
|
||||
<div role="tabpanel">
|
||||
|
|
@ -179,8 +262,23 @@ function SummaryView({ session }: { session: WorkspaceSession }) {
|
|||
)}
|
||||
</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">
|
||||
<ActivityTimeline session={session} />
|
||||
<ActivityTimeline reviews={reviews} session={session} />
|
||||
</Section>
|
||||
|
||||
<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 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({
|
||||
tone: "neutral",
|
||||
node: <>Created worktree & branch</>,
|
||||
|
|
@ -306,6 +555,7 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) {
|
|||
event.tone === "now" && "inspector-timeline__ev--now",
|
||||
event.tone === "good" && "inspector-timeline__ev--good",
|
||||
event.tone === "warn" && "inspector-timeline__ev--warn",
|
||||
event.tone === "bad" && "inspector-timeline__ev--bad",
|
||||
)}
|
||||
>
|
||||
<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 {
|
||||
switch (status) {
|
||||
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 { CenterPane } from "./CenterPane";
|
||||
import { SessionInspector } from "./SessionInspector";
|
||||
|
|
@ -7,6 +7,7 @@ import { useUiStore } from "../stores/ui-store";
|
|||
import { useShell } from "../lib/shell-context";
|
||||
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
|
||||
import { isOrchestratorSession } from "../types/workspace";
|
||||
import type { TerminalTarget } from "../types/terminal";
|
||||
|
||||
const INSPECTOR_MIN_PERCENT = 22;
|
||||
const INSPECTOR_MAX_PERCENT = 45;
|
||||
|
|
@ -45,10 +46,15 @@ export function SessionView({ sessionId }: SessionViewProps) {
|
|||
const { daemonStatus } = useShell();
|
||||
const inspectorRef = useRef<PanelImperativeHandle | 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 isOrchestrator = session ? isOrchestratorSession(session) : false;
|
||||
|
||||
useEffect(() => {
|
||||
setTerminalTarget({ kind: "worker" });
|
||||
}, [sessionId]);
|
||||
|
||||
// Orchestrator sessions are terminal-only; only worker sessions have the rail.
|
||||
const hasInspector = !isOrchestrator;
|
||||
// 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
|
||||
be strings. Numeric sizes here once clamped the inspector to 45px. */}
|
||||
<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>
|
||||
{hasInspector ? (
|
||||
<>
|
||||
|
|
@ -171,7 +183,12 @@ export function SessionView({ sessionId }: SessionViewProps) {
|
|||
{/* Stable content width while the panel animates (yyork pattern):
|
||||
the pane clips instead of reflowing the inspector mid-collapse. */}
|
||||
<div className="h-full min-w-[280px]">
|
||||
<SessionInspector session={session} />
|
||||
<SessionInspector
|
||||
onOpenReviewerTerminal={({ handleId, harness }) =>
|
||||
setTerminalTarget({ kind: "reviewer", handleId, harness })
|
||||
}
|
||||
session={session}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { TerminalTarget } from "../types/terminal";
|
||||
import type { WorkspaceSession } from "../types/workspace";
|
||||
import type { Theme } from "../stores/ui-store";
|
||||
import { useTerminalSession, type AttachableTerminal, type TerminalSessionState } from "../hooks/useTerminalSession";
|
||||
|
|
@ -8,14 +9,16 @@ type TerminalPaneProps = {
|
|||
session?: WorkspaceSession;
|
||||
theme: Theme;
|
||||
daemonReady: boolean;
|
||||
terminalTarget?: TerminalTarget;
|
||||
};
|
||||
|
||||
export function TerminalPane({ session, theme, daemonReady }: TerminalPaneProps) {
|
||||
export function TerminalPane({ session, theme, daemonReady, terminalTarget }: TerminalPaneProps) {
|
||||
if (!window.ao) {
|
||||
const provider = terminalTarget?.kind === "reviewer" ? terminalTarget.harness : (session?.provider ?? "claude");
|
||||
return (
|
||||
<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-blue)]">{session?.branch || "main"}</span> $ {session?.provider ?? "claude"}
|
||||
<span className="text-[var(--term-blue)]">{session?.branch || "main"}</span> $ {provider}
|
||||
{"\n"}
|
||||
<span className="text-[var(--term-green)]">✻ Welcome to the agent CLI</span>
|
||||
{"\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 {
|
||||
|
|
@ -35,15 +38,19 @@ function bannerText(state: TerminalSessionState, error?: string): string | undef
|
|||
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
|
||||
// sessions never remounts XtermTerminal — the attachment effect re-points
|
||||
// the mux and clears the screen instead. A keyed remount would tear down the
|
||||
// renderer mid-switch and lose the warm GPU surface.
|
||||
const [terminal, setTerminal] = useState<AttachableTerminal | null>(null);
|
||||
const [initFailed, setInitFailed] = useState(false);
|
||||
const { attach, state, error } = useTerminalSession(session, { daemonReady });
|
||||
const handleId = session?.terminalHandleId;
|
||||
const { attach, state, error } = useTerminalSession(attachSession, { daemonReady });
|
||||
const handleId = attachSession?.terminalHandleId;
|
||||
const hadAttachmentRef = useRef(false);
|
||||
|
||||
const handleReady = useCallback((handle: AttachableTerminal) => setTerminal(handle), []);
|
||||
|
|
|
|||
|
|
@ -575,6 +575,77 @@ body.is-resizing-x [data-slot="sidebar-container"] {
|
|||
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
|
||||
* sizes via flex-grow, which is animatable. Suppress it while the separator
|
||||
* 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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: 24px 8px;
|
||||
text-align: center;
|
||||
|
|
@ -734,6 +986,10 @@ body.is-resizing-x [data-slot="sidebar-container"] {
|
|||
background: var(--amber);
|
||||
}
|
||||
|
||||
.inspector-timeline__ev--bad .inspector-timeline__node {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.inspector-timeline__et {
|
||||
font-size: 12px;
|
||||
color: var(--fg);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
export type TerminalTarget =
|
||||
| { kind: "worker" }
|
||||
| {
|
||||
kind: "reviewer";
|
||||
handleId: string;
|
||||
harness: string;
|
||||
};
|
||||
Loading…
Reference in New Issue