Skip to content
Merged
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
100 changes: 100 additions & 0 deletions frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("@tanstack/react-router")>();
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(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
}

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(<SessionsBoard />);

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(<PullRequestsPage />);

expect(await screen.findByText("#278")).toBeInTheDocument();
expect(screen.queryByText("No open pull requests.")).not.toBeInTheDocument();
expect(screen.getByText("fix the bug")).toBeInTheDocument();
});
});
114 changes: 113 additions & 1 deletion frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ 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) => {
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}`);
});
}
Expand Down Expand Up @@ -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 },
Expand Down
29 changes: 27 additions & 2 deletions frontend/src/renderer/hooks/useWorkspaceQuery.ts
Original file line number Diff line number Diff line change
@@ -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<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;
Expand All @@ -16,11 +30,21 @@ 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: (sessionsData?.sessions ?? [])
sessions: sessions
.filter((session) => session.projectId === project.id)
.map((session) => ({
id: session.id,
Expand All @@ -34,6 +58,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
status: toSessionStatus(session.status, session.isTerminated),
createdAt: session.createdAt,
updatedAt: session.updatedAt,
pullRequest: prBySession.get(session.id),
})),
}));
}
Expand Down
Loading