No pull request opened yet.
;
}
if (isLoading) {
@@ -433,204 +507,6 @@ function reviewStatus(review?: ReviewRun): {
return { label: "Complete", tone: "success", icon: | "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 ChangesView({ session }: { session: WorkspaceSession }) {
- const files = session.changedFiles ?? [];
-
- return (
-
-
- Changed
- {files.length}
-
-
-
-
- All files
-
-
-
- Discard all
-
-
-
- Stage all
-
-
-
-
- {files.length === 0 ? (
-
No changes yet.
- ) : (
- files.map((file) => (
-
- {file.path}
- +{file.additions}
- −{file.deletions}
-
-
- ))
- )}
-
-
-
-
-
-
-
- Commit & Push
-
-
-
-
-
-
{session.branch || "—"}
-
-
-
- Create PR
-
-
-
- );
-}
-
function BrowserView() {
return (
diff --git a/frontend/src/renderer/components/SessionView.test.tsx b/frontend/src/renderer/components/SessionView.test.tsx
index 0e4fc5f15..2e529b34d 100644
--- a/frontend/src/renderer/components/SessionView.test.tsx
+++ b/frontend/src/renderer/components/SessionView.test.tsx
@@ -29,6 +29,7 @@ const { workspaces, panels } = vi.hoisted(() => {
branch: "ao/sess-1",
status: "working",
updatedAt: "2026-06-10T00:00:00Z",
+ prs: [],
} satisfies WorkspaceSession;
const orchestrator = {
...worker,
diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx
index 9de6b4344..673380d49 100644
--- a/frontend/src/renderer/components/SessionsBoard.tsx
+++ b/frontend/src/renderer/components/SessionsBoard.tsx
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Plus } from "lucide-react";
-import { type AttentionZone, type WorkspaceSession, attentionZone, workerSessions } from "../types/workspace";
+import { type AttentionZone, type WorkspaceSession, attentionZone, openPRs, workerSessions } from "../types/workspace";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead } from "./DashboardSubhead";
import { OrchestratorIcon } from "./icons";
@@ -255,6 +255,19 @@ function ZoneColumn({
);
}
+// One-line PR summary for the card footer. A session can own several PRs, so
+// collapse to a count once past one; detail lives in the inspector stack.
+function prSummary(session: WorkspaceSession): string {
+ const total = session.prs.length;
+ if (total === 0) return "no PR yet";
+ if (total === 1) {
+ const pr = session.prs[0];
+ return `PR #${pr.number} · ${pr.state}`;
+ }
+ const open = openPRs(session).length;
+ return open > 0 ? `${total} PRs · ${open} open` : `${total} PRs`;
+}
+
function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) {
const badge = sessionBadge(session);
const branch = session.branch || "";
@@ -285,7 +298,7 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: (
{showBranch && {branch}
}
- {session.pullRequest ? `PR #${session.pullRequest.number} · ${session.pullRequest.state}` : "no PR yet"}
+ {prSummary(session)}
);
diff --git a/frontend/src/renderer/components/ShellTopbar.test.tsx b/frontend/src/renderer/components/ShellTopbar.test.tsx
index 64c9ddba2..3b2c9d419 100644
--- a/frontend/src/renderer/components/ShellTopbar.test.tsx
+++ b/frontend/src/renderer/components/ShellTopbar.test.tsx
@@ -32,6 +32,7 @@ const worker: WorkspaceSession = {
branch: "ao/sess-1",
status: "working",
updatedAt: "2026-06-10T00:00:00Z",
+ prs: [],
};
function renderKill(session: WorkspaceSession = worker) {
diff --git a/frontend/src/renderer/hooks/useTerminalSession.test.tsx b/frontend/src/renderer/hooks/useTerminalSession.test.tsx
index 06f76aaf5..d8efca527 100644
--- a/frontend/src/renderer/hooks/useTerminalSession.test.tsx
+++ b/frontend/src/renderer/hooks/useTerminalSession.test.tsx
@@ -17,6 +17,7 @@ const session: WorkspaceSession = {
branch: "main",
status: "working",
updatedAt: "now",
+ prs: [],
};
type FakeMux = {
diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx
index d25127eeb..309e60ecc 100644
--- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx
+++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx
@@ -20,15 +20,10 @@ 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, options?: { params?: { path?: { sessionId?: string } } }) => {
+ getMock.mockImplementation(async (url: 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}`);
});
}
@@ -96,7 +91,7 @@ describe("useWorkspaceQuery", () => {
});
});
- it("hydrates each session's pullRequest from the /pr endpoint (issue #251)", async () => {
+ it("maps each session's prs straight from the session list", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
@@ -108,6 +103,18 @@ describe("useWorkspaceQuery", () => {
status: "pr_open",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
+ prs: [
+ {
+ number: 278,
+ state: "open",
+ url: "u",
+ ci: "passing",
+ review: "approved",
+ mergeability: "clean",
+ reviewComments: false,
+ updatedAt: "2026-06-10T16:15:04Z",
+ },
+ ],
},
{
id: "sess-2",
@@ -120,87 +127,26 @@ describe("useWorkspaceQuery", () => {
},
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,
+ expect(sessions[0].prs).toEqual([
+ {
+ number: 278,
+ state: "open",
+ url: "u",
+ ci: "passing",
+ review: "approved",
+ mergeability: "clean",
+ reviewComments: false,
+ updatedAt: "2026-06-10T16:15:04Z",
},
- 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();
+ ]);
+ // A session with no PRs maps to an empty stack, so the empty states render.
+ expect(sessions[1].prs).toEqual([]);
});
it("marks terminated sessions regardless of their reported status", async () => {
diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts
index 3fab7fe3a..0b6b47764 100644
--- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts
+++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts
@@ -1,25 +1,31 @@
import { useQuery } from "@tanstack/react-query";
+import type { components } from "../../api/schema";
import { apiClient } from "../lib/api-client";
import { mockWorkspaces } from "../lib/mock-data";
-import { toAgentProvider, toSessionStatus, type WorkspaceSession, type WorkspaceSummary } from "../types/workspace";
+import {
+ type PRState,
+ type PullRequestFacts,
+ toAgentProvider,
+ toSessionStatus,
+ type WorkspaceSummary,
+} from "../types/workspace";
+
+function toPullRequestFacts(pr: components["schemas"]["SessionPRFacts"]): PullRequestFacts {
+ return {
+ url: pr.url,
+ number: pr.number,
+ state: pr.state as PRState,
+ ci: pr.ci,
+ review: pr.review,
+ mergeability: pr.mergeability,
+ reviewComments: pr.reviewComments,
+ updatedAt: pr.updatedAt,
+ };
+}
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;
@@ -30,21 +36,11 @@ 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: sessions
+ sessions: (sessionsData?.sessions ?? [])
.filter((session) => session.projectId === project.id)
.map((session) => ({
id: session.id,
@@ -58,7 +54,7 @@ async function fetchWorkspaces(): Promise {
status: toSessionStatus(session.status, session.isTerminated),
createdAt: session.createdAt,
updatedAt: session.updatedAt,
- pullRequest: prBySession.get(session.id),
+ prs: (session.prs ?? []).map(toPullRequestFacts),
})),
}));
}
diff --git a/frontend/src/renderer/lib/mock-data.ts b/frontend/src/renderer/lib/mock-data.ts
index df97916c9..5d3af9520 100644
--- a/frontend/src/renderer/lib/mock-data.ts
+++ b/frontend/src/renderer/lib/mock-data.ts
@@ -1,8 +1,21 @@
-import type { WorkspaceSummary } from "../types/workspace";
+import type { PRState, PullRequestFacts, WorkspaceSummary } from "../types/workspace";
const now = new Date().toISOString();
const hoursAgo = (hours: number) => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
+// Single-PR preview helper. Sessions that own a stack (stacked-auth) inline
+// their facts instead; the daemon aggregates per-PR CI/review server-side.
+const pr = (number: number, state: PRState, ci = "passing"): PullRequestFacts => ({
+ url: `https://github.com/me/pull/${number}`,
+ number,
+ state,
+ ci,
+ review: state === "merged" ? "approved" : "none",
+ mergeability: "mergeable",
+ reviewComments: false,
+ updatedAt: now,
+});
+
export const mockWorkspaces: WorkspaceSummary[] = [
{
id: "api-gateway",
@@ -28,6 +41,52 @@ export const mockWorkspaces: WorkspaceSummary[] = [
},
],
commitMessage: "refactor terminal mux",
+ prs: [],
+ },
+ {
+ id: "stacked-auth",
+ terminalHandleId: "stacked-auth/terminal_0",
+ workspaceId: "api-gateway",
+ workspaceName: "api-gateway",
+ title: "auth stack",
+ provider: "claude-code",
+ branch: "feat/ns",
+ status: "review_pending",
+ updatedAt: now,
+ createdAt: hoursAgo(2),
+ // One session owning a stack: open root, draft child, merged base.
+ prs: [
+ {
+ url: "https://github.com/me/api-gateway/pull/41",
+ number: 41,
+ state: "open",
+ ci: "passing",
+ review: "approved",
+ mergeability: "mergeable",
+ reviewComments: false,
+ updatedAt: now,
+ },
+ {
+ url: "https://github.com/me/api-gateway/pull/42",
+ number: 42,
+ state: "draft",
+ ci: "pending",
+ review: "none",
+ mergeability: "unknown",
+ reviewComments: false,
+ updatedAt: now,
+ },
+ {
+ url: "https://github.com/me/api-gateway/pull/40",
+ number: 40,
+ state: "merged",
+ ci: "passing",
+ review: "approved",
+ mergeability: "mergeable",
+ reviewComments: false,
+ updatedAt: hoursAgo(1),
+ },
+ ],
},
{
id: "fix-auth-timeouts",
@@ -39,7 +98,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "ci_failed",
updatedAt: hoursAgo(1),
createdAt: hoursAgo(6),
- pullRequest: { number: 184, state: "open" },
+ prs: [pr(184, "open", "failing")],
},
{
id: "rate-limit-headers",
@@ -51,7 +110,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "review_pending",
updatedAt: hoursAgo(2),
createdAt: hoursAgo(9),
- pullRequest: { number: 185, state: "open" },
+ prs: [pr(185, "open")],
},
],
},
@@ -70,6 +129,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "needs_input",
updatedAt: now,
createdAt: hoursAgo(4),
+ prs: [],
},
{
id: "shader-cache",
@@ -85,6 +145,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
{ path: "src/render/shader-cache.ts", additions: 86, deletions: 12 },
{ path: "src/render/webgl-context.ts", additions: 24, deletions: 5 },
],
+ prs: [],
},
{
id: "texture-leak",
@@ -96,7 +157,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "ci_failed",
updatedAt: hoursAgo(1.5),
createdAt: hoursAgo(7),
- pullRequest: { number: 51, state: "open" },
+ prs: [pr(51, "open", "failing")],
},
{
id: "review-camera-pan",
@@ -108,7 +169,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "review_pending",
updatedAt: hoursAgo(3),
createdAt: hoursAgo(10),
- pullRequest: { number: 52, state: "open" },
+ prs: [pr(52, "open")],
},
{
id: "draft-webgpu-probe",
@@ -120,7 +181,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "draft",
updatedAt: hoursAgo(5),
createdAt: hoursAgo(12),
- pullRequest: { number: 53, state: "draft" },
+ prs: [pr(53, "draft")],
},
{
id: "merge-frame-stats",
@@ -132,7 +193,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "mergeable",
updatedAt: hoursAgo(0.25),
createdAt: hoursAgo(14),
- pullRequest: { number: 54, state: "open" },
+ prs: [pr(54, "open")],
},
{
id: "approved-pixel-ratio",
@@ -144,7 +205,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "approved",
updatedAt: hoursAgo(2.5),
createdAt: hoursAgo(16),
- pullRequest: { number: 55, state: "open" },
+ prs: [pr(55, "open")],
},
{
id: "input-pointer-lock",
@@ -156,7 +217,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "changes_requested",
updatedAt: hoursAgo(4),
createdAt: hoursAgo(18),
- pullRequest: { number: 56, state: "open" },
+ prs: [pr(56, "open")],
},
],
},
@@ -175,6 +236,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "working",
updatedAt: hoursAgo(0.75),
createdAt: hoursAgo(3),
+ prs: [],
},
{
id: "profile-sheet",
@@ -186,7 +248,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "mergeable",
updatedAt: hoursAgo(1.25),
createdAt: hoursAgo(8),
- pullRequest: { number: 92, state: "open" },
+ prs: [pr(92, "open")],
},
],
},
@@ -205,7 +267,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "review_pending",
updatedAt: hoursAgo(2.25),
createdAt: hoursAgo(11),
- pullRequest: { number: 117, state: "open" },
+ prs: [pr(117, "open")],
},
{
id: "tax-id-validation",
@@ -217,6 +279,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "needs_input",
updatedAt: hoursAgo(1.75),
createdAt: hoursAgo(5),
+ prs: [],
},
],
},
diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css
index 4abaa05bf..f0fe46f55 100644
--- a/frontend/src/renderer/styles.css
+++ b/frontend/src/renderer/styles.css
@@ -1214,186 +1214,3 @@ body.is-resizing-x [data-slot="sidebar-container"] {
font-family: var(--font-mono);
margin-top: 2px;
}
-
-/* Changes tab — git rail (second screenshot) */
-.inspector-changes__head {
- display: flex;
- align-items: center;
- gap: 8px;
- margin: -6px -6px 10px;
- padding: 0 6px 10px;
- border-bottom: 1px solid var(--border);
-}
-
-.inspector-changes__title {
- font-size: 13px;
- font-weight: 600;
- color: var(--fg);
-}
-
-.inspector-changes__count {
- font-family: var(--font-mono);
- font-size: 11px;
- color: var(--fg-passive);
-}
-
-.inspector-changes__actions {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 8px;
- font-size: 12px;
-}
-
-.inspector-changes__action {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- color: var(--fg-muted);
- transition: color 0.12s ease;
-}
-
-.inspector-changes__action:hover {
- color: var(--fg);
-}
-
-.inspector-changes__action--danger {
- color: var(--red);
-}
-
-.inspector-changes__action--danger:hover {
- opacity: 0.85;
-}
-
-.inspector-changes__action--end {
- margin-left: auto;
-}
-
-.inspector-changes__list {
- min-height: 120px;
- margin-bottom: 12px;
-}
-
-.inspector-changes__file {
- display: flex;
- align-items: center;
- gap: 8px;
- height: 28px;
- padding: 0 8px;
- border-radius: 6px;
- font-family: var(--font-mono);
- font-size: 12px;
- transition: background 0.12s ease;
-}
-
-.inspector-changes__file:hover {
- background: var(--interactive-hover);
-}
-
-.inspector-changes__path {
- min-width: 0;
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--fg);
-}
-
-.inspector-changes__add {
- flex-shrink: 0;
- color: var(--green);
-}
-
-.inspector-changes__del {
- flex-shrink: 0;
- color: var(--red);
-}
-
-.inspector-changes__stage {
- width: 13px;
- height: 13px;
- flex-shrink: 0;
- color: var(--fg-passive);
-}
-
-.inspector-changes__stage.is-staged {
- color: var(--accent);
-}
-
-.inspector-changes__commit {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding-top: 12px;
- border-top: 1px solid var(--border);
-}
-
-.inspector-changes__input,
-.inspector-changes__textarea {
- width: 100%;
- border-radius: 6px;
- border: 1px solid var(--border);
- background: transparent;
- padding: 6px 10px;
- font-size: 12.5px;
- color: var(--fg);
-}
-
-.inspector-changes__input::placeholder,
-.inspector-changes__textarea::placeholder {
- color: var(--fg-passive);
-}
-
-.inspector-changes__input:focus-visible,
-.inspector-changes__textarea:focus-visible {
- border-color: var(--accent);
- outline: 2px solid var(--accent-weak);
- outline-offset: 0;
-}
-
-.inspector-changes__textarea {
- resize: none;
-}
-
-.inspector-changes__footer {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-top: 12px;
- padding-top: 12px;
- border-top: 1px solid var(--border);
- font-family: var(--font-mono);
- font-size: 11px;
- color: var(--fg-passive);
-}
-
-.inspector-changes__footer svg {
- width: 12px;
- height: 12px;
- flex-shrink: 0;
-}
-
-.inspector-changes__branch {
- min-width: 0;
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--fg-muted);
-}
-
-.inspector-changes__pr {
- display: inline-flex;
- flex-shrink: 0;
- align-items: center;
- gap: 4px;
- border-radius: 6px;
- border: 1px solid var(--border);
- padding: 2px 8px;
- color: var(--fg-muted);
- transition: background 0.12s ease;
-}
-
-.inspector-changes__pr:hover {
- background: var(--interactive-hover);
-}
diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts
index 6845c37c0..729c88c99 100644
--- a/frontend/src/renderer/types/workspace.test.ts
+++ b/frontend/src/renderer/types/workspace.test.ts
@@ -8,7 +8,13 @@ import {
toSessionStatus,
workerDisplayStatus,
workerStatusPulses,
+ openPRs,
+ mergedPRCount,
+ primaryPR,
+ sortedPRs,
type AttentionZone,
+ type PRState,
+ type PullRequestFacts,
type SessionStatus,
type WorkspaceSession,
type WorkspaceSummary,
@@ -24,10 +30,21 @@ function sessionWith(overrides: Partial): WorkspaceSession {
branch: "feat/x",
status: "working",
updatedAt: "2026-01-01T00:00:00Z",
+ prs: [],
...overrides,
};
}
+const pr = (overrides: Partial & { number: number; state: PRState }): PullRequestFacts => ({
+ url: `https://example.com/pr/${overrides.number}`,
+ ci: "passing",
+ review: "approved",
+ mergeability: "mergeable",
+ reviewComments: false,
+ updatedAt: "2026-01-01T00:00:00Z",
+ ...overrides,
+});
+
describe("toSessionStatus", () => {
it("passes through a known status", () => {
expect(toSessionStatus("mergeable")).toBe("mergeable");
@@ -141,6 +158,41 @@ describe("toAgentProvider", () => {
});
});
+describe("PR helpers", () => {
+ const session = sessionWith({
+ prs: [
+ pr({ number: 41, state: "open" }),
+ pr({ number: 42, state: "draft" }),
+ pr({ number: 40, state: "merged" }),
+ pr({ number: 39, state: "closed" }),
+ ],
+ });
+
+ it("sortedPRs orders open, draft, merged, closed then by number", () => {
+ expect(sortedPRs(session).map((p) => p.number)).toEqual([41, 42, 40, 39]);
+ });
+
+ it("openPRs returns open and draft only", () => {
+ expect(
+ openPRs(session)
+ .map((p) => p.number)
+ .sort(),
+ ).toEqual([41, 42]);
+ });
+
+ it("mergedPRCount counts merged PRs", () => {
+ expect(mergedPRCount(session)).toBe(1);
+ });
+
+ it("primaryPR is the highest-priority PR (open before merged)", () => {
+ expect(primaryPR(session)?.number).toBe(41);
+ });
+
+ it("primaryPR is undefined when there are no PRs", () => {
+ expect(primaryPR(sessionWith({ prs: [] }))).toBeUndefined();
+ });
+});
+
describe("attentionZone", () => {
const cases: Array<[SessionStatus, AttentionZone]> = [
["mergeable", "merge"],
diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts
index d401f48bb..a5f1b4668 100644
--- a/frontend/src/renderer/types/workspace.ts
+++ b/frontend/src/renderer/types/workspace.ts
@@ -67,6 +67,26 @@ export type ChangedFile = {
export type SessionKind = "worker" | "orchestrator";
+/** Lifecycle state of a single pull request, mirrors the daemon's enum. */
+export type PRState = "open" | "draft" | "merged" | "closed";
+
+/**
+ * One attributed pull request, mirroring the daemon's SessionPRFacts wire shape.
+ * A session can own many (e.g. a stack), so {@link WorkspaceSession.prs} is a
+ * list. The wire carries no source/target branch or parent pointer, so the UI
+ * renders a flat list of PRs, not a stack tree.
+ */
+export type PullRequestFacts = {
+ url: string;
+ number: number;
+ state: PRState;
+ ci: string;
+ review: string;
+ mergeability: string;
+ reviewComments: boolean;
+ updatedAt: string;
+};
+
export type WorkspaceSession = {
id: string;
terminalHandleId?: string;
@@ -85,10 +105,12 @@ export type WorkspaceSession = {
changedFiles?: ChangedFile[];
/** Pre-filled commit subject for the Git rail, when known. */
commitMessage?: string;
- pullRequest?: {
- number: number;
- state: "open" | "draft" | "merged" | "closed";
- };
+ /**
+ * The session's attributed pull requests. One session can own many (a stack
+ * or independent PRs); empty when none are open yet. Status aggregation is
+ * done server-side, so {@link status} already reflects all of these.
+ */
+ prs: PullRequestFacts[];
/**
* Display status as derived by the daemon at read time. Optional override; when
* absent it is derived from {@link SessionStatus} via {@link workerDisplayStatus}.
@@ -119,6 +141,28 @@ export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplaySta
}
}
+// Open PRs (actionable) sort above merged/closed; ties break by number.
+const prStateRank: Record = { open: 0, draft: 1, merged: 2, closed: 3 };
+
+/** A session's PRs ordered actionable-first (open, draft, merged, closed). */
+export function sortedPRs(session: WorkspaceSession): PullRequestFacts[] {
+ return [...session.prs].sort((a, b) => prStateRank[a.state] - prStateRank[b.state] || a.number - b.number);
+}
+
+/** PRs still in flight (open or draft). */
+export function openPRs(session: WorkspaceSession): PullRequestFacts[] {
+ return session.prs.filter((pr) => pr.state === "open" || pr.state === "draft");
+}
+
+export function mergedPRCount(session: WorkspaceSession): number {
+ return session.prs.filter((pr) => pr.state === "merged").length;
+}
+
+/** The highest-priority PR for compact one-line surfaces (board card, sidebar). */
+export function primaryPR(session: WorkspaceSession): PullRequestFacts | undefined {
+ return sortedPRs(session)[0];
+}
+
export function isOrchestratorSession(session: WorkspaceSession): boolean {
return session.kind === "orchestrator" || session.id.endsWith("-orchestrator");
}
@@ -228,10 +272,6 @@ export type WorkspaceSummary = {
additions: number;
deletions: number;
};
- pullRequest?: {
- number: number;
- state: "open" | "draft" | "merged" | "closed";
- };
sessions: WorkspaceSession[];
};