diff --git a/frontend/e2e/multi-pr.spec.ts b/frontend/e2e/multi-pr.spec.ts new file mode 100644 index 00000000..6fdb1f44 --- /dev/null +++ b/frontend/e2e/multi-pr.spec.ts @@ -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(); +}); diff --git a/frontend/src/renderer/components/PullRequestsPage.test.tsx b/frontend/src/renderer/components/PullRequestsPage.test.tsx new file mode 100644 index 00000000..90a770bb --- /dev/null +++ b/frontend/src/renderer/components/PullRequestsPage.test.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/renderer/components/PullRequestsPage.tsx b/frontend/src/renderer/components/PullRequestsPage.tsx index a4d01911..bded8cf0 100644 --- a/frontend/src/renderer/components/PullRequestsPage.tsx +++ b/frontend/src/renderer/components/PullRequestsPage.tsx @@ -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["state"]; - const stateTone: Record = { open: "border-success/40 bg-success/10 text-success", draft: "border-border bg-raised text-muted-foreground", @@ -23,26 +21,22 @@ const stateTone: Record = { const stateRank: Record = { 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 } => - 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 (
@@ -68,7 +62,7 @@ export function PullRequestsPage() { {rows.map((row) => ( 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 ( - #{row.number} + #{row.pr.number}
{row.session.title}
@@ -132,8 +126,8 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
- - {row.state} + + {row.pr.state} e.stopPropagation()}> diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx new file mode 100644 index 00000000..a42c9387 --- /dev/null +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, 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(); + + 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(); + + 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(); + expect(screen.getByText("No pull request opened yet.")).toBeInTheDocument(); + }); + + it("links each PR to its url", () => { + render(); + 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(); + const tabs = screen.getAllByRole("tab").map((el) => el.textContent?.trim()); + expect(tabs).toEqual(["Summary", "Reviews", "Browser"]); + }); + + it("switches to the Reviews tab and shows the empty placeholder", () => { + render(); + fireEvent.click(screen.getByRole("tab", { name: /Reviews/ })); + expect(screen.getByText("No reviews yet.")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index dee4cf69..e1767a9e 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -1,17 +1,12 @@ -import { useQuery } from "@tanstack/react-query"; import { useState, type ReactNode } from "react"; -import { GitBranch, GitCommitHorizontal, GitPullRequest, Plus, Square, Trash2 } from "lucide-react"; -import type { components } from "../../api/schema"; -import { apiClient } from "../lib/api-client"; +import { GitPullRequest } from "lucide-react"; 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 InspectorView = "summary" | "changes" | "browser"; +type InspectorView = "summary" | "reviews" | "browser"; const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [ { @@ -29,15 +24,11 @@ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [ ), }, { - id: "changes", - label: "Changes", + id: "reviews", + label: "Reviews", icon: ( ), }, @@ -54,7 +45,7 @@ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [ }, ]; -const prStateTone: Record = { +const prStateTone: Record = { 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", @@ -62,8 +53,7 @@ const prStateTone: Record = { }; /** - * 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 }: { session?: WorkspaceSession }) { const [view, setView] = useState("summary"); @@ -98,7 +88,7 @@ export function SessionInspector({ session }: { session?: WorkspaceSession }) {
{view === "summary" ? : null} - {view === "changes" ? : null} + {view === "reviews" ? : null} {view === "browser" ? : null}
@@ -118,62 +108,19 @@ function Section({ title, action, children }: { title: string; action?: ReactNod } function SummaryView({ session }: { session: WorkspaceSession }) { - const hasPr = Boolean(session.pullRequest); - 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 prFacts = query.data?.[0]; + const prs = sortedPRs(session); const branchLabel = session.branch || `session/${session.id}`; return (
-
- Open ↗ - - ) : undefined - } - > - {!hasPr ? ( +
1 ? `Pull requests (${prs.length})` : "Pull request"}> + {prs.length === 0 ? (

No pull request opened yet.

- ) : query.isLoading ? ( -

Loading pull request…

) : ( -
-
-
- {prFacts ? ( -
- - - -
- ) : ( -

No enriched PR facts yet.

- )} +
+ {prs.map((pr) => ( + + ))}
)}
@@ -194,6 +141,33 @@ function SummaryView({ session }: { session: WorkspaceSession }) { ); } +// 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 ( +
+
+
+
+ + + +
+
+ ); +} + type TimelineTone = "now" | "good" | "warn" | "neutral"; function ActivityTimeline({ session }: { session: WorkspaceSession }) { @@ -213,12 +187,12 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) { ts: formatTimeCompact(session.updatedAt), }); - if (session.pullRequest) { + for (const pr of sortedPRs(session)) { events.push({ tone: "good", node: ( <> - Opened PR #{session.pullRequest.number} + Opened PR #{pr.number} ), ts: null, @@ -298,67 +272,15 @@ function InspectorStatusPill({ session }: { session: WorkspaceSession }) { ); } -function ChangesView({ session }: { session: WorkspaceSession }) { - const files = session.changedFiles ?? []; - +function ReviewsView() { return ( -
-
- Changed - {files.length} -
- -
- - - -
- -
- {files.length === 0 ? ( -

No changes yet.

- ) : ( - files.map((file) => ( -
- {file.path} - +{file.additions} - −{file.deletions} -
- )) - )} -
- -
- -