diff --git a/frontend/e2e/multi-pr.spec.ts b/frontend/e2e/multi-pr.spec.ts new file mode 100644 index 000000000..de8eeac80 --- /dev/null +++ b/frontend/e2e/multi-pr.spec.ts @@ -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(); +}); diff --git a/frontend/e2e/reviews-tab.spec.ts b/frontend/e2e/reviews-tab.spec.ts new file mode 100644 index 000000000..cd2bb2f91 --- /dev/null +++ b/frontend/e2e/reviews-tab.spec.ts @@ -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(); +}); diff --git a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx index b3738953a..34d9b305f 100644 --- a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx +++ b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx @@ -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", + }, + ], }, ], }, diff --git a/frontend/src/renderer/components/PullRequestsPage.test.tsx b/frontend/src/renderer/components/PullRequestsPage.test.tsx new file mode 100644 index 000000000..90a770bb6 --- /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 a4d01911d..bded8cf02 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 index 626728018..bb5179696 100644 --- a/frontend/src/renderer/components/SessionInspector.test.tsx +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -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(); + + 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"]); + }); +}); + +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(); + renderWithQuery( + , + ); + 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(); + renderWithQuery( + , + ); + 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(); + renderWithQuery( + , + ); + 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(); + await userEvent.click(screen.getByRole("tab", { name: /Reviews/ })); + + expect(await screen.findByText("No pull request opened yet.")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index 91e341dfb..87edce1d2 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -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: ( ), }, @@ -73,7 +54,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", @@ -81,8 +62,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, @@ -122,8 +102,8 @@ export function SessionInspector({
- {view === "summary" ? : null} - {view === "changes" ? : null} + {view === "summary" ? : null} + {view === "reviews" ? : null} {view === "browser" ? : null}
@@ -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 ( +
+
1 ? `Pull requests (${prs.length})` : "Pull request"}> + {prs.length === 0 ? ( +

No pull request opened yet.

+ ) : ( +
+ {prs.map((pr) => ( + + ))} +
+ )} +
+ +
+ +
+ +
+
+ + + + +
+
+
+ ); +} + +// 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 }) { + const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = []; + const detail = activityDetail(session.status); + + events.push({ + tone: "now", + node: ( + <> + + + + {detail ? — {detail} : null} + + ), + ts: formatTimeCompact(session.updatedAt), + }); + + for (const pr of sortedPRs(session)) { + events.push({ + tone: "good", + node: ( + <> + Opened PR #{pr.number} + + ), + ts: null, + }); + } + + events.push({ + tone: "neutral", + node: <>Created worktree & branch, + ts: formatTimeCompact(session.createdAt ?? session.updatedAt), + }); + + return ( +
+ {events.map((event, index) => ( +
+
+ ))} +
+ ); +} + +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 | "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 ( + + + {label} + + ); +} + +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(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 (
-
- Open ↗ - - ) : undefined - } - > - {!hasPr ? ( -

No pull request opened yet.

- ) : query.isLoading ? ( -

Loading pull request…

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

No enriched PR facts yet.

- )} -
- )} -
-
- -
- -
- -
-
- - - - -
-
); } @@ -322,7 +396,7 @@ function ReviewPanel({ onTrigger: () => void; onOpenTerminal?: OpenReviewerTerminal; }) { - if (!session.pullRequest) { + if (sortedPRs(session).length === 0) { return

No pull request opened yet.

; } if (isLoading) { @@ -433,204 +507,6 @@ function reviewStatus(review?: ReviewRun): { return { label: "Complete", tone: "success", icon: