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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions frontend/e2e/multi-pr.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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();

const numbers = page.locator("tbody tr td:first-child");
await expect(numbers).toHaveText(["#41", "#42", "#40"]);

// 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();
});
95 changes: 95 additions & 0 deletions frontend/src/renderer/components/PullRequestsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
38 changes: 16 additions & 22 deletions frontend/src/renderer/components/PullRequestsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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">
Expand All @@ -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({
Expand All @@ -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;
Expand All @@ -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));
},
Expand All @@ -120,20 +114,20 @@ 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">
{[row.session.workspaceName, row.session.branch].filter(Boolean).join(" · ")}
</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()}>
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/renderer/components/SessionInspector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SessionInspector } from "./SessionInspector";
import type { PRState, PullRequestFacts, WorkspaceSession } from "../types/workspace";

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: "ws-1",
workspaceName: "my-app",
title: "do the thing",
provider: "claude-code",
kind: "worker",
branch: "feat/ns",
status: "review_pending",
updatedAt: "2026-06-15T00:00:00Z",
prs,
});

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",
]);
});
});
Loading