diff --git a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx new file mode 100644 index 00000000..b3738953 --- /dev/null +++ b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx @@ -0,0 +1,100 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +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. +const { getMock, navigateMock } = vi.hoisted(() => ({ getMock: vi.fn(), navigateMock: vi.fn() })); + +vi.mock("../../lib/api-client", () => ({ + apiClient: { GET: getMock, POST: vi.fn() }, + apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"), +})); + +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigateMock }; +}); + +import { SessionsBoard } from "../../components/SessionsBoard"; +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 } } }) => { + if (url === "/api/v1/projects") { + return { data: { projects: [{ id: "proj-1", name: "my-app", path: "/repo/my-app" }] }, error: undefined }; + } + if (url === "/api/v1/sessions") { + return { + data: { + sessions: [ + { + id: "sess-1", + projectId: "proj-1", + displayName: "fix the bug", + harness: "claude-code", + 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", + }, + ], + }, + error: undefined, + }; + } + throw new Error(`unexpected GET ${url}`); + }); +} + +function renderWithProviders(node: ReactNode) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render({node}); +} + +beforeEach(() => { + getMock.mockReset(); + navigateMock.mockReset(); + respondWithProjectAndPR(); +}); + +describe("PR hydration for a normal project (#251)", () => { + it("renders the PR on the Board card instead of 'no PR yet'", async () => { + renderWithProviders(); + + expect(await screen.findByText("PR #278 · open")).toBeInTheDocument(); + expect(screen.queryByText("no PR yet")).not.toBeInTheDocument(); + }); + + it("lists the session on the PR page instead of being empty", async () => { + renderWithProviders(); + + expect(await screen.findByText("#278")).toBeInTheDocument(); + expect(screen.queryByText("No open pull requests.")).not.toBeInTheDocument(); + expect(screen.getByText("fix the bug")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index 74a75e57..d25127ee 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -20,10 +20,15 @@ function wrapper({ children }: { children: ReactNode }) { function respondWith(payload: { projects?: { data?: unknown; error?: unknown }; sessions?: { data?: unknown; error?: unknown }; + prsBySession?: Record; }) { - getMock.mockImplementation(async (url: string) => { + getMock.mockImplementation(async (url: string, options?: { params?: { path?: { sessionId?: 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}`); }); } @@ -91,6 +96,113 @@ describe("useWorkspaceQuery", () => { }); }); + it("hydrates each session's pullRequest from the /pr endpoint (issue #251)", 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", + }, + { + id: "sess-2", + projectId: "proj-1", + status: "working", + isTerminated: false, + updatedAt: "2026-06-10T16:15:04Z", + }, + ], + }, + 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, + }, + 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(); + }); + it("marks terminated sessions regardless of their reported status", async () => { respondWith({ projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined }, diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index 12b432f8..3fab7fe3 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -1,11 +1,25 @@ import { useQuery } from "@tanstack/react-query"; import { apiClient } from "../lib/api-client"; import { mockWorkspaces } from "../lib/mock-data"; -import { toAgentProvider, toSessionStatus, type WorkspaceSummary } from "../types/workspace"; +import { toAgentProvider, toSessionStatus, type WorkspaceSession, type WorkspaceSummary } from "../types/workspace"; 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 { + 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 { if (usePreviewData) { return mockWorkspaces; @@ -16,11 +30,21 @@ async function fetchWorkspaces(): Promise { 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: (sessionsData?.sessions ?? []) + sessions: sessions .filter((session) => session.projectId === project.id) .map((session) => ({ id: session.id, @@ -34,6 +58,7 @@ async function fetchWorkspaces(): Promise { status: toSessionStatus(session.status, session.isTerminated), createdAt: session.createdAt, updatedAt: session.updatedAt, + pullRequest: prBySession.get(session.id), })), })); }