Skip to content

Commit 6ac65d2

Browse files
neversettle17-101claudegithub-actions[bot]
authored
fix(frontend): populate session.pullRequest from /pr endpoint (#251) (#279)
* fix(frontend): populate session.pullRequest from /pr endpoint (#251) WorkspaceSession.pullRequest was declared but never populated, so every Boolean(session.pullRequest) check was dead: the Summary tab gated its PR fetch on it (never ran), and the Board card and /prs page read it directly (always empty). Hydrate the field centrally in fetchWorkspaces — the single place that builds session objects for the workspace, board, PR page, and sidebar — by fetching GET /sessions/{sessionId}/pr per non-terminated session in parallel and attaching {number, state}. A per-session fetch error degrades to "no PR" rather than failing the whole workspace query; terminated sessions are skipped. GET /sessions/{sessionId}/pr stays the single source of truth, so no new backend endpoint and no changes to the consuming components. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: format with prettier [skip ci] * test(frontend): verify PR hydration end-to-end for a normal project (#251) Drives the real useWorkspaceQuery + real SessionsBoard / PullRequestsPage for an ordinary project (from /api/v1/projects) whose session has an open PR, mocking only the HTTP client and router. Confirms PR facts fetched from /sessions/{id}/pr flow through the shared workspace cache into both the Board card ("PR #278 · open") and the PR page row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(frontend): move PR hydration integration test into __tests__/integration Cross-component integration test belongs in a dedicated folder, separate from the co-located unit tests. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 3986d48 commit 6ac65d2

3 files changed

Lines changed: 240 additions & 3 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { render, screen } from "@testing-library/react";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import type { ReactNode } from "react";
5+
6+
// Drives the real useWorkspaceQuery + real Board / PR-page consumers end to end
7+
// for a normal project, mocking only the HTTP client and the router. Proves the
8+
// #251 fix: PR facts fetched from /pr flow through the shared workspace cache
9+
// into every consumer.
10+
const { getMock, navigateMock } = vi.hoisted(() => ({ getMock: vi.fn(), navigateMock: vi.fn() }));
11+
12+
vi.mock("../../lib/api-client", () => ({
13+
apiClient: { GET: getMock, POST: vi.fn() },
14+
apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"),
15+
}));
16+
17+
vi.mock("@tanstack/react-router", async (importOriginal) => {
18+
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
19+
return { ...actual, useNavigate: () => navigateMock };
20+
});
21+
22+
import { SessionsBoard } from "../../components/SessionsBoard";
23+
import { PullRequestsPage } from "../../components/PullRequestsPage";
24+
25+
// One ordinary project with one worker session that has an open PR (#278).
26+
function respondWithProjectAndPR() {
27+
getMock.mockImplementation(async (url: string, options?: { params?: { path?: { sessionId?: string } } }) => {
28+
if (url === "/api/v1/projects") {
29+
return { data: { projects: [{ id: "proj-1", name: "my-app", path: "/repo/my-app" }] }, error: undefined };
30+
}
31+
if (url === "/api/v1/sessions") {
32+
return {
33+
data: {
34+
sessions: [
35+
{
36+
id: "sess-1",
37+
projectId: "proj-1",
38+
displayName: "fix the bug",
39+
harness: "claude-code",
40+
status: "pr_open",
41+
isTerminated: false,
42+
updatedAt: "2026-06-10T16:15:04Z",
43+
},
44+
],
45+
},
46+
error: undefined,
47+
};
48+
}
49+
if (url === "/api/v1/sessions/{sessionId}/pr") {
50+
expect(options?.params?.path?.sessionId).toBe("sess-1");
51+
return {
52+
data: {
53+
sessionId: "sess-1",
54+
prs: [
55+
{
56+
number: 278,
57+
state: "open",
58+
url: "https://github.com/aoagents/ReverbCode/pull/278",
59+
ci: "passing",
60+
review: "approved",
61+
mergeability: "clean",
62+
reviewComments: false,
63+
updatedAt: "2026-06-10T16:15:04Z",
64+
},
65+
],
66+
},
67+
error: undefined,
68+
};
69+
}
70+
throw new Error(`unexpected GET ${url}`);
71+
});
72+
}
73+
74+
function renderWithProviders(node: ReactNode) {
75+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
76+
render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
77+
}
78+
79+
beforeEach(() => {
80+
getMock.mockReset();
81+
navigateMock.mockReset();
82+
respondWithProjectAndPR();
83+
});
84+
85+
describe("PR hydration for a normal project (#251)", () => {
86+
it("renders the PR on the Board card instead of 'no PR yet'", async () => {
87+
renderWithProviders(<SessionsBoard />);
88+
89+
expect(await screen.findByText("PR #278 · open")).toBeInTheDocument();
90+
expect(screen.queryByText("no PR yet")).not.toBeInTheDocument();
91+
});
92+
93+
it("lists the session on the PR page instead of being empty", async () => {
94+
renderWithProviders(<PullRequestsPage />);
95+
96+
expect(await screen.findByText("#278")).toBeInTheDocument();
97+
expect(screen.queryByText("No open pull requests.")).not.toBeInTheDocument();
98+
expect(screen.getByText("fix the bug")).toBeInTheDocument();
99+
});
100+
});

frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ function wrapper({ children }: { children: ReactNode }) {
2020
function respondWith(payload: {
2121
projects?: { data?: unknown; error?: unknown };
2222
sessions?: { data?: unknown; error?: unknown };
23+
prsBySession?: Record<string, { data?: unknown; error?: unknown }>;
2324
}) {
24-
getMock.mockImplementation(async (url: string) => {
25+
getMock.mockImplementation(async (url: string, options?: { params?: { path?: { sessionId?: string } } }) => {
2526
if (url === "/api/v1/projects") return payload.projects ?? { data: { projects: [] }, error: undefined };
2627
if (url === "/api/v1/sessions") return payload.sessions ?? { data: { sessions: [] }, error: undefined };
28+
if (url === "/api/v1/sessions/{sessionId}/pr") {
29+
const sessionId = options?.params?.path?.sessionId ?? "";
30+
return payload.prsBySession?.[sessionId] ?? { data: { sessionId, prs: [] }, error: undefined };
31+
}
2732
throw new Error(`unexpected GET ${url}`);
2833
});
2934
}
@@ -91,6 +96,113 @@ describe("useWorkspaceQuery", () => {
9196
});
9297
});
9398

99+
it("hydrates each session's pullRequest from the /pr endpoint (issue #251)", async () => {
100+
respondWith({
101+
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
102+
sessions: {
103+
data: {
104+
sessions: [
105+
{
106+
id: "sess-1",
107+
projectId: "proj-1",
108+
status: "pr_open",
109+
isTerminated: false,
110+
updatedAt: "2026-06-10T16:15:04Z",
111+
},
112+
{
113+
id: "sess-2",
114+
projectId: "proj-1",
115+
status: "working",
116+
isTerminated: false,
117+
updatedAt: "2026-06-10T16:15:04Z",
118+
},
119+
],
120+
},
121+
error: undefined,
122+
},
123+
prsBySession: {
124+
"sess-1": {
125+
data: {
126+
sessionId: "sess-1",
127+
prs: [
128+
{
129+
number: 278,
130+
state: "open",
131+
url: "u",
132+
ci: "passing",
133+
review: "approved",
134+
mergeability: "clean",
135+
reviewComments: false,
136+
updatedAt: "2026-06-10T16:15:04Z",
137+
},
138+
],
139+
},
140+
error: undefined,
141+
},
142+
},
143+
});
144+
145+
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
146+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
147+
148+
const sessions = result.current.data?.[0].sessions ?? [];
149+
expect(sessions[0].pullRequest).toEqual({ number: 278, state: "open" });
150+
// No PR for the endpoint's empty response → undefined, so the empty states render.
151+
expect(sessions[1].pullRequest).toBeUndefined();
152+
});
153+
154+
it("treats a per-session PR fetch error as no PR without failing the query", async () => {
155+
respondWith({
156+
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
157+
sessions: {
158+
data: {
159+
sessions: [
160+
{
161+
id: "sess-1",
162+
projectId: "proj-1",
163+
status: "pr_open",
164+
isTerminated: false,
165+
updatedAt: "2026-06-10T16:15:04Z",
166+
},
167+
],
168+
},
169+
error: undefined,
170+
},
171+
prsBySession: { "sess-1": { data: undefined, error: new Error("pr backend down") } },
172+
});
173+
174+
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
175+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
176+
177+
expect(result.current.data?.[0].sessions[0].pullRequest).toBeUndefined();
178+
});
179+
180+
it("skips the PR fetch for terminated sessions", async () => {
181+
respondWith({
182+
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
183+
sessions: {
184+
data: {
185+
sessions: [
186+
{
187+
id: "sess-1",
188+
projectId: "proj-1",
189+
status: "merged",
190+
isTerminated: true,
191+
updatedAt: "2026-06-10T16:15:04Z",
192+
},
193+
],
194+
},
195+
error: undefined,
196+
},
197+
});
198+
199+
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
200+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
201+
202+
expect(getMock).not.toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/pr", expect.anything());
203+
expect(result.current.data?.[0].sessions[0].pullRequest).toBeUndefined();
204+
});
205+
94206
it("marks terminated sessions regardless of their reported status", async () => {
95207
respondWith({
96208
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },

frontend/src/renderer/hooks/useWorkspaceQuery.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
11
import { useQuery } from "@tanstack/react-query";
22
import { apiClient } from "../lib/api-client";
33
import { mockWorkspaces } from "../lib/mock-data";
4-
import { toAgentProvider, toSessionStatus, type WorkspaceSummary } from "../types/workspace";
4+
import { toAgentProvider, toSessionStatus, type WorkspaceSession, type WorkspaceSummary } from "../types/workspace";
55

66
export const workspaceQueryKey = ["workspaces"] as const;
77
const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1";
88

9+
// GET /sessions/{sessionId}/pr is the single source of truth for PR facts — no
10+
// PR data rides on the session list — so we hydrate each session's lightweight
11+
// {number, state} here, centrally, for every consumer (Summary, Board, PR page,
12+
// Sidebar) that reads this query's cache. A per-session failure is treated as
13+
// "no PR" rather than failing the whole workspace query.
14+
async function fetchSessionPR(sessionId: string): Promise<WorkspaceSession["pullRequest"]> {
15+
const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/pr", {
16+
params: { path: { sessionId } },
17+
});
18+
if (error) return undefined;
19+
const pr = data?.prs?.[0];
20+
return pr ? { number: pr.number, state: pr.state } : undefined;
21+
}
22+
923
async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
1024
if (usePreviewData) {
1125
return mockWorkspaces;
@@ -16,11 +30,21 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
1630

1731
if (projectsError || sessionsError) throw projectsError ?? sessionsError;
1832

33+
const sessions = sessionsData?.sessions ?? [];
34+
// Skip terminated sessions — their PRs are archived and the call is wasted.
35+
const prBySession = new Map(
36+
await Promise.all(
37+
sessions
38+
.filter((session) => !session.isTerminated)
39+
.map(async (session) => [session.id, await fetchSessionPR(session.id)] as const),
40+
),
41+
);
42+
1943
return (projectsData?.projects ?? []).map((project) => ({
2044
id: project.id,
2145
name: project.name,
2246
path: project.path,
23-
sessions: (sessionsData?.sessions ?? [])
47+
sessions: sessions
2448
.filter((session) => session.projectId === project.id)
2549
.map((session) => ({
2650
id: session.id,
@@ -34,6 +58,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
3458
status: toSessionStatus(session.status, session.isTerminated),
3559
createdAt: session.createdAt,
3660
updatedAt: session.updatedAt,
61+
pullRequest: prBySession.get(session.id),
3762
})),
3863
}));
3964
}

0 commit comments

Comments
 (0)