- {files.length === 0 ? (
-
No changes yet.
- ) : (
- files.map((file) => (
-
- {file.path}
- +{file.additions}
- −{file.deletions}
-
-
- ))
- )}
-
-
-
{session.branch || "—"}
-
+
+
+
+
No reviews yet.
+
Reviews from this session's PRs will appear here.
);
diff --git a/frontend/src/renderer/components/SessionView.test.tsx b/frontend/src/renderer/components/SessionView.test.tsx
index 0e4fc5f1..2e529b34 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 6dcd622c..61ec964e 100644
--- a/frontend/src/renderer/components/SessionsBoard.tsx
+++ b/frontend/src/renderer/components/SessionsBoard.tsx
@@ -5,6 +5,7 @@ import {
type WorkerDisplayStatus,
type WorkspaceSession,
attentionZone,
+ openPRs,
workerDisplayStatus,
workerSessions,
} from "../types/workspace";
@@ -99,7 +100,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
return (
-
+
{workspaceQuery.isError ? (
@@ -197,6 +198,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 = BADGE[workerDisplayStatus(session)];
const branch = session.branch || `session/${session.id}`;
@@ -223,7 +237,7 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: (
{branch}
- {session.pullRequest ? `PR #${session.pullRequest.number} · ${session.pullRequest.state}` : "no PR yet"}
+ {prSummary(session)}
);
diff --git a/frontend/src/renderer/hooks/useTerminalSession.test.tsx b/frontend/src/renderer/hooks/useTerminalSession.test.tsx
index 06f76aaf..d8efca52 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.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts
index 12b432f8..0b6b4776 100644
--- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts
+++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts
@@ -1,7 +1,27 @@
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 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";
@@ -34,6 +54,7 @@ async function fetchWorkspaces(): Promise
{
status: toSessionStatus(session.status, session.isTerminated),
createdAt: session.createdAt,
updatedAt: session.updatedAt,
+ prs: (session.prs ?? []).map(toPullRequestFacts),
})),
}));
}
diff --git a/frontend/src/renderer/lib/mock-data.ts b/frontend/src/renderer/lib/mock-data.ts
index d838046b..77756f88 100644
--- a/frontend/src/renderer/lib/mock-data.ts
+++ b/frontend/src/renderer/lib/mock-data.ts
@@ -25,6 +25,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: new Date().toISOString(),
+ createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
+ // 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: new Date().toISOString(),
+ },
+ {
+ url: "https://github.com/me/api-gateway/pull/42",
+ number: 42,
+ state: "draft",
+ ci: "pending",
+ review: "none",
+ mergeability: "unknown",
+ reviewComments: false,
+ updatedAt: new Date().toISOString(),
+ },
+ {
+ url: "https://github.com/me/api-gateway/pull/40",
+ number: 40,
+ state: "merged",
+ ci: "passing",
+ review: "approved",
+ mergeability: "mergeable",
+ reviewComments: false,
+ updatedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
+ },
+ ],
},
],
},
@@ -43,6 +89,7 @@ export const mockWorkspaces: WorkspaceSummary[] = [
status: "needs_input",
updatedAt: new Date().toISOString(),
createdAt: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
+ prs: [],
},
],
},
diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css
index 10ad8ba8..7d7fb981 100644
--- a/frontend/src/renderer/styles.css
+++ b/frontend/src/renderer/styles.css
@@ -759,186 +759,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 6845c37c..729c88c9 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 d401f48b..a5f1b466 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[];
};