feat(frontend): surface multiple PRs per session + reviewer in inspector (#335)

* feat(frontend): surface multiple PRs per session

Replace the single optional pullRequest on a session with a prs[] list and
render it across every PR surface: the inspector stacks one card per PR, the
PR board lists one row per attributed PR, and the board card shows a PR count
summary. useWorkspaceQuery now maps the live /api/v1/sessions prs[] into the
query (the frontend surfaced no PRs before this). Ordering is actionable-first
(open, draft, merged, closed).

Adds unit tests (SessionInspector, PullRequestsPage), a Playwright e2e spec,
and a multi-PR mock-data fixture.

* chore: format with prettier [skip ci]

* feat(frontend): swap inspector Changes tab for empty Reviews placeholder

The inspector rail's Summary tab already renders the multi-PR stack from
PR #237 work, so the Changes (Git rail) tab is the next inspector surface
to evolve. Reviews will land on its own backed by PR-scoped review data
(separate workstream); reserve the slot now with an empty placeholder so
the navigation lands before the data does.

- Tabs are now Summary, Reviews, Browser. The InspectorView union and
  the VIEWS array are updated; Reviews gets a message-bubble icon to
  distinguish it from Summary's list icon.
- ChangesView and its lucide imports (GitBranch, GitCommitHorizontal,
  Plus, Square, Trash2) and the unused Button import are removed; a
  small ReviewsView mirrors BrowserView's empty-state shape.
- styles.css drops the orphaned .inspector-changes__* block.
- SessionInspector.test.tsx asserts the new tab labels and that Reviews
  shows the empty placeholder.

* feat(frontend): wire the reviewer feature into the inspector Reviews tab

The multi-PR transplant left the Reviews tab an empty placeholder, which would
have regressed the existing session review feature. Move the reviewer panel
(trigger/re-run a review, open the reviewer terminal, surface verdict + status)
into the Reviews tab, gated on the multi-PR model: it reads the session's prs[]
to decide between the reviewer card and the "no PR yet" empty state, and pulls
review runs + project reviewer config straight from the daemon.

Reconciles the prs[]-from-session-list model across the suite: ShellTopbar and
pr-hydration fixtures carry prs[], useWorkspaceQuery tests drop the obsolete
per-session /pr fetch, and SessionsBoard restores the dropped board title.

Adds a Playwright reviews-tab e2e spec (reviewer card for a session with PRs;
empty state for one without).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(frontend): adapt multi-PR e2e specs to main's expanded fixture

The rebase pulled in main's larger mock-data fixture (4 workspaces, 13 PRs)
and its renamed refactor-mux title. Rewrite the PR-board assertion to verify
the actionable-first ordering invariant instead of a brittle full-list match,
and update the empty-state selector to the session's current title.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Pritom Mazumdar 2026-06-20 16:27:53 +05:30 committed by GitHub
parent a8a3056a6b
commit b4a8fad215
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 767 additions and 717 deletions

View File

@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test";
// dev:web (VITE_NO_ELECTRON=1) serves lib/mock-data.ts. The api-gateway
// workspace owns a "stacked-auth" session ("auth stack") carrying three PRs:
// #41 open, #42 draft, #40 merged — the multi-PR-per-session case this suite
// guards across the inspector rail and the PR board.
test("the inspector rail stacks every PR a session owns, actionable-first", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Open auth stack" }).click();
await expect(page).toHaveURL(/sessions\/stacked-auth/);
const inspector = page.locator("#inspector");
await expect(inspector).toBeVisible();
// Plural heading reflects the stack size.
await expect(inspector.getByText("Pull requests (3)")).toBeVisible();
// One card per PR, ordered open → draft → merged (the merged base sinks).
// Scope to the PR section: the Activity timeline also renders "Opened PR #n".
const prSection = inspector.locator("section.inspector-section", { hasText: "Pull requests (3)" });
const cards = prSection.locator("text=/^PR #\\d+$/");
await expect(cards).toHaveText(["PR #41", "PR #42", "PR #40"]);
});
test("the PR board lists one row per attributed PR, actionable PRs first", async ({ page }) => {
await page.goto("/#/prs");
await expect(page.getByRole("heading", { name: "Pull requests" })).toBeVisible();
// stacked-auth's three PRs keep actionable-first order across the whole board:
// open #41 before draft #42, and the lone merged PR (#40) sinks to the bottom.
const numbers = await page.locator("tbody tr td:first-child").allTextContents();
expect(numbers.indexOf("#41")).toBeLessThan(numbers.indexOf("#42"));
expect(numbers.indexOf("#42")).toBeLessThan(numbers.indexOf("#40"));
expect(numbers.indexOf("#40")).toBe(numbers.length - 1);
// Open/draft rows are actionable; the merged row is not.
const mergedRow = page.locator("tbody tr", { hasText: "#40" });
await expect(mergedRow.getByRole("button", { name: "Merge" })).toHaveCount(0);
const openRow = page.locator("tbody tr", { hasText: "#41" });
await expect(openRow.getByRole("button", { name: "Merge" })).toBeVisible();
});

View File

@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test";
// dev:web (VITE_NO_ELECTRON=1) serves lib/mock-data.ts, whose "stacked-auth"
// session ("auth stack") owns three PRs — so the inspector's Reviews tab is
// enabled. The tab fetches review runs and project config straight from the
// daemon; dev:web has no daemon, so we stub those two routes to drive the
// reviewer panel and prove it renders a real reviewer card (not the empty
// "no PR yet" placeholder) once a session owns a PR.
test("the Reviews tab renders the reviewer panel for a session that owns PRs", async ({ page }) => {
await page.route("**/api/v1/sessions/stacked-auth/reviews", (route) =>
route.fulfill({
json: {
reviewerHandleId: "reviewer-pane",
reviews: [
{
id: "run-1",
reviewId: "review-1",
sessionId: "stacked-auth",
harness: "codex",
status: "complete",
verdict: "approved",
body: "Looks good.",
prUrl: "https://github.com/me/api-gateway/pull/41",
targetSha: "abc123",
createdAt: new Date().toISOString(),
},
],
},
}),
);
await page.route("**/api/v1/projects/api-gateway", (route) =>
route.fulfill({
json: {
status: "ok",
project: {
id: "api-gateway",
kind: "git",
name: "api-gateway",
path: "/Users/me/api-gateway",
repo: "api-gateway",
defaultBranch: "main",
config: { reviewers: [{ harness: "codex" }] },
},
},
}),
);
await page.goto("/");
await page.getByRole("button", { name: "Open auth stack" }).click();
await expect(page).toHaveURL(/sessions\/stacked-auth/);
const inspector = page.locator("#inspector");
await expect(inspector).toBeVisible();
await inspector.getByRole("tab", { name: "Reviews" }).click();
// The reviewer card surfaces the harness, its approved verdict, and both
// actions — never the empty state, since this session owns a PR.
await expect(inspector.getByText("No pull request opened yet.")).toHaveCount(0);
await expect(inspector.getByText("codex")).toBeVisible();
await expect(inspector.getByText("Approved")).toBeVisible();
await expect(inspector.getByRole("button", { name: "Re-run review" })).toBeVisible();
await expect(inspector.getByRole("button", { name: "Open terminal" })).toBeVisible();
});
test("the Reviews tab shows the empty state for a session with no PRs", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Open Split terminal mux responsibilities" }).click();
await expect(page).toHaveURL(/sessions\/refactor-mux/);
const inspector = page.locator("#inspector");
await expect(inspector).toBeVisible();
await inspector.getByRole("tab", { name: "Reviews" }).click();
await expect(inspector.getByText("No pull request opened yet.")).toBeVisible();
});

View File

@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
// Drives the real useWorkspaceQuery + real Board / PR-page consumers end to end
// for a normal project, mocking only the HTTP client and the router. Proves the
// #251 fix: PR facts fetched from /pr flow through the shared workspace cache
// into every consumer.
// for a normal project, mocking only the HTTP client and the router. Proves PR
// facts carried on the session list flow through the shared workspace cache into
// every consumer.
const { getMock, navigateMock } = vi.hoisted(() => ({ getMock: vi.fn(), navigateMock: vi.fn() }));
vi.mock("../../lib/api-client", () => ({
@ -24,7 +24,7 @@ import { PullRequestsPage } from "../../components/PullRequestsPage";
// One ordinary project with one worker session that has an open PR (#278).
function respondWithProjectAndPR() {
getMock.mockImplementation(async (url: string, options?: { params?: { path?: { sessionId?: string } } }) => {
getMock.mockImplementation(async (url: string) => {
if (url === "/api/v1/projects") {
return { data: { projects: [{ id: "proj-1", name: "my-app", path: "/repo/my-app" }] }, error: undefined };
}
@ -40,27 +40,18 @@ function respondWithProjectAndPR() {
status: "pr_open",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
};
}
if (url === "/api/v1/sessions/{sessionId}/pr") {
expect(options?.params?.path?.sessionId).toBe("sess-1");
return {
data: {
sessionId: "sess-1",
prs: [
{
number: 278,
state: "open",
url: "https://github.com/aoagents/ReverbCode/pull/278",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
prs: [
{
number: 278,
state: "open",
url: "https://github.com/aoagents/ReverbCode/pull/278",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
],
},

View File

@ -0,0 +1,95 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PullRequestsPage } from "./PullRequestsPage";
import type { PRState, PullRequestFacts, WorkspaceSession, WorkspaceSummary } from "../types/workspace";
const { navigateMock, postMock, useWorkspaceQueryMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
postMock: vi.fn(),
useWorkspaceQueryMock: vi.fn(),
}));
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => navigateMock }));
vi.mock("../hooks/useWorkspaceQuery", () => ({
useWorkspaceQuery: () => useWorkspaceQueryMock(),
workspaceQueryKey: ["workspaces"],
}));
vi.mock("../lib/api-client", () => ({
apiClient: { POST: (...args: unknown[]) => postMock(...args) },
apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"),
}));
const pr = (n: number, state: PRState): PullRequestFacts => ({
url: `https://example.com/pr/${n}`,
number: n,
state,
ci: "passing",
review: "approved",
mergeability: "mergeable",
reviewComments: false,
updatedAt: "2026-06-15T00:00:00Z",
});
const session = (id: string, prs: PullRequestFacts[]): WorkspaceSession => ({
id,
workspaceId: "proj-1",
workspaceName: "my-app",
title: id,
provider: "claude-code",
kind: "worker",
branch: "feat/ns",
status: "review_pending",
updatedAt: "2026-06-15T00:00:00Z",
prs,
});
function setWorkspaces(sessions: WorkspaceSession[]) {
const data: WorkspaceSummary[] = [{ id: "proj-1", name: "my-app", path: "/p", sessions }];
useWorkspaceQueryMock.mockReturnValue({ data, isError: false, isLoading: false });
}
function renderPage() {
render(
<QueryClientProvider client={new QueryClient()}>
<PullRequestsPage />
</QueryClientProvider>,
);
}
beforeEach(() => {
navigateMock.mockReset();
postMock.mockReset().mockResolvedValue({ data: { method: "squash" }, error: undefined });
});
afterEach(() => vi.restoreAllMocks());
describe("PullRequestsPage", () => {
it("renders one row per PR across sessions, actionable PRs first", () => {
setWorkspaces([session("auth", [pr(41, "open"), pr(42, "draft"), pr(40, "merged")])]);
renderPage();
const rows = screen.getAllByRole("row").slice(1); // drop header
const numbers = rows.map((r) => within(r).getByText(/^#\d+$/).textContent);
expect(numbers).toEqual(["#41", "#42", "#40"]);
});
it("merges the PR by its own number, not the session's", async () => {
setWorkspaces([session("auth", [pr(41, "open"), pr(42, "draft")])]);
renderPage();
const user = userEvent.setup();
const childRow = screen.getByText("#42").closest("tr")!;
await user.click(within(childRow).getByRole("button", { name: "Merge" }));
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(postMock).toHaveBeenCalledWith("/api/v1/prs/{id}/merge", { params: { path: { id: "42" } } });
});
it("shows an empty state when no session has a PR", () => {
setWorkspaces([session("idle", [])]);
renderPage();
expect(screen.getByText("No open pull requests.")).toBeInTheDocument();
});
});

View File

@ -3,15 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import type { WorkspaceSession } from "../types/workspace";
import { type PRState, type PullRequestFacts, type WorkspaceSession } from "../types/workspace";
import { DashboardSubhead } from "./DashboardSubhead";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
import { cn } from "../lib/utils";
type PRState = NonNullable<WorkspaceSession["pullRequest"]>["state"];
const stateTone: Record<PRState, string> = {
open: "border-success/40 bg-success/10 text-success",
draft: "border-border bg-raised text-muted-foreground",
@ -23,26 +21,22 @@ const stateTone: Record<PRState, string> = {
const stateRank: Record<PRState, number> = { open: 0, draft: 1, merged: 2, closed: 3 };
type PRRow = {
number: number;
state: PRState;
pr: PullRequestFacts;
session: WorkspaceSession;
};
// The PR board, ported from agent-orchestrator's PullRequestsPage. The Go
// daemon has no PR-list endpoint, so the board is derived from session PR
// fields (every session carries pullRequest); actions hit /prs/{number}/merge
// and /resolve-comments. Per-PR CI/review facts live on the session route's
// inspector.
// The PR board, ported from agent-orchestrator's PullRequestsPage. One row per
// attributed PR — a session can own several (a stack or independent PRs), so we
// flatMap the session's prs list rather than assuming one. Actions hit
// /prs/{number}/merge and /resolve-comments. Per-PR CI/review facts also live on
// the session route's inspector.
export function PullRequestsPage() {
const navigate = useNavigate();
const workspaceQuery = useWorkspaceQuery();
const sessions = (workspaceQuery.data ?? []).flatMap((w) => w.sessions);
const rows: PRRow[] = sessions
.filter((s): s is WorkspaceSession & { pullRequest: NonNullable<WorkspaceSession["pullRequest"]> } =>
Boolean(s.pullRequest),
)
.map((s) => ({ number: s.pullRequest.number, state: s.pullRequest.state, session: s }))
.sort((a, b) => stateRank[a.state] - stateRank[b.state] || a.number - b.number);
.flatMap((s) => s.prs.map((pr) => ({ pr, session: s })))
.sort((a, b) => stateRank[a.pr.state] - stateRank[b.pr.state] || a.pr.number - b.pr.number);
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
@ -68,7 +62,7 @@ export function PullRequestsPage() {
<TableBody>
{rows.map((row) => (
<PRRowView
key={`${row.session.id}-${row.number}`}
key={`${row.session.id}-${row.pr.number}`}
row={row}
onOpen={() =>
void navigate({
@ -94,7 +88,7 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
const merge = useMutation({
mutationFn: async () => {
const { data, error } = await apiClient.POST("/api/v1/prs/{id}/merge", {
params: { path: { id: String(row.number) } },
params: { path: { id: String(row.pr.number) } },
});
if (error) throw new Error(apiErrorMessage(error));
return data;
@ -109,7 +103,7 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
const resolve = useMutation({
mutationFn: async () => {
const { error } = await apiClient.POST("/api/v1/prs/{id}/resolve-comments", {
params: { path: { id: String(row.number) } },
params: { path: { id: String(row.pr.number) } },
});
if (error) throw new Error(apiErrorMessage(error));
},
@ -120,11 +114,11 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
onError: (e) => setNote({ ok: false, text: e instanceof Error ? e.message : "resolve failed" }),
});
const actionable = row.state === "open" || row.state === "draft";
const actionable = row.pr.state === "open" || row.pr.state === "draft";
return (
<TableRow className="cursor-pointer" onClick={onOpen}>
<TableCell className="font-mono text-[12px] text-muted-foreground">#{row.number}</TableCell>
<TableCell className="font-mono text-[12px] text-muted-foreground">#{row.pr.number}</TableCell>
<TableCell className="max-w-0">
<div className="truncate text-[13px] text-foreground">{row.session.title}</div>
<div className="truncate font-mono text-[10px] text-passive">
@ -132,8 +126,8 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className={cn("h-5 px-1.5 text-[10px] font-medium", stateTone[row.state])}>
{row.state}
<Badge variant="outline" className={cn("h-5 px-1.5 text-[10px] font-medium", stateTone[row.pr.state])}>
{row.pr.state}
</Badge>
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>

View File

@ -1,10 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } 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";
import type { PRState, PullRequestFacts, WorkspaceSession } from "../types/workspace";
const { getMock, postMock } = vi.hoisted(() => ({
getMock: vi.fn(),
@ -25,28 +25,29 @@ vi.mock("../lib/api-client", () => ({
},
}));
const worker: WorkspaceSession = {
const pr = (n: number, state: PRState): PullRequestFacts => ({
url: `https://example.com/pr/${n}`,
number: n,
state,
ci: "passing",
review: "approved",
mergeability: "mergeable",
reviewComments: false,
updatedAt: "2026-06-15T00:00:00Z",
});
const session = (prs: PullRequestFacts[]): WorkspaceSession => ({
id: "sess-1",
workspaceId: "proj-1",
workspaceId: "ws-1",
workspaceName: "my-app",
title: "do the thing",
provider: "claude-code",
kind: "worker",
branch: "ao/sess-1",
status: "working",
updatedAt: "2026-06-10T00:00:00Z",
};
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;
branch: "feat/ns",
status: "review_pending",
updatedAt: "2026-06-15T00:00:00Z",
prs,
});
function renderWithQuery(children: ReactNode) {
const client = new QueryClient({
@ -57,24 +58,6 @@ function renderWithQuery(children: ReactNode) {
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 } };
}
@ -83,7 +66,7 @@ function mockCommonGets(reviews: unknown[] = [], reviewerHandleId = "") {
data: {
status: "ok",
project: {
id: "proj-1",
id: "ws-1",
kind: "git",
name: "my-app",
path: "/repo",
@ -106,7 +89,7 @@ const approvedReview = {
status: "complete",
verdict: "approved",
body: "Looks good.",
prUrl: "https://github.com/aoagents/reverbcode/pull/3",
prUrl: "https://example.com/pr/3",
targetSha: "abc123",
createdAt: "2026-06-16T10:06:00Z",
};
@ -114,28 +97,78 @@ const approvedReview = {
beforeEach(() => {
getMock.mockReset();
postMock.mockReset();
getMock.mockResolvedValue({ data: { prs: [] }, error: undefined });
getMock.mockResolvedValue({ data: { reviewerHandleId: "", reviews: [] }, error: undefined });
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
});
describe("SessionInspector reviews", () => {
describe("SessionInspector PR section", () => {
// Scope assertions to the PR section: the activity timeline also renders
// "Opened PR #n", so an unscoped query matches both the card and the event.
const prSection = (title: string) =>
within(screen.getByText(title).closest("section.inspector-section") as HTMLElement);
it("renders one card per PR, ordered actionable-first, when a session owns a stack", () => {
render(<SessionInspector session={session([pr(40, "merged"), pr(41, "open"), pr(42, "draft")])} />);
expect(screen.getByText("Pull requests (3)")).toBeInTheDocument();
const cards = prSection("Pull requests (3)")
.getAllByText(/^PR #\d+$/)
.map((el) => el.textContent);
// open (41), draft (42), merged (40)
expect(cards).toEqual(["PR #41", "PR #42", "PR #40"]);
});
it("uses the singular heading and shows enriched facts for a single PR", () => {
render(<SessionInspector session={session([pr(7, "open")])} />);
expect(screen.getByText("Pull request")).toBeInTheDocument();
expect(screen.queryByText(/Pull requests \(/)).not.toBeInTheDocument();
expect(prSection("Pull request").getByText("PR #7")).toBeInTheDocument();
// CI/Merge/Review facts surface per card.
expect(prSection("Pull request").getAllByText("passing").length).toBeGreaterThan(0);
});
it("shows the empty state when there are no PRs", () => {
render(<SessionInspector session={session([])} />);
expect(screen.getByText("No pull request opened yet.")).toBeInTheDocument();
});
it("links each PR to its url", () => {
render(<SessionInspector session={session([pr(41, "open"), pr(42, "draft")])} />);
const links = screen.getAllByRole("link", { name: /Open/ });
expect(links.map((a) => a.getAttribute("href"))).toEqual([
"https://example.com/pr/41",
"https://example.com/pr/42",
]);
});
});
describe("SessionInspector tabs", () => {
it("exposes Summary, Reviews, and Browser as the three inspector tabs", () => {
render(<SessionInspector session={session([pr(1, "open")])} />);
const tabs = screen.getAllByRole("tab").map((el) => el.textContent?.trim());
expect(tabs).toEqual(["Summary", "Reviews", "Browser"]);
});
});
describe("SessionInspector reviews tab", () => {
const openReviewsTab = async () => userEvent.click(screen.getByRole("tab", { name: /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: "",
},
review: { ...approvedReview, status: "running", verdict: "", body: "" },
},
});
const onOpenReviewerTerminal = vi.fn();
renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);
renderWithQuery(
<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session([pr(3, "open")])} />,
);
await openReviewsTab();
await userEvent.click(await screen.findByRole("button", { name: /run review/i }));
@ -151,14 +184,14 @@ describe("SessionInspector reviews", () => {
mockCommonGets([approvedReview], "reviewer-pane");
postMock.mockResolvedValue({
response: { status: 200 },
data: {
reviewerHandleId: "reviewer-pane",
review: approvedReview,
},
data: { reviewerHandleId: "reviewer-pane", review: approvedReview },
});
const onOpenReviewerTerminal = vi.fn();
renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);
renderWithQuery(
<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session([pr(3, "open")])} />,
);
await openReviewsTab();
await userEvent.click(await screen.findByRole("button", { name: /re-run review/i }));
@ -170,11 +203,22 @@ describe("SessionInspector reviews", () => {
mockCommonGets([approvedReview], "reviewer-pane");
const onOpenReviewerTerminal = vi.fn();
renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);
renderWithQuery(
<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session([pr(3, "open")])} />,
);
await openReviewsTab();
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" });
});
it("shows the no-PR empty state when the session has no PRs", async () => {
mockCommonGets();
renderWithQuery(<SessionInspector session={session([])} />);
await userEvent.click(screen.getByRole("tab", { name: /Reviews/ }));
expect(await screen.findByText("No pull request opened yet.")).toBeInTheDocument();
});
});

View File

@ -1,37 +1,22 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import {
AlertCircle,
CheckCircle2,
CircleMinus,
GitBranch,
GitCommitHorizontal,
GitPullRequest,
Play,
Plus,
Shield,
Square,
Terminal,
Trash2,
} from "lucide-react";
import { AlertCircle, CheckCircle2, CircleMinus, GitPullRequest, Play, Shield, Terminal } from "lucide-react";
import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { formatTimeCompact } from "../lib/format-time";
import type { SessionStatus, WorkspaceSession } from "../types/workspace";
import { workerDisplayStatus } from "../types/workspace";
import type { PRState, PullRequestFacts, SessionStatus, WorkspaceSession } from "../types/workspace";
import { sortedPRs, workerDisplayStatus } from "../types/workspace";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
type PRFacts = components["schemas"]["SessionPRFacts"];
type ProjectConfig = components["schemas"]["ProjectConfig"];
type ReviewRun = components["schemas"]["ReviewRun"];
type ReviewsResponse = components["schemas"]["ListReviewsResponse"];
type InspectorView = "summary" | "changes" | "browser";
type OpenReviewerTerminal = (target: { handleId: string; harness: string }) => void;
type InspectorView = "summary" | "reviews" | "browser";
const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
{
id: "summary",
@ -48,15 +33,11 @@ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
),
},
{
id: "changes",
label: "Changes",
id: "reviews",
label: "Reviews",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<path d="M12 3v6" />
<path d="M9 6h6" />
<path d="M11 18H7a2 2 0 0 1-2-2V6" />
<path d="M13 15h4" />
<path d="M19 9v7a2 2 0 0 1-2 2h-2" />
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
),
},
@ -73,7 +54,7 @@ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
},
];
const prStateTone: Record<PRFacts["state"], string> = {
const prStateTone: Record<PRState, string> = {
open: "border-success/40 bg-success/10 text-success",
draft: "border-border bg-raised text-muted-foreground",
merged: "border-accent/40 bg-accent-weak text-accent",
@ -81,8 +62,7 @@ const prStateTone: Record<PRFacts["state"], string> = {
};
/**
* Tabbed inspector rail beside the terminal cloned from agent-orchestrator
* SessionInspector (Summary · Changes · Browser).
* Tabbed inspector rail beside the terminal (Summary · Reviews · Browser).
*/
export function SessionInspector({
session,
@ -122,8 +102,8 @@ export function SessionInspector({
</div>
<div className="session-inspector__body">
{view === "summary" ? <SummaryView onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} /> : null}
{view === "changes" ? <ChangesView session={session} /> : null}
{view === "summary" ? <SummaryView session={session} /> : null}
{view === "reviews" ? <ReviewsView onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} /> : null}
{view === "browser" ? <BrowserView /> : null}
</div>
</aside>
@ -142,27 +122,181 @@ function Section({ title, action, children }: { title: string; action?: ReactNod
);
}
function SummaryView({
function SummaryView({ session }: { session: WorkspaceSession }) {
const prs = sortedPRs(session);
const branchLabel = session.branch || `session/${session.id}`;
return (
<div role="tabpanel">
<Section title={prs.length > 1 ? `Pull requests (${prs.length})` : "Pull request"}>
{prs.length === 0 ? (
<p className="inspector-empty">No pull request opened yet.</p>
) : (
<div className="flex flex-col gap-2.5">
{prs.map((pr) => (
<PRCard key={pr.url} pr={pr} />
))}
</div>
)}
</Section>
<Section title="Activity">
<ActivityTimeline session={session} />
</Section>
<Section title="Overview">
<dl className="inspector-kv">
<Row k="Agent" v={session.provider} mono />
<Row k="Branch" v={branchLabel} mono />
<Row k="Started" v={formatTimeCompact(session.createdAt ?? session.updatedAt)} mono />
<Row k="Session" v={session.id} mono />
</dl>
</Section>
</div>
);
}
// One PR per card; a session's PRs stack vertically. Mirrors the minimal
// single-PR rail the parallel-agent tools converged on (emdash, conductor),
// repeated per PR rather than collapsed into one aggregate widget.
function PRCard({ pr }: { pr: PullRequestFacts }) {
return (
<div className="flex flex-col gap-2 rounded-[7px] border border-border bg-surface p-2.5">
<div className="flex items-center gap-2">
<GitPullRequest className="h-3.5 w-3.5 shrink-0 text-passive" aria-hidden="true" />
<span className="text-[12.5px] font-medium text-foreground">PR #{pr.number}</span>
<Badge variant="outline" className={cn("ml-auto h-5 px-1.5 text-[10px] font-medium", prStateTone[pr.state])}>
{pr.state}
</Badge>
{pr.url ? (
<a href={pr.url} target="_blank" rel="noopener noreferrer" className="inspector-section__link">
Open
</a>
) : null}
</div>
<dl className="inspector-kv">
<Row k="CI" v={pr.ci || "—"} mono />
<Row k="Merge" v={pr.mergeability || "—"} mono />
<Row k="Review" v={pr.review || "—"} mono />
</dl>
</div>
);
}
type TimelineTone = "now" | "good" | "warn" | "neutral";
function ActivityTimeline({ session }: { session: WorkspaceSession }) {
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
const detail = activityDetail(session.status);
events.push({
tone: "now",
node: (
<>
<span className="inspector-timeline__badge">
<InspectorStatusPill session={session} />
</span>
{detail ? <span className="inspector-timeline__detail"> {detail}</span> : null}
</>
),
ts: formatTimeCompact(session.updatedAt),
});
for (const pr of sortedPRs(session)) {
events.push({
tone: "good",
node: (
<>
Opened <b>PR #{pr.number}</b>
</>
),
ts: null,
});
}
events.push({
tone: "neutral",
node: <>Created worktree &amp; branch</>,
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
});
return (
<div className="inspector-timeline">
{events.map((event, index) => (
<div
key={index}
className={cn(
"inspector-timeline__ev",
event.tone === "now" && "inspector-timeline__ev--now",
event.tone === "good" && "inspector-timeline__ev--good",
event.tone === "warn" && "inspector-timeline__ev--warn",
)}
>
<span className="inspector-timeline__node" aria-hidden="true" />
<div className="inspector-timeline__et">{event.node}</div>
{event.ts ? <div className="inspector-timeline__ets">{event.ts}</div> : null}
</div>
))}
</div>
);
}
function activityDetail(status: SessionStatus): string | null {
switch (status) {
case "idle":
return "Session idle";
case "needs_input":
return "Waiting for your input";
case "working":
return null;
default:
return null;
}
}
const STATUS_PILL: Record<
ReturnType<typeof workerDisplayStatus> | "idle",
{ label: string; tone: string; breathe: boolean }
> = {
working: { label: "Working", tone: "var(--orange)", breathe: true },
needs_you: { label: "Input needed", tone: "var(--amber)", breathe: false },
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },
idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false },
};
function InspectorStatusPill({ session }: { session: WorkspaceSession }) {
const key = session.status === "idle" ? "idle" : workerDisplayStatus(session);
const { label, tone, breathe } = STATUS_PILL[key];
return (
<span
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold"
style={{
color: tone,
background: `color-mix(in srgb, ${tone} 7%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tone} 25%, transparent)`,
}}
>
<span
className={cn("h-1.5 w-1.5 rounded-full", breathe && "animate-status-pulse")}
style={{ background: tone }}
/>
{label}
</span>
);
}
function ReviewsView({
session,
onOpenReviewerTerminal,
}: {
session: WorkspaceSession;
onOpenReviewerTerminal?: OpenReviewerTerminal;
}) {
const hasPr = Boolean(session.pullRequest);
const hasPr = sortedPRs(session).length > 0;
const queryClient = useQueryClient();
const [reviewNotice, setReviewNotice] = useState<string | null>(null);
const query = useQuery({
queryKey: ["session-pr", session.id],
enabled: hasPr,
queryFn: async () => {
const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/pr", {
params: { path: { sessionId: session.id } },
});
if (error) return [] as PRFacts[];
return data?.prs ?? [];
},
});
const reviewsQuery = useQuery({
queryKey: ["session-reviews", session.id],
enabled: hasPr,
@ -202,7 +336,6 @@ function SummaryView({
},
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.");
@ -213,56 +346,10 @@ function SummaryView({
}
},
});
const prFacts = query.data?.[0];
const branchLabel = session.branch || `session/${session.id}`;
const reviews = reviewsQuery.data?.reviews ?? [];
return (
<div role="tabpanel">
<Section
title="Pull request"
action={
prFacts?.url ? (
<a href={prFacts.url} target="_blank" rel="noopener noreferrer" className="inspector-section__link">
Open
</a>
) : undefined
}
>
{!hasPr ? (
<p className="inspector-empty">No pull request opened yet.</p>
) : query.isLoading ? (
<p className="inspector-empty">Loading pull request</p>
) : (
<div className="flex flex-col gap-2">
<div className="inspector-pr-summary">
<GitPullRequest className="h-3.5 w-3.5 shrink-0 text-passive" aria-hidden="true" />
<span className="inspector-pr-summary__title">PR #{prFacts?.number ?? session.pullRequest?.number}</span>
{prFacts ? (
<Badge
variant="outline"
className={cn(
"inspector-pr-summary__state h-5 px-1.5 text-[10px] font-medium",
prStateTone[prFacts.state],
)}
>
{prFacts.state}
</Badge>
) : null}
</div>
{prFacts ? (
<dl className="inspector-kv">
<Row k="CI" v={prFacts.ci || "—"} mono />
<Row k="Merge" v={prFacts.mergeability || "—"} mono />
<Row k="Review" v={prFacts.review || "—"} mono />
</dl>
) : (
<p className="inspector-empty">No enriched PR facts yet.</p>
)}
</div>
)}
</Section>
<Section title="Reviews">
<ReviewPanel
config={projectConfigQuery.data}
@ -277,19 +364,6 @@ function SummaryView({
session={session}
/>
</Section>
<Section title="Activity">
<ActivityTimeline reviews={reviews} session={session} />
</Section>
<Section title="Overview">
<dl className="inspector-kv">
<Row k="Agent" v={session.provider} mono />
<Row k="Branch" v={branchLabel} mono />
<Row k="Started" v={formatTimeCompact(session.createdAt ?? session.updatedAt)} mono />
<Row k="Session" v={session.id} mono />
</dl>
</Section>
</div>
);
}
@ -322,7 +396,7 @@ function ReviewPanel({
onTrigger: () => void;
onOpenTerminal?: OpenReviewerTerminal;
}) {
if (!session.pullRequest) {
if (sortedPRs(session).length === 0) {
return <p className="inspector-empty">No pull request opened yet.</p>;
}
if (isLoading) {
@ -433,204 +507,6 @@ function reviewStatus(review?: ReviewRun): {
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);
events.push({
tone: "now",
node: (
<>
<span className="inspector-timeline__badge">
<InspectorStatusPill session={session} />
</span>
{detail ? <span className="inspector-timeline__detail"> {detail}</span> : null}
</>
),
ts: formatTimeCompact(session.updatedAt),
});
if (session.pullRequest) {
events.push({
tone: "good",
node: (
<>
Opened <b>PR #{session.pullRequest.number}</b>
</>
),
ts: null,
});
}
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 &amp; branch</>,
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
});
return (
<div className="inspector-timeline">
{events.map((event, index) => (
<div
key={index}
className={cn(
"inspector-timeline__ev",
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" />
<div className="inspector-timeline__et">{event.node}</div>
{event.ts ? <div className="inspector-timeline__ets">{event.ts}</div> : null}
</div>
))}
</div>
);
}
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":
return "Session idle";
case "needs_input":
return "Waiting for your input";
case "working":
return null;
default:
return null;
}
}
const STATUS_PILL: Record<
ReturnType<typeof workerDisplayStatus> | "idle",
{ label: string; tone: string; breathe: boolean }
> = {
working: { label: "Working", tone: "var(--orange)", breathe: true },
needs_you: { label: "Input needed", tone: "var(--amber)", breathe: false },
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },
idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false },
};
function InspectorStatusPill({ session }: { session: WorkspaceSession }) {
const key = session.status === "idle" ? "idle" : workerDisplayStatus(session);
const { label, tone, breathe } = STATUS_PILL[key];
return (
<span
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold"
style={{
color: tone,
background: `color-mix(in srgb, ${tone} 7%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tone} 25%, transparent)`,
}}
>
<span
className={cn("h-1.5 w-1.5 rounded-full", breathe && "animate-status-pulse")}
style={{ background: tone }}
/>
{label}
</span>
);
}
function ChangesView({ session }: { session: WorkspaceSession }) {
const files = session.changedFiles ?? [];
return (
<div role="tabpanel" className="flex min-h-0 flex-1 flex-col">
<div className="inspector-changes__head">
<span className="inspector-changes__title">Changed</span>
<span className="inspector-changes__count">{files.length}</span>
</div>
<div className="inspector-changes__actions">
<button className="inspector-changes__action" type="button">
All files
</button>
<button className="inspector-changes__action inspector-changes__action--danger" type="button">
<Trash2 aria-hidden="true" />
Discard all
</button>
<button className="inspector-changes__action inspector-changes__action--end" type="button">
<Plus aria-hidden="true" />
Stage all
</button>
</div>
<div className="inspector-changes__list">
{files.length === 0 ? (
<p className="inspector-empty inspector-empty--center">No changes yet.</p>
) : (
files.map((file) => (
<div className="inspector-changes__file" key={file.path}>
<span className="inspector-changes__path">{file.path}</span>
<span className="inspector-changes__add">+{file.additions}</span>
<span className="inspector-changes__del">{file.deletions}</span>
<Square className={cn("inspector-changes__stage", file.staged && "is-staged")} aria-hidden="true" />
</div>
))
)}
</div>
<div className="inspector-changes__commit">
<input
className="inspector-changes__input"
defaultValue={session.commitMessage ?? ""}
key={session.id}
placeholder="Commit message"
/>
<textarea className="inspector-changes__textarea" placeholder="Description" rows={2} />
<Button className="w-full" disabled={files.length === 0} variant="primary">
<GitCommitHorizontal aria-hidden="true" />
Commit &amp; Push
</Button>
</div>
<div className="inspector-changes__footer">
<GitBranch aria-hidden="true" />
<span className="inspector-changes__branch">{session.branch || "—"}</span>
<button className="inspector-changes__pr" type="button">
<Plus aria-hidden="true" />
<GitPullRequest aria-hidden="true" />
Create PR
</button>
</div>
</div>
);
}
function BrowserView() {
return (
<div role="tabpanel">

View File

@ -29,6 +29,7 @@ const { workspaces, panels } = vi.hoisted(() => {
branch: "ao/sess-1",
status: "working",
updatedAt: "2026-06-10T00:00:00Z",
prs: [],
} satisfies WorkspaceSession;
const orchestrator = {
...worker,

View File

@ -2,7 +2,7 @@ import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Plus } from "lucide-react";
import { type AttentionZone, type WorkspaceSession, attentionZone, workerSessions } from "../types/workspace";
import { type AttentionZone, type WorkspaceSession, attentionZone, openPRs, workerSessions } from "../types/workspace";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead } from "./DashboardSubhead";
import { OrchestratorIcon } from "./icons";
@ -255,6 +255,19 @@ function ZoneColumn({
);
}
// One-line PR summary for the card footer. A session can own several PRs, so
// collapse to a count once past one; detail lives in the inspector stack.
function prSummary(session: WorkspaceSession): string {
const total = session.prs.length;
if (total === 0) return "no PR yet";
if (total === 1) {
const pr = session.prs[0];
return `PR #${pr.number} · ${pr.state}`;
}
const open = openPRs(session).length;
return open > 0 ? `${total} PRs · ${open} open` : `${total} PRs`;
}
function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) {
const badge = sessionBadge(session);
const branch = session.branch || "";
@ -285,7 +298,7 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: (
</div>
{showBranch && <div className="px-[13px] pb-2.5 font-mono text-[10.5px] text-passive">{branch}</div>}
<div className="border-t border-border px-[13px] py-2 font-mono text-[10.5px] text-passive">
{session.pullRequest ? `PR #${session.pullRequest.number} · ${session.pullRequest.state}` : "no PR yet"}
{prSummary(session)}
</div>
</button>
);

View File

@ -32,6 +32,7 @@ const worker: WorkspaceSession = {
branch: "ao/sess-1",
status: "working",
updatedAt: "2026-06-10T00:00:00Z",
prs: [],
};
function renderKill(session: WorkspaceSession = worker) {

View File

@ -17,6 +17,7 @@ const session: WorkspaceSession = {
branch: "main",
status: "working",
updatedAt: "now",
prs: [],
};
type FakeMux = {

View File

@ -20,15 +20,10 @@ function wrapper({ children }: { children: ReactNode }) {
function respondWith(payload: {
projects?: { data?: unknown; error?: unknown };
sessions?: { data?: unknown; error?: unknown };
prsBySession?: Record<string, { data?: unknown; error?: unknown }>;
}) {
getMock.mockImplementation(async (url: string, options?: { params?: { path?: { sessionId?: string } } }) => {
getMock.mockImplementation(async (url: string) => {
if (url === "/api/v1/projects") return payload.projects ?? { data: { projects: [] }, error: undefined };
if (url === "/api/v1/sessions") return payload.sessions ?? { data: { sessions: [] }, error: undefined };
if (url === "/api/v1/sessions/{sessionId}/pr") {
const sessionId = options?.params?.path?.sessionId ?? "";
return payload.prsBySession?.[sessionId] ?? { data: { sessionId, prs: [] }, error: undefined };
}
throw new Error(`unexpected GET ${url}`);
});
}
@ -96,7 +91,7 @@ describe("useWorkspaceQuery", () => {
});
});
it("hydrates each session's pullRequest from the /pr endpoint (issue #251)", async () => {
it("maps each session's prs straight from the session list", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
@ -108,6 +103,18 @@ describe("useWorkspaceQuery", () => {
status: "pr_open",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
prs: [
{
number: 278,
state: "open",
url: "u",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
{
id: "sess-2",
@ -120,87 +127,26 @@ describe("useWorkspaceQuery", () => {
},
error: undefined,
},
prsBySession: {
"sess-1": {
data: {
sessionId: "sess-1",
prs: [
{
number: 278,
state: "open",
url: "u",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
},
},
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const sessions = result.current.data?.[0].sessions ?? [];
expect(sessions[0].pullRequest).toEqual({ number: 278, state: "open" });
// No PR for the endpoint's empty response → undefined, so the empty states render.
expect(sessions[1].pullRequest).toBeUndefined();
});
it("treats a per-session PR fetch error as no PR without failing the query", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
status: "pr_open",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
expect(sessions[0].prs).toEqual([
{
number: 278,
state: "open",
url: "u",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
},
prsBySession: { "sess-1": { data: undefined, error: new Error("pr backend down") } },
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.[0].sessions[0].pullRequest).toBeUndefined();
});
it("skips the PR fetch for terminated sessions", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
status: "merged",
isTerminated: true,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
},
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(getMock).not.toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/pr", expect.anything());
expect(result.current.data?.[0].sessions[0].pullRequest).toBeUndefined();
]);
// A session with no PRs maps to an empty stack, so the empty states render.
expect(sessions[1].prs).toEqual([]);
});
it("marks terminated sessions regardless of their reported status", async () => {

View File

@ -1,25 +1,31 @@
import { useQuery } from "@tanstack/react-query";
import type { components } from "../../api/schema";
import { apiClient } from "../lib/api-client";
import { mockWorkspaces } from "../lib/mock-data";
import { toAgentProvider, toSessionStatus, type WorkspaceSession, type WorkspaceSummary } from "../types/workspace";
import {
type PRState,
type PullRequestFacts,
toAgentProvider,
toSessionStatus,
type WorkspaceSummary,
} from "../types/workspace";
function toPullRequestFacts(pr: components["schemas"]["SessionPRFacts"]): PullRequestFacts {
return {
url: pr.url,
number: pr.number,
state: pr.state as PRState,
ci: pr.ci,
review: pr.review,
mergeability: pr.mergeability,
reviewComments: pr.reviewComments,
updatedAt: pr.updatedAt,
};
}
export const workspaceQueryKey = ["workspaces"] as const;
const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1";
// GET /sessions/{sessionId}/pr is the single source of truth for PR facts — no
// PR data rides on the session list — so we hydrate each session's lightweight
// {number, state} here, centrally, for every consumer (Summary, Board, PR page,
// Sidebar) that reads this query's cache. A per-session failure is treated as
// "no PR" rather than failing the whole workspace query.
async function fetchSessionPR(sessionId: string): Promise<WorkspaceSession["pullRequest"]> {
const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/pr", {
params: { path: { sessionId } },
});
if (error) return undefined;
const pr = data?.prs?.[0];
return pr ? { number: pr.number, state: pr.state } : undefined;
}
async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
if (usePreviewData) {
return mockWorkspaces;
@ -30,21 +36,11 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
if (projectsError || sessionsError) throw projectsError ?? sessionsError;
const sessions = sessionsData?.sessions ?? [];
// Skip terminated sessions — their PRs are archived and the call is wasted.
const prBySession = new Map(
await Promise.all(
sessions
.filter((session) => !session.isTerminated)
.map(async (session) => [session.id, await fetchSessionPR(session.id)] as const),
),
);
return (projectsData?.projects ?? []).map((project) => ({
id: project.id,
name: project.name,
path: project.path,
sessions: sessions
sessions: (sessionsData?.sessions ?? [])
.filter((session) => session.projectId === project.id)
.map((session) => ({
id: session.id,
@ -58,7 +54,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
status: toSessionStatus(session.status, session.isTerminated),
createdAt: session.createdAt,
updatedAt: session.updatedAt,
pullRequest: prBySession.get(session.id),
prs: (session.prs ?? []).map(toPullRequestFacts),
})),
}));
}

View File

@ -1,8 +1,21 @@
import type { WorkspaceSummary } from "../types/workspace";
import type { PRState, PullRequestFacts, WorkspaceSummary } from "../types/workspace";
const now = new Date().toISOString();
const hoursAgo = (hours: number) => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
// Single-PR preview helper. Sessions that own a stack (stacked-auth) inline
// their facts instead; the daemon aggregates per-PR CI/review server-side.
const pr = (number: number, state: PRState, ci = "passing"): PullRequestFacts => ({
url: `https://github.com/me/pull/${number}`,
number,
state,
ci,
review: state === "merged" ? "approved" : "none",
mergeability: "mergeable",
reviewComments: false,
updatedAt: now,
});
export const mockWorkspaces: WorkspaceSummary[] = [
{
id: "api-gateway",
@ -28,6 +41,52 @@ export const mockWorkspaces: WorkspaceSummary[] = [
},
],
commitMessage: "refactor terminal mux",
prs: [],
},
{
id: "stacked-auth",
terminalHandleId: "stacked-auth/terminal_0",
workspaceId: "api-gateway",
workspaceName: "api-gateway",
title: "auth stack",
provider: "claude-code",
branch: "feat/ns",
status: "review_pending",
updatedAt: now,
createdAt: hoursAgo(2),
// One session owning a stack: open root, draft child, merged base.
prs: [
{
url: "https://github.com/me/api-gateway/pull/41",
number: 41,
state: "open",
ci: "passing",
review: "approved",
mergeability: "mergeable",
reviewComments: false,
updatedAt: now,
},
{
url: "https://github.com/me/api-gateway/pull/42",
number: 42,
state: "draft",
ci: "pending",
review: "none",
mergeability: "unknown",
reviewComments: false,
updatedAt: now,
},
{
url: "https://github.com/me/api-gateway/pull/40",
number: 40,
state: "merged",
ci: "passing",
review: "approved",
mergeability: "mergeable",
reviewComments: false,
updatedAt: hoursAgo(1),
},
],
},
{
id: "fix-auth-timeouts",
@ -39,7 +98,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "ci_failed",
updatedAt: hoursAgo(1),
createdAt: hoursAgo(6),
pullRequest: { number: 184, state: "open" },
prs: [pr(184, "open", "failing")],
},
{
id: "rate-limit-headers",
@ -51,7 +110,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "review_pending",
updatedAt: hoursAgo(2),
createdAt: hoursAgo(9),
pullRequest: { number: 185, state: "open" },
prs: [pr(185, "open")],
},
],
},
@ -70,6 +129,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "needs_input",
updatedAt: now,
createdAt: hoursAgo(4),
prs: [],
},
{
id: "shader-cache",
@ -85,6 +145,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
{ path: "src/render/shader-cache.ts", additions: 86, deletions: 12 },
{ path: "src/render/webgl-context.ts", additions: 24, deletions: 5 },
],
prs: [],
},
{
id: "texture-leak",
@ -96,7 +157,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "ci_failed",
updatedAt: hoursAgo(1.5),
createdAt: hoursAgo(7),
pullRequest: { number: 51, state: "open" },
prs: [pr(51, "open", "failing")],
},
{
id: "review-camera-pan",
@ -108,7 +169,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "review_pending",
updatedAt: hoursAgo(3),
createdAt: hoursAgo(10),
pullRequest: { number: 52, state: "open" },
prs: [pr(52, "open")],
},
{
id: "draft-webgpu-probe",
@ -120,7 +181,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "draft",
updatedAt: hoursAgo(5),
createdAt: hoursAgo(12),
pullRequest: { number: 53, state: "draft" },
prs: [pr(53, "draft")],
},
{
id: "merge-frame-stats",
@ -132,7 +193,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "mergeable",
updatedAt: hoursAgo(0.25),
createdAt: hoursAgo(14),
pullRequest: { number: 54, state: "open" },
prs: [pr(54, "open")],
},
{
id: "approved-pixel-ratio",
@ -144,7 +205,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "approved",
updatedAt: hoursAgo(2.5),
createdAt: hoursAgo(16),
pullRequest: { number: 55, state: "open" },
prs: [pr(55, "open")],
},
{
id: "input-pointer-lock",
@ -156,7 +217,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "changes_requested",
updatedAt: hoursAgo(4),
createdAt: hoursAgo(18),
pullRequest: { number: 56, state: "open" },
prs: [pr(56, "open")],
},
],
},
@ -175,6 +236,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "working",
updatedAt: hoursAgo(0.75),
createdAt: hoursAgo(3),
prs: [],
},
{
id: "profile-sheet",
@ -186,7 +248,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "mergeable",
updatedAt: hoursAgo(1.25),
createdAt: hoursAgo(8),
pullRequest: { number: 92, state: "open" },
prs: [pr(92, "open")],
},
],
},
@ -205,7 +267,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "review_pending",
updatedAt: hoursAgo(2.25),
createdAt: hoursAgo(11),
pullRequest: { number: 117, state: "open" },
prs: [pr(117, "open")],
},
{
id: "tax-id-validation",
@ -217,6 +279,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "needs_input",
updatedAt: hoursAgo(1.75),
createdAt: hoursAgo(5),
prs: [],
},
],
},

View File

@ -1214,186 +1214,3 @@ body.is-resizing-x [data-slot="sidebar-container"] {
font-family: var(--font-mono);
margin-top: 2px;
}
/* Changes tab — git rail (second screenshot) */
.inspector-changes__head {
display: flex;
align-items: center;
gap: 8px;
margin: -6px -6px 10px;
padding: 0 6px 10px;
border-bottom: 1px solid var(--border);
}
.inspector-changes__title {
font-size: 13px;
font-weight: 600;
color: var(--fg);
}
.inspector-changes__count {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-passive);
}
.inspector-changes__actions {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
font-size: 12px;
}
.inspector-changes__action {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--fg-muted);
transition: color 0.12s ease;
}
.inspector-changes__action:hover {
color: var(--fg);
}
.inspector-changes__action--danger {
color: var(--red);
}
.inspector-changes__action--danger:hover {
opacity: 0.85;
}
.inspector-changes__action--end {
margin-left: auto;
}
.inspector-changes__list {
min-height: 120px;
margin-bottom: 12px;
}
.inspector-changes__file {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 8px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 12px;
transition: background 0.12s ease;
}
.inspector-changes__file:hover {
background: var(--interactive-hover);
}
.inspector-changes__path {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
}
.inspector-changes__add {
flex-shrink: 0;
color: var(--green);
}
.inspector-changes__del {
flex-shrink: 0;
color: var(--red);
}
.inspector-changes__stage {
width: 13px;
height: 13px;
flex-shrink: 0;
color: var(--fg-passive);
}
.inspector-changes__stage.is-staged {
color: var(--accent);
}
.inspector-changes__commit {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.inspector-changes__input,
.inspector-changes__textarea {
width: 100%;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
padding: 6px 10px;
font-size: 12.5px;
color: var(--fg);
}
.inspector-changes__input::placeholder,
.inspector-changes__textarea::placeholder {
color: var(--fg-passive);
}
.inspector-changes__input:focus-visible,
.inspector-changes__textarea:focus-visible {
border-color: var(--accent);
outline: 2px solid var(--accent-weak);
outline-offset: 0;
}
.inspector-changes__textarea {
resize: none;
}
.inspector-changes__footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-passive);
}
.inspector-changes__footer svg {
width: 12px;
height: 12px;
flex-shrink: 0;
}
.inspector-changes__branch {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-muted);
}
.inspector-changes__pr {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 4px;
border-radius: 6px;
border: 1px solid var(--border);
padding: 2px 8px;
color: var(--fg-muted);
transition: background 0.12s ease;
}
.inspector-changes__pr:hover {
background: var(--interactive-hover);
}

View File

@ -8,7 +8,13 @@ import {
toSessionStatus,
workerDisplayStatus,
workerStatusPulses,
openPRs,
mergedPRCount,
primaryPR,
sortedPRs,
type AttentionZone,
type PRState,
type PullRequestFacts,
type SessionStatus,
type WorkspaceSession,
type WorkspaceSummary,
@ -24,10 +30,21 @@ function sessionWith(overrides: Partial<WorkspaceSession>): WorkspaceSession {
branch: "feat/x",
status: "working",
updatedAt: "2026-01-01T00:00:00Z",
prs: [],
...overrides,
};
}
const pr = (overrides: Partial<PullRequestFacts> & { number: number; state: PRState }): PullRequestFacts => ({
url: `https://example.com/pr/${overrides.number}`,
ci: "passing",
review: "approved",
mergeability: "mergeable",
reviewComments: false,
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
});
describe("toSessionStatus", () => {
it("passes through a known status", () => {
expect(toSessionStatus("mergeable")).toBe("mergeable");
@ -141,6 +158,41 @@ describe("toAgentProvider", () => {
});
});
describe("PR helpers", () => {
const session = sessionWith({
prs: [
pr({ number: 41, state: "open" }),
pr({ number: 42, state: "draft" }),
pr({ number: 40, state: "merged" }),
pr({ number: 39, state: "closed" }),
],
});
it("sortedPRs orders open, draft, merged, closed then by number", () => {
expect(sortedPRs(session).map((p) => p.number)).toEqual([41, 42, 40, 39]);
});
it("openPRs returns open and draft only", () => {
expect(
openPRs(session)
.map((p) => p.number)
.sort(),
).toEqual([41, 42]);
});
it("mergedPRCount counts merged PRs", () => {
expect(mergedPRCount(session)).toBe(1);
});
it("primaryPR is the highest-priority PR (open before merged)", () => {
expect(primaryPR(session)?.number).toBe(41);
});
it("primaryPR is undefined when there are no PRs", () => {
expect(primaryPR(sessionWith({ prs: [] }))).toBeUndefined();
});
});
describe("attentionZone", () => {
const cases: Array<[SessionStatus, AttentionZone]> = [
["mergeable", "merge"],

View File

@ -67,6 +67,26 @@ export type ChangedFile = {
export type SessionKind = "worker" | "orchestrator";
/** Lifecycle state of a single pull request, mirrors the daemon's enum. */
export type PRState = "open" | "draft" | "merged" | "closed";
/**
* One attributed pull request, mirroring the daemon's SessionPRFacts wire shape.
* A session can own many (e.g. a stack), so {@link WorkspaceSession.prs} is a
* list. The wire carries no source/target branch or parent pointer, so the UI
* renders a flat list of PRs, not a stack tree.
*/
export type PullRequestFacts = {
url: string;
number: number;
state: PRState;
ci: string;
review: string;
mergeability: string;
reviewComments: boolean;
updatedAt: string;
};
export type WorkspaceSession = {
id: string;
terminalHandleId?: string;
@ -85,10 +105,12 @@ export type WorkspaceSession = {
changedFiles?: ChangedFile[];
/** Pre-filled commit subject for the Git rail, when known. */
commitMessage?: string;
pullRequest?: {
number: number;
state: "open" | "draft" | "merged" | "closed";
};
/**
* The session's attributed pull requests. One session can own many (a stack
* or independent PRs); empty when none are open yet. Status aggregation is
* done server-side, so {@link status} already reflects all of these.
*/
prs: PullRequestFacts[];
/**
* Display status as derived by the daemon at read time. Optional override; when
* absent it is derived from {@link SessionStatus} via {@link workerDisplayStatus}.
@ -119,6 +141,28 @@ export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplaySta
}
}
// Open PRs (actionable) sort above merged/closed; ties break by number.
const prStateRank: Record<PRState, number> = { open: 0, draft: 1, merged: 2, closed: 3 };
/** A session's PRs ordered actionable-first (open, draft, merged, closed). */
export function sortedPRs(session: WorkspaceSession): PullRequestFacts[] {
return [...session.prs].sort((a, b) => prStateRank[a.state] - prStateRank[b.state] || a.number - b.number);
}
/** PRs still in flight (open or draft). */
export function openPRs(session: WorkspaceSession): PullRequestFacts[] {
return session.prs.filter((pr) => pr.state === "open" || pr.state === "draft");
}
export function mergedPRCount(session: WorkspaceSession): number {
return session.prs.filter((pr) => pr.state === "merged").length;
}
/** The highest-priority PR for compact one-line surfaces (board card, sidebar). */
export function primaryPR(session: WorkspaceSession): PullRequestFacts | undefined {
return sortedPRs(session)[0];
}
export function isOrchestratorSession(session: WorkspaceSession): boolean {
return session.kind === "orchestrator" || session.id.endsWith("-orchestrator");
}
@ -228,10 +272,6 @@ export type WorkspaceSummary = {
additions: number;
deletions: number;
};
pullRequest?: {
number: number;
state: "open" | "draft" | "merged" | "closed";
};
sessions: WorkspaceSession[];
};