Skip to content

Commit 35ce40f

Browse files
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>
1 parent 96d1649 commit 35ce40f

2 files changed

Lines changed: 105 additions & 3 deletions

File tree

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

Lines changed: 78 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,78 @@ 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+
{ id: "sess-1", projectId: "proj-1", status: "pr_open", isTerminated: false, updatedAt: "2026-06-10T16:15:04Z" },
106+
{ id: "sess-2", projectId: "proj-1", status: "working", isTerminated: false, updatedAt: "2026-06-10T16:15:04Z" },
107+
],
108+
},
109+
error: undefined,
110+
},
111+
prsBySession: {
112+
"sess-1": {
113+
data: {
114+
sessionId: "sess-1",
115+
prs: [{ number: 278, state: "open", url: "u", ci: "passing", review: "approved", mergeability: "clean", reviewComments: false, updatedAt: "2026-06-10T16:15:04Z" }],
116+
},
117+
error: undefined,
118+
},
119+
},
120+
});
121+
122+
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
123+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
124+
125+
const sessions = result.current.data?.[0].sessions ?? [];
126+
expect(sessions[0].pullRequest).toEqual({ number: 278, state: "open" });
127+
// No PR for the endpoint's empty response → undefined, so the empty states render.
128+
expect(sessions[1].pullRequest).toBeUndefined();
129+
});
130+
131+
it("treats a per-session PR fetch error as no PR without failing the query", async () => {
132+
respondWith({
133+
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
134+
sessions: {
135+
data: {
136+
sessions: [
137+
{ id: "sess-1", projectId: "proj-1", status: "pr_open", isTerminated: false, updatedAt: "2026-06-10T16:15:04Z" },
138+
],
139+
},
140+
error: undefined,
141+
},
142+
prsBySession: { "sess-1": { data: undefined, error: new Error("pr backend down") } },
143+
});
144+
145+
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
146+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
147+
148+
expect(result.current.data?.[0].sessions[0].pullRequest).toBeUndefined();
149+
});
150+
151+
it("skips the PR fetch for terminated sessions", async () => {
152+
respondWith({
153+
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
154+
sessions: {
155+
data: {
156+
sessions: [
157+
{ id: "sess-1", projectId: "proj-1", status: "merged", isTerminated: true, updatedAt: "2026-06-10T16:15:04Z" },
158+
],
159+
},
160+
error: undefined,
161+
},
162+
});
163+
164+
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
165+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
166+
167+
expect(getMock).not.toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/pr", expect.anything());
168+
expect(result.current.data?.[0].sessions[0].pullRequest).toBeUndefined();
169+
});
170+
94171
it("marks terminated sessions regardless of their reported status", async () => {
95172
respondWith({
96173
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)