feat(frontend): surface multiple PRs per session + reviewer in inspector (#335)
* feat(frontend): surface multiple PRs per session Replace the single optional pullRequest on a session with a prs[] list and render it across every PR surface: the inspector stacks one card per PR, the PR board lists one row per attributed PR, and the board card shows a PR count summary. useWorkspaceQuery now maps the live /api/v1/sessions prs[] into the query (the frontend surfaced no PRs before this). Ordering is actionable-first (open, draft, merged, closed). Adds unit tests (SessionInspector, PullRequestsPage), a Playwright e2e spec, and a multi-PR mock-data fixture. * chore: format with prettier [skip ci] * feat(frontend): swap inspector Changes tab for empty Reviews placeholder The inspector rail's Summary tab already renders the multi-PR stack from PR #237 work, so the Changes (Git rail) tab is the next inspector surface to evolve. Reviews will land on its own backed by PR-scoped review data (separate workstream); reserve the slot now with an empty placeholder so the navigation lands before the data does. - Tabs are now Summary, Reviews, Browser. The InspectorView union and the VIEWS array are updated; Reviews gets a message-bubble icon to distinguish it from Summary's list icon. - ChangesView and its lucide imports (GitBranch, GitCommitHorizontal, Plus, Square, Trash2) and the unused Button import are removed; a small ReviewsView mirrors BrowserView's empty-state shape. - styles.css drops the orphaned .inspector-changes__* block. - SessionInspector.test.tsx asserts the new tab labels and that Reviews shows the empty placeholder. * feat(frontend): wire the reviewer feature into the inspector Reviews tab The multi-PR transplant left the Reviews tab an empty placeholder, which would have regressed the existing session review feature. Move the reviewer panel (trigger/re-run a review, open the reviewer terminal, surface verdict + status) into the Reviews tab, gated on the multi-PR model: it reads the session's prs[] to decide between the reviewer card and the "no PR yet" empty state, and pulls review runs + project reviewer config straight from the daemon. Reconciles the prs[]-from-session-list model across the suite: ShellTopbar and pr-hydration fixtures carry prs[], useWorkspaceQuery tests drop the obsolete per-session /pr fetch, and SessionsBoard restores the dropped board title. Adds a Playwright reviews-tab e2e spec (reviewer card for a session with PRs; empty state for one without). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(frontend): adapt multi-PR e2e specs to main's expanded fixture The rebase pulled in main's larger mock-data fixture (4 workspaces, 13 PRs) and its renamed refactor-mux title. Rewrite the PR-board assertion to verify the actionable-first ordering invariant instead of a brittle full-list match, and update the empty-state selector to the session's current title. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: format with prettier [skip ci] --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a8a3056a6b
commit
b4a8fad215
|
|
@ -0,0 +1,43 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// dev:web (VITE_NO_ELECTRON=1) serves lib/mock-data.ts. The api-gateway
|
||||
// workspace owns a "stacked-auth" session ("auth stack") carrying three PRs:
|
||||
// #41 open, #42 draft, #40 merged — the multi-PR-per-session case this suite
|
||||
// guards across the inspector rail and the PR board.
|
||||
|
||||
test("the inspector rail stacks every PR a session owns, actionable-first", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Open auth stack" }).click();
|
||||
await expect(page).toHaveURL(/sessions\/stacked-auth/);
|
||||
|
||||
const inspector = page.locator("#inspector");
|
||||
await expect(inspector).toBeVisible();
|
||||
|
||||
// Plural heading reflects the stack size.
|
||||
await expect(inspector.getByText("Pull requests (3)")).toBeVisible();
|
||||
|
||||
// One card per PR, ordered open → draft → merged (the merged base sinks).
|
||||
// Scope to the PR section: the Activity timeline also renders "Opened PR #n".
|
||||
const prSection = inspector.locator("section.inspector-section", { hasText: "Pull requests (3)" });
|
||||
const cards = prSection.locator("text=/^PR #\\d+$/");
|
||||
await expect(cards).toHaveText(["PR #41", "PR #42", "PR #40"]);
|
||||
});
|
||||
|
||||
test("the PR board lists one row per attributed PR, actionable PRs first", async ({ page }) => {
|
||||
await page.goto("/#/prs");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Pull requests" })).toBeVisible();
|
||||
|
||||
// stacked-auth's three PRs keep actionable-first order across the whole board:
|
||||
// open #41 before draft #42, and the lone merged PR (#40) sinks to the bottom.
|
||||
const numbers = await page.locator("tbody tr td:first-child").allTextContents();
|
||||
expect(numbers.indexOf("#41")).toBeLessThan(numbers.indexOf("#42"));
|
||||
expect(numbers.indexOf("#42")).toBeLessThan(numbers.indexOf("#40"));
|
||||
expect(numbers.indexOf("#40")).toBe(numbers.length - 1);
|
||||
|
||||
// Open/draft rows are actionable; the merged row is not.
|
||||
const mergedRow = page.locator("tbody tr", { hasText: "#40" });
|
||||
await expect(mergedRow.getByRole("button", { name: "Merge" })).toHaveCount(0);
|
||||
const openRow = page.locator("tbody tr", { hasText: "#41" });
|
||||
await expect(openRow.getByRole("button", { name: "Merge" })).toBeVisible();
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// dev:web (VITE_NO_ELECTRON=1) serves lib/mock-data.ts, whose "stacked-auth"
|
||||
// session ("auth stack") owns three PRs — so the inspector's Reviews tab is
|
||||
// enabled. The tab fetches review runs and project config straight from the
|
||||
// daemon; dev:web has no daemon, so we stub those two routes to drive the
|
||||
// reviewer panel and prove it renders a real reviewer card (not the empty
|
||||
// "no PR yet" placeholder) once a session owns a PR.
|
||||
|
||||
test("the Reviews tab renders the reviewer panel for a session that owns PRs", async ({ page }) => {
|
||||
await page.route("**/api/v1/sessions/stacked-auth/reviews", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
reviewerHandleId: "reviewer-pane",
|
||||
reviews: [
|
||||
{
|
||||
id: "run-1",
|
||||
reviewId: "review-1",
|
||||
sessionId: "stacked-auth",
|
||||
harness: "codex",
|
||||
status: "complete",
|
||||
verdict: "approved",
|
||||
body: "Looks good.",
|
||||
prUrl: "https://github.com/me/api-gateway/pull/41",
|
||||
targetSha: "abc123",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/projects/api-gateway", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
status: "ok",
|
||||
project: {
|
||||
id: "api-gateway",
|
||||
kind: "git",
|
||||
name: "api-gateway",
|
||||
path: "/Users/me/api-gateway",
|
||||
repo: "api-gateway",
|
||||
defaultBranch: "main",
|
||||
config: { reviewers: [{ harness: "codex" }] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Open auth stack" }).click();
|
||||
await expect(page).toHaveURL(/sessions\/stacked-auth/);
|
||||
|
||||
const inspector = page.locator("#inspector");
|
||||
await expect(inspector).toBeVisible();
|
||||
|
||||
await inspector.getByRole("tab", { name: "Reviews" }).click();
|
||||
|
||||
// The reviewer card surfaces the harness, its approved verdict, and both
|
||||
// actions — never the empty state, since this session owns a PR.
|
||||
await expect(inspector.getByText("No pull request opened yet.")).toHaveCount(0);
|
||||
await expect(inspector.getByText("codex")).toBeVisible();
|
||||
await expect(inspector.getByText("Approved")).toBeVisible();
|
||||
await expect(inspector.getByRole("button", { name: "Re-run review" })).toBeVisible();
|
||||
await expect(inspector.getByRole("button", { name: "Open terminal" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the Reviews tab shows the empty state for a session with no PRs", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Open Split terminal mux responsibilities" }).click();
|
||||
await expect(page).toHaveURL(/sessions\/refactor-mux/);
|
||||
|
||||
const inspector = page.locator("#inspector");
|
||||
await expect(inspector).toBeVisible();
|
||||
|
||||
await inspector.getByRole("tab", { name: "Reviews" }).click();
|
||||
await expect(inspector.getByText("No pull request opened yet.")).toBeVisible();
|
||||
});
|
||||
|
|
@ -4,9 +4,9 @@ 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.
|
||||
// for a normal project, mocking only the HTTP client and the router. Proves PR
|
||||
// facts carried on the session list 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", () => ({
|
||||
|
|
@ -24,7 +24,7 @@ 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 } } }) => {
|
||||
getMock.mockImplementation(async (url: string) => {
|
||||
if (url === "/api/v1/projects") {
|
||||
return { data: { projects: [{ id: "proj-1", name: "my-app", path: "/repo/my-app" }] }, error: undefined };
|
||||
}
|
||||
|
|
@ -40,27 +40,18 @@ function respondWithProjectAndPR() {
|
|||
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",
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PullRequestsPage } from "./PullRequestsPage";
|
||||
import type { PRState, PullRequestFacts, WorkspaceSession, WorkspaceSummary } from "../types/workspace";
|
||||
|
||||
const { navigateMock, postMock, useWorkspaceQueryMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
postMock: vi.fn(),
|
||||
useWorkspaceQueryMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => navigateMock }));
|
||||
vi.mock("../hooks/useWorkspaceQuery", () => ({
|
||||
useWorkspaceQuery: () => useWorkspaceQueryMock(),
|
||||
workspaceQueryKey: ["workspaces"],
|
||||
}));
|
||||
vi.mock("../lib/api-client", () => ({
|
||||
apiClient: { POST: (...args: unknown[]) => postMock(...args) },
|
||||
apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"),
|
||||
}));
|
||||
|
||||
const pr = (n: number, state: PRState): PullRequestFacts => ({
|
||||
url: `https://example.com/pr/${n}`,
|
||||
number: n,
|
||||
state,
|
||||
ci: "passing",
|
||||
review: "approved",
|
||||
mergeability: "mergeable",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-15T00:00:00Z",
|
||||
});
|
||||
|
||||
const session = (id: string, prs: PullRequestFacts[]): WorkspaceSession => ({
|
||||
id,
|
||||
workspaceId: "proj-1",
|
||||
workspaceName: "my-app",
|
||||
title: id,
|
||||
provider: "claude-code",
|
||||
kind: "worker",
|
||||
branch: "feat/ns",
|
||||
status: "review_pending",
|
||||
updatedAt: "2026-06-15T00:00:00Z",
|
||||
prs,
|
||||
});
|
||||
|
||||
function setWorkspaces(sessions: WorkspaceSession[]) {
|
||||
const data: WorkspaceSummary[] = [{ id: "proj-1", name: "my-app", path: "/p", sessions }];
|
||||
useWorkspaceQueryMock.mockReturnValue({ data, isError: false, isLoading: false });
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<PullRequestsPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
postMock.mockReset().mockResolvedValue({ data: { method: "squash" }, error: undefined });
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("PullRequestsPage", () => {
|
||||
it("renders one row per PR across sessions, actionable PRs first", () => {
|
||||
setWorkspaces([session("auth", [pr(41, "open"), pr(42, "draft"), pr(40, "merged")])]);
|
||||
renderPage();
|
||||
|
||||
const rows = screen.getAllByRole("row").slice(1); // drop header
|
||||
const numbers = rows.map((r) => within(r).getByText(/^#\d+$/).textContent);
|
||||
expect(numbers).toEqual(["#41", "#42", "#40"]);
|
||||
});
|
||||
|
||||
it("merges the PR by its own number, not the session's", async () => {
|
||||
setWorkspaces([session("auth", [pr(41, "open"), pr(42, "draft")])]);
|
||||
renderPage();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const childRow = screen.getByText("#42").closest("tr")!;
|
||||
await user.click(within(childRow).getByRole("button", { name: "Merge" }));
|
||||
|
||||
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
|
||||
expect(postMock).toHaveBeenCalledWith("/api/v1/prs/{id}/merge", { params: { path: { id: "42" } } });
|
||||
});
|
||||
|
||||
it("shows an empty state when no session has a PR", () => {
|
||||
setWorkspaces([session("idle", [])]);
|
||||
renderPage();
|
||||
expect(screen.getByText("No open pull requests.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,15 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { useState } from "react";
|
||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||
import type { WorkspaceSession } from "../types/workspace";
|
||||
import { type PRState, type PullRequestFacts, type WorkspaceSession } from "../types/workspace";
|
||||
import { DashboardSubhead } from "./DashboardSubhead";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type PRState = NonNullable<WorkspaceSession["pullRequest"]>["state"];
|
||||
|
||||
const stateTone: Record<PRState, string> = {
|
||||
open: "border-success/40 bg-success/10 text-success",
|
||||
draft: "border-border bg-raised text-muted-foreground",
|
||||
|
|
@ -23,26 +21,22 @@ const stateTone: Record<PRState, string> = {
|
|||
const stateRank: Record<PRState, number> = { open: 0, draft: 1, merged: 2, closed: 3 };
|
||||
|
||||
type PRRow = {
|
||||
number: number;
|
||||
state: PRState;
|
||||
pr: PullRequestFacts;
|
||||
session: WorkspaceSession;
|
||||
};
|
||||
|
||||
// The PR board, ported from agent-orchestrator's PullRequestsPage. The Go
|
||||
// daemon has no PR-list endpoint, so the board is derived from session PR
|
||||
// fields (every session carries pullRequest); actions hit /prs/{number}/merge
|
||||
// and /resolve-comments. Per-PR CI/review facts live on the session route's
|
||||
// inspector.
|
||||
// The PR board, ported from agent-orchestrator's PullRequestsPage. One row per
|
||||
// attributed PR — a session can own several (a stack or independent PRs), so we
|
||||
// flatMap the session's prs list rather than assuming one. Actions hit
|
||||
// /prs/{number}/merge and /resolve-comments. Per-PR CI/review facts also live on
|
||||
// the session route's inspector.
|
||||
export function PullRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const workspaceQuery = useWorkspaceQuery();
|
||||
const sessions = (workspaceQuery.data ?? []).flatMap((w) => w.sessions);
|
||||
const rows: PRRow[] = sessions
|
||||
.filter((s): s is WorkspaceSession & { pullRequest: NonNullable<WorkspaceSession["pullRequest"]> } =>
|
||||
Boolean(s.pullRequest),
|
||||
)
|
||||
.map((s) => ({ number: s.pullRequest.number, state: s.pullRequest.state, session: s }))
|
||||
.sort((a, b) => stateRank[a.state] - stateRank[b.state] || a.number - b.number);
|
||||
.flatMap((s) => s.prs.map((pr) => ({ pr, session: s })))
|
||||
.sort((a, b) => stateRank[a.pr.state] - stateRank[b.pr.state] || a.pr.number - b.pr.number);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
|
||||
|
|
@ -68,7 +62,7 @@ export function PullRequestsPage() {
|
|||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<PRRowView
|
||||
key={`${row.session.id}-${row.number}`}
|
||||
key={`${row.session.id}-${row.pr.number}`}
|
||||
row={row}
|
||||
onOpen={() =>
|
||||
void navigate({
|
||||
|
|
@ -94,7 +88,7 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
|
|||
const merge = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data, error } = await apiClient.POST("/api/v1/prs/{id}/merge", {
|
||||
params: { path: { id: String(row.number) } },
|
||||
params: { path: { id: String(row.pr.number) } },
|
||||
});
|
||||
if (error) throw new Error(apiErrorMessage(error));
|
||||
return data;
|
||||
|
|
@ -109,7 +103,7 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
|
|||
const resolve = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { error } = await apiClient.POST("/api/v1/prs/{id}/resolve-comments", {
|
||||
params: { path: { id: String(row.number) } },
|
||||
params: { path: { id: String(row.pr.number) } },
|
||||
});
|
||||
if (error) throw new Error(apiErrorMessage(error));
|
||||
},
|
||||
|
|
@ -120,11 +114,11 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
|
|||
onError: (e) => setNote({ ok: false, text: e instanceof Error ? e.message : "resolve failed" }),
|
||||
});
|
||||
|
||||
const actionable = row.state === "open" || row.state === "draft";
|
||||
const actionable = row.pr.state === "open" || row.pr.state === "draft";
|
||||
|
||||
return (
|
||||
<TableRow className="cursor-pointer" onClick={onOpen}>
|
||||
<TableCell className="font-mono text-[12px] text-muted-foreground">#{row.number}</TableCell>
|
||||
<TableCell className="font-mono text-[12px] text-muted-foreground">#{row.pr.number}</TableCell>
|
||||
<TableCell className="max-w-0">
|
||||
<div className="truncate text-[13px] text-foreground">{row.session.title}</div>
|
||||
<div className="truncate font-mono text-[10px] text-passive">
|
||||
|
|
@ -132,8 +126,8 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={cn("h-5 px-1.5 text-[10px] font-medium", stateTone[row.state])}>
|
||||
{row.state}
|
||||
<Badge variant="outline" className={cn("h-5 px-1.5 text-[10px] font-medium", stateTone[row.pr.state])}>
|
||||
{row.pr.state}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkspaceSession } from "../types/workspace";
|
||||
import { SessionInspector } from "./SessionInspector";
|
||||
import type { PRState, PullRequestFacts, WorkspaceSession } from "../types/workspace";
|
||||
|
||||
const { getMock, postMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
|
|
@ -25,28 +25,29 @@ vi.mock("../lib/api-client", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const worker: WorkspaceSession = {
|
||||
const pr = (n: number, state: PRState): PullRequestFacts => ({
|
||||
url: `https://example.com/pr/${n}`,
|
||||
number: n,
|
||||
state,
|
||||
ci: "passing",
|
||||
review: "approved",
|
||||
mergeability: "mergeable",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-15T00:00:00Z",
|
||||
});
|
||||
|
||||
const session = (prs: PullRequestFacts[]): WorkspaceSession => ({
|
||||
id: "sess-1",
|
||||
workspaceId: "proj-1",
|
||||
workspaceId: "ws-1",
|
||||
workspaceName: "my-app",
|
||||
title: "do the thing",
|
||||
provider: "claude-code",
|
||||
kind: "worker",
|
||||
branch: "ao/sess-1",
|
||||
status: "working",
|
||||
updatedAt: "2026-06-10T00:00:00Z",
|
||||
};
|
||||
|
||||
const reviewSession = {
|
||||
...worker,
|
||||
terminalHandleId: "worker-pane",
|
||||
title: "review me",
|
||||
provider: "codex",
|
||||
branch: "session/sess-1",
|
||||
createdAt: "2026-06-16T10:00:00Z",
|
||||
updatedAt: "2026-06-16T10:05:00Z",
|
||||
pullRequest: { number: 3, state: "open" },
|
||||
} satisfies WorkspaceSession;
|
||||
branch: "feat/ns",
|
||||
status: "review_pending",
|
||||
updatedAt: "2026-06-15T00:00:00Z",
|
||||
prs,
|
||||
});
|
||||
|
||||
function renderWithQuery(children: ReactNode) {
|
||||
const client = new QueryClient({
|
||||
|
|
@ -57,24 +58,6 @@ function renderWithQuery(children: ReactNode) {
|
|||
|
||||
function mockCommonGets(reviews: unknown[] = [], reviewerHandleId = "") {
|
||||
getMock.mockImplementation(async (path: string) => {
|
||||
if (path === "/api/v1/sessions/{sessionId}/pr") {
|
||||
return {
|
||||
data: {
|
||||
prs: [
|
||||
{
|
||||
url: "https://github.com/aoagents/reverbcode/pull/3",
|
||||
number: 3,
|
||||
state: "open",
|
||||
ci: "passing",
|
||||
review: "required",
|
||||
mergeability: "mergeable",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-16T10:05:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/api/v1/sessions/{sessionId}/reviews") {
|
||||
return { data: { reviewerHandleId, reviews } };
|
||||
}
|
||||
|
|
@ -83,7 +66,7 @@ function mockCommonGets(reviews: unknown[] = [], reviewerHandleId = "") {
|
|||
data: {
|
||||
status: "ok",
|
||||
project: {
|
||||
id: "proj-1",
|
||||
id: "ws-1",
|
||||
kind: "git",
|
||||
name: "my-app",
|
||||
path: "/repo",
|
||||
|
|
@ -106,7 +89,7 @@ const approvedReview = {
|
|||
status: "complete",
|
||||
verdict: "approved",
|
||||
body: "Looks good.",
|
||||
prUrl: "https://github.com/aoagents/reverbcode/pull/3",
|
||||
prUrl: "https://example.com/pr/3",
|
||||
targetSha: "abc123",
|
||||
createdAt: "2026-06-16T10:06:00Z",
|
||||
};
|
||||
|
|
@ -114,28 +97,78 @@ const approvedReview = {
|
|||
beforeEach(() => {
|
||||
getMock.mockReset();
|
||||
postMock.mockReset();
|
||||
getMock.mockResolvedValue({ data: { prs: [] }, error: undefined });
|
||||
getMock.mockResolvedValue({ data: { reviewerHandleId: "", reviews: [] }, error: undefined });
|
||||
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
|
||||
});
|
||||
|
||||
describe("SessionInspector reviews", () => {
|
||||
describe("SessionInspector PR section", () => {
|
||||
// Scope assertions to the PR section: the activity timeline also renders
|
||||
// "Opened PR #n", so an unscoped query matches both the card and the event.
|
||||
const prSection = (title: string) =>
|
||||
within(screen.getByText(title).closest("section.inspector-section") as HTMLElement);
|
||||
|
||||
it("renders one card per PR, ordered actionable-first, when a session owns a stack", () => {
|
||||
render(<SessionInspector session={session([pr(40, "merged"), pr(41, "open"), pr(42, "draft")])} />);
|
||||
|
||||
expect(screen.getByText("Pull requests (3)")).toBeInTheDocument();
|
||||
const cards = prSection("Pull requests (3)")
|
||||
.getAllByText(/^PR #\d+$/)
|
||||
.map((el) => el.textContent);
|
||||
// open (41), draft (42), merged (40)
|
||||
expect(cards).toEqual(["PR #41", "PR #42", "PR #40"]);
|
||||
});
|
||||
|
||||
it("uses the singular heading and shows enriched facts for a single PR", () => {
|
||||
render(<SessionInspector session={session([pr(7, "open")])} />);
|
||||
|
||||
expect(screen.getByText("Pull request")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Pull requests \(/)).not.toBeInTheDocument();
|
||||
expect(prSection("Pull request").getByText("PR #7")).toBeInTheDocument();
|
||||
// CI/Merge/Review facts surface per card.
|
||||
expect(prSection("Pull request").getAllByText("passing").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no PRs", () => {
|
||||
render(<SessionInspector session={session([])} />);
|
||||
expect(screen.getByText("No pull request opened yet.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links each PR to its url", () => {
|
||||
render(<SessionInspector session={session([pr(41, "open"), pr(42, "draft")])} />);
|
||||
const links = screen.getAllByRole("link", { name: /Open/ });
|
||||
expect(links.map((a) => a.getAttribute("href"))).toEqual([
|
||||
"https://example.com/pr/41",
|
||||
"https://example.com/pr/42",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionInspector tabs", () => {
|
||||
it("exposes Summary, Reviews, and Browser as the three inspector tabs", () => {
|
||||
render(<SessionInspector session={session([pr(1, "open")])} />);
|
||||
const tabs = screen.getAllByRole("tab").map((el) => el.textContent?.trim());
|
||||
expect(tabs).toEqual(["Summary", "Reviews", "Browser"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionInspector reviews tab", () => {
|
||||
const openReviewsTab = async () => userEvent.click(screen.getByRole("tab", { name: /Reviews/ }));
|
||||
|
||||
it("triggers a review and opens the returned reviewer terminal", async () => {
|
||||
mockCommonGets();
|
||||
postMock.mockResolvedValue({
|
||||
response: { status: 201 },
|
||||
data: {
|
||||
reviewerHandleId: "reviewer-pane",
|
||||
review: {
|
||||
...approvedReview,
|
||||
status: "running",
|
||||
verdict: "",
|
||||
body: "",
|
||||
},
|
||||
review: { ...approvedReview, status: "running", verdict: "", body: "" },
|
||||
},
|
||||
});
|
||||
const onOpenReviewerTerminal = vi.fn();
|
||||
|
||||
renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);
|
||||
renderWithQuery(
|
||||
<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session([pr(3, "open")])} />,
|
||||
);
|
||||
await openReviewsTab();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /run review/i }));
|
||||
|
||||
|
|
@ -151,14 +184,14 @@ describe("SessionInspector reviews", () => {
|
|||
mockCommonGets([approvedReview], "reviewer-pane");
|
||||
postMock.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
data: {
|
||||
reviewerHandleId: "reviewer-pane",
|
||||
review: approvedReview,
|
||||
},
|
||||
data: { reviewerHandleId: "reviewer-pane", review: approvedReview },
|
||||
});
|
||||
const onOpenReviewerTerminal = vi.fn();
|
||||
|
||||
renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);
|
||||
renderWithQuery(
|
||||
<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session([pr(3, "open")])} />,
|
||||
);
|
||||
await openReviewsTab();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /re-run review/i }));
|
||||
|
||||
|
|
@ -170,11 +203,22 @@ describe("SessionInspector reviews", () => {
|
|||
mockCommonGets([approvedReview], "reviewer-pane");
|
||||
const onOpenReviewerTerminal = vi.fn();
|
||||
|
||||
renderWithQuery(<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={reviewSession} />);
|
||||
renderWithQuery(
|
||||
<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session([pr(3, "open")])} />,
|
||||
);
|
||||
await openReviewsTab();
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText("Approved").length).toBeGreaterThan(0));
|
||||
await userEvent.click(screen.getByRole("button", { name: /open terminal/i }));
|
||||
|
||||
expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" });
|
||||
});
|
||||
|
||||
it("shows the no-PR empty state when the session has no PRs", async () => {
|
||||
mockCommonGets();
|
||||
renderWithQuery(<SessionInspector session={session([])} />);
|
||||
await userEvent.click(screen.getByRole("tab", { name: /Reviews/ }));
|
||||
|
||||
expect(await screen.findByText("No pull request opened yet.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,37 +1,22 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
CircleMinus,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
GitPullRequest,
|
||||
Play,
|
||||
Plus,
|
||||
Shield,
|
||||
Square,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, CircleMinus, GitPullRequest, Play, Shield, Terminal } from "lucide-react";
|
||||
import type { components } from "../../api/schema";
|
||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||
import { formatTimeCompact } from "../lib/format-time";
|
||||
import type { SessionStatus, WorkspaceSession } from "../types/workspace";
|
||||
import { workerDisplayStatus } from "../types/workspace";
|
||||
import type { PRState, PullRequestFacts, SessionStatus, WorkspaceSession } from "../types/workspace";
|
||||
import { sortedPRs, workerDisplayStatus } from "../types/workspace";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type PRFacts = components["schemas"]["SessionPRFacts"];
|
||||
type ProjectConfig = components["schemas"]["ProjectConfig"];
|
||||
type ReviewRun = components["schemas"]["ReviewRun"];
|
||||
type ReviewsResponse = components["schemas"]["ListReviewsResponse"];
|
||||
type InspectorView = "summary" | "changes" | "browser";
|
||||
|
||||
type OpenReviewerTerminal = (target: { handleId: string; harness: string }) => void;
|
||||
|
||||
type InspectorView = "summary" | "reviews" | "browser";
|
||||
|
||||
const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
|
||||
{
|
||||
id: "summary",
|
||||
|
|
@ -48,15 +33,11 @@ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
|
|||
),
|
||||
},
|
||||
{
|
||||
id: "changes",
|
||||
label: "Changes",
|
||||
id: "reviews",
|
||||
label: "Reviews",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
|
||||
<path d="M12 3v6" />
|
||||
<path d="M9 6h6" />
|
||||
<path d="M11 18H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M13 15h4" />
|
||||
<path d="M19 9v7a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
|
|
@ -73,7 +54,7 @@ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const prStateTone: Record<PRFacts["state"], string> = {
|
||||
const prStateTone: Record<PRState, string> = {
|
||||
open: "border-success/40 bg-success/10 text-success",
|
||||
draft: "border-border bg-raised text-muted-foreground",
|
||||
merged: "border-accent/40 bg-accent-weak text-accent",
|
||||
|
|
@ -81,8 +62,7 @@ const prStateTone: Record<PRFacts["state"], string> = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Tabbed inspector rail beside the terminal — cloned from agent-orchestrator
|
||||
* SessionInspector (Summary · Changes · Browser).
|
||||
* Tabbed inspector rail beside the terminal (Summary · Reviews · Browser).
|
||||
*/
|
||||
export function SessionInspector({
|
||||
session,
|
||||
|
|
@ -122,8 +102,8 @@ export function SessionInspector({
|
|||
</div>
|
||||
|
||||
<div className="session-inspector__body">
|
||||
{view === "summary" ? <SummaryView onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} /> : null}
|
||||
{view === "changes" ? <ChangesView session={session} /> : null}
|
||||
{view === "summary" ? <SummaryView session={session} /> : null}
|
||||
{view === "reviews" ? <ReviewsView onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} /> : null}
|
||||
{view === "browser" ? <BrowserView /> : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -142,27 +122,181 @@ function Section({ title, action, children }: { title: string; action?: ReactNod
|
|||
);
|
||||
}
|
||||
|
||||
function SummaryView({
|
||||
function SummaryView({ session }: { session: WorkspaceSession }) {
|
||||
const prs = sortedPRs(session);
|
||||
const branchLabel = session.branch || `session/${session.id}`;
|
||||
|
||||
return (
|
||||
<div role="tabpanel">
|
||||
<Section title={prs.length > 1 ? `Pull requests (${prs.length})` : "Pull request"}>
|
||||
{prs.length === 0 ? (
|
||||
<p className="inspector-empty">No pull request opened yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{prs.map((pr) => (
|
||||
<PRCard key={pr.url} pr={pr} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Activity">
|
||||
<ActivityTimeline session={session} />
|
||||
</Section>
|
||||
|
||||
<Section title="Overview">
|
||||
<dl className="inspector-kv">
|
||||
<Row k="Agent" v={session.provider} mono />
|
||||
<Row k="Branch" v={branchLabel} mono />
|
||||
<Row k="Started" v={formatTimeCompact(session.createdAt ?? session.updatedAt)} mono />
|
||||
<Row k="Session" v={session.id} mono />
|
||||
</dl>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One PR per card; a session's PRs stack vertically. Mirrors the minimal
|
||||
// single-PR rail the parallel-agent tools converged on (emdash, conductor),
|
||||
// repeated per PR rather than collapsed into one aggregate widget.
|
||||
function PRCard({ pr }: { pr: PullRequestFacts }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-[7px] border border-border bg-surface p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitPullRequest className="h-3.5 w-3.5 shrink-0 text-passive" aria-hidden="true" />
|
||||
<span className="text-[12.5px] font-medium text-foreground">PR #{pr.number}</span>
|
||||
<Badge variant="outline" className={cn("ml-auto h-5 px-1.5 text-[10px] font-medium", prStateTone[pr.state])}>
|
||||
{pr.state}
|
||||
</Badge>
|
||||
{pr.url ? (
|
||||
<a href={pr.url} target="_blank" rel="noopener noreferrer" className="inspector-section__link">
|
||||
Open ↗
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="inspector-kv">
|
||||
<Row k="CI" v={pr.ci || "—"} mono />
|
||||
<Row k="Merge" v={pr.mergeability || "—"} mono />
|
||||
<Row k="Review" v={pr.review || "—"} mono />
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineTone = "now" | "good" | "warn" | "neutral";
|
||||
|
||||
function ActivityTimeline({ session }: { session: WorkspaceSession }) {
|
||||
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
|
||||
const detail = activityDetail(session.status);
|
||||
|
||||
events.push({
|
||||
tone: "now",
|
||||
node: (
|
||||
<>
|
||||
<span className="inspector-timeline__badge">
|
||||
<InspectorStatusPill session={session} />
|
||||
</span>
|
||||
{detail ? <span className="inspector-timeline__detail"> — {detail}</span> : null}
|
||||
</>
|
||||
),
|
||||
ts: formatTimeCompact(session.updatedAt),
|
||||
});
|
||||
|
||||
for (const pr of sortedPRs(session)) {
|
||||
events.push({
|
||||
tone: "good",
|
||||
node: (
|
||||
<>
|
||||
Opened <b>PR #{pr.number}</b>
|
||||
</>
|
||||
),
|
||||
ts: null,
|
||||
});
|
||||
}
|
||||
|
||||
events.push({
|
||||
tone: "neutral",
|
||||
node: <>Created worktree & branch</>,
|
||||
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="inspector-timeline">
|
||||
{events.map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"inspector-timeline__ev",
|
||||
event.tone === "now" && "inspector-timeline__ev--now",
|
||||
event.tone === "good" && "inspector-timeline__ev--good",
|
||||
event.tone === "warn" && "inspector-timeline__ev--warn",
|
||||
)}
|
||||
>
|
||||
<span className="inspector-timeline__node" aria-hidden="true" />
|
||||
<div className="inspector-timeline__et">{event.node}</div>
|
||||
{event.ts ? <div className="inspector-timeline__ets">{event.ts}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function activityDetail(status: SessionStatus): string | null {
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return "Session idle";
|
||||
case "needs_input":
|
||||
return "Waiting for your input";
|
||||
case "working":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_PILL: Record<
|
||||
ReturnType<typeof workerDisplayStatus> | "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 (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold"
|
||||
style={{
|
||||
color: tone,
|
||||
background: `color-mix(in srgb, ${tone} 7%, transparent)`,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tone} 25%, transparent)`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn("h-1.5 w-1.5 rounded-full", breathe && "animate-status-pulse")}
|
||||
style={{ background: tone }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewsView({
|
||||
session,
|
||||
onOpenReviewerTerminal,
|
||||
}: {
|
||||
session: WorkspaceSession;
|
||||
onOpenReviewerTerminal?: OpenReviewerTerminal;
|
||||
}) {
|
||||
const hasPr = Boolean(session.pullRequest);
|
||||
const hasPr = sortedPRs(session).length > 0;
|
||||
const queryClient = useQueryClient();
|
||||
const [reviewNotice, setReviewNotice] = useState<string | null>(null);
|
||||
const query = useQuery({
|
||||
queryKey: ["session-pr", session.id],
|
||||
enabled: hasPr,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/pr", {
|
||||
params: { path: { sessionId: session.id } },
|
||||
});
|
||||
if (error) return [] as PRFacts[];
|
||||
return data?.prs ?? [];
|
||||
},
|
||||
});
|
||||
const reviewsQuery = useQuery({
|
||||
queryKey: ["session-reviews", session.id],
|
||||
enabled: hasPr,
|
||||
|
|
@ -202,7 +336,6 @@ function SummaryView({
|
|||
},
|
||||
onSuccess: ({ data, reused }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["session-reviews", session.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["session-pr", session.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
if (reused) {
|
||||
setReviewNotice("Review is already up to date for this commit.");
|
||||
|
|
@ -213,56 +346,10 @@ function SummaryView({
|
|||
}
|
||||
},
|
||||
});
|
||||
const prFacts = query.data?.[0];
|
||||
const branchLabel = session.branch || `session/${session.id}`;
|
||||
const reviews = reviewsQuery.data?.reviews ?? [];
|
||||
|
||||
return (
|
||||
<div role="tabpanel">
|
||||
<Section
|
||||
title="Pull request"
|
||||
action={
|
||||
prFacts?.url ? (
|
||||
<a href={prFacts.url} target="_blank" rel="noopener noreferrer" className="inspector-section__link">
|
||||
Open ↗
|
||||
</a>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{!hasPr ? (
|
||||
<p className="inspector-empty">No pull request opened yet.</p>
|
||||
) : query.isLoading ? (
|
||||
<p className="inspector-empty">Loading pull request…</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="inspector-pr-summary">
|
||||
<GitPullRequest className="h-3.5 w-3.5 shrink-0 text-passive" aria-hidden="true" />
|
||||
<span className="inspector-pr-summary__title">PR #{prFacts?.number ?? session.pullRequest?.number}</span>
|
||||
{prFacts ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"inspector-pr-summary__state h-5 px-1.5 text-[10px] font-medium",
|
||||
prStateTone[prFacts.state],
|
||||
)}
|
||||
>
|
||||
{prFacts.state}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{prFacts ? (
|
||||
<dl className="inspector-kv">
|
||||
<Row k="CI" v={prFacts.ci || "—"} mono />
|
||||
<Row k="Merge" v={prFacts.mergeability || "—"} mono />
|
||||
<Row k="Review" v={prFacts.review || "—"} mono />
|
||||
</dl>
|
||||
) : (
|
||||
<p className="inspector-empty">No enriched PR facts yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Reviews">
|
||||
<ReviewPanel
|
||||
config={projectConfigQuery.data}
|
||||
|
|
@ -277,19 +364,6 @@ function SummaryView({
|
|||
session={session}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Activity">
|
||||
<ActivityTimeline reviews={reviews} session={session} />
|
||||
</Section>
|
||||
|
||||
<Section title="Overview">
|
||||
<dl className="inspector-kv">
|
||||
<Row k="Agent" v={session.provider} mono />
|
||||
<Row k="Branch" v={branchLabel} mono />
|
||||
<Row k="Started" v={formatTimeCompact(session.createdAt ?? session.updatedAt)} mono />
|
||||
<Row k="Session" v={session.id} mono />
|
||||
</dl>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -322,7 +396,7 @@ function ReviewPanel({
|
|||
onTrigger: () => void;
|
||||
onOpenTerminal?: OpenReviewerTerminal;
|
||||
}) {
|
||||
if (!session.pullRequest) {
|
||||
if (sortedPRs(session).length === 0) {
|
||||
return <p className="inspector-empty">No pull request opened yet.</p>;
|
||||
}
|
||||
if (isLoading) {
|
||||
|
|
@ -433,204 +507,6 @@ function reviewStatus(review?: ReviewRun): {
|
|||
return { label: "Complete", tone: "success", icon: <CheckCircle2 aria-hidden="true" /> };
|
||||
}
|
||||
|
||||
type TimelineTone = "now" | "good" | "warn" | "bad" | "neutral";
|
||||
|
||||
function ActivityTimeline({ session, reviews }: { session: WorkspaceSession; reviews: ReviewRun[] }) {
|
||||
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
|
||||
const detail = activityDetail(session.status);
|
||||
|
||||
events.push({
|
||||
tone: "now",
|
||||
node: (
|
||||
<>
|
||||
<span className="inspector-timeline__badge">
|
||||
<InspectorStatusPill session={session} />
|
||||
</span>
|
||||
{detail ? <span className="inspector-timeline__detail"> — {detail}</span> : null}
|
||||
</>
|
||||
),
|
||||
ts: formatTimeCompact(session.updatedAt),
|
||||
});
|
||||
|
||||
if (session.pullRequest) {
|
||||
events.push({
|
||||
tone: "good",
|
||||
node: (
|
||||
<>
|
||||
Opened <b>PR #{session.pullRequest.number}</b>
|
||||
</>
|
||||
),
|
||||
ts: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const review of reviews.slice(0, 4)) {
|
||||
events.push({
|
||||
tone: reviewTimelineTone(review),
|
||||
node: (
|
||||
<>
|
||||
{reviewTimelineLabel(review)} <span className="inspector-timeline__detail">- {review.harness}</span>
|
||||
</>
|
||||
),
|
||||
ts: formatTimeCompact(review.createdAt),
|
||||
});
|
||||
}
|
||||
|
||||
events.push({
|
||||
tone: "neutral",
|
||||
node: <>Created worktree & branch</>,
|
||||
ts: formatTimeCompact(session.createdAt ?? session.updatedAt),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="inspector-timeline">
|
||||
{events.map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"inspector-timeline__ev",
|
||||
event.tone === "now" && "inspector-timeline__ev--now",
|
||||
event.tone === "good" && "inspector-timeline__ev--good",
|
||||
event.tone === "warn" && "inspector-timeline__ev--warn",
|
||||
event.tone === "bad" && "inspector-timeline__ev--bad",
|
||||
)}
|
||||
>
|
||||
<span className="inspector-timeline__node" aria-hidden="true" />
|
||||
<div className="inspector-timeline__et">{event.node}</div>
|
||||
{event.ts ? <div className="inspector-timeline__ets">{event.ts}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function reviewTimelineTone(review: ReviewRun): TimelineTone {
|
||||
if (review.status === "failed" || review.verdict === "changes_requested") return "bad";
|
||||
if (review.status === "running") return "warn";
|
||||
if (review.verdict === "approved") return "good";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function reviewTimelineLabel(review: ReviewRun): string {
|
||||
if (review.status === "running") return "Review requested";
|
||||
if (review.status === "failed") return "Review failed";
|
||||
if (review.verdict === "approved") return "Approved";
|
||||
if (review.verdict === "changes_requested") return "Changes requested";
|
||||
return "Review complete";
|
||||
}
|
||||
|
||||
function activityDetail(status: SessionStatus): string | null {
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return "Session idle";
|
||||
case "needs_input":
|
||||
return "Waiting for your input";
|
||||
case "working":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_PILL: Record<
|
||||
ReturnType<typeof workerDisplayStatus> | "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 (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold"
|
||||
style={{
|
||||
color: tone,
|
||||
background: `color-mix(in srgb, ${tone} 7%, transparent)`,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tone} 25%, transparent)`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn("h-1.5 w-1.5 rounded-full", breathe && "animate-status-pulse")}
|
||||
style={{ background: tone }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangesView({ session }: { session: WorkspaceSession }) {
|
||||
const files = session.changedFiles ?? [];
|
||||
|
||||
return (
|
||||
<div role="tabpanel" className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="inspector-changes__head">
|
||||
<span className="inspector-changes__title">Changed</span>
|
||||
<span className="inspector-changes__count">{files.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="inspector-changes__actions">
|
||||
<button className="inspector-changes__action" type="button">
|
||||
All files
|
||||
</button>
|
||||
<button className="inspector-changes__action inspector-changes__action--danger" type="button">
|
||||
<Trash2 aria-hidden="true" />
|
||||
Discard all
|
||||
</button>
|
||||
<button className="inspector-changes__action inspector-changes__action--end" type="button">
|
||||
<Plus aria-hidden="true" />
|
||||
Stage all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="inspector-changes__list">
|
||||
{files.length === 0 ? (
|
||||
<p className="inspector-empty inspector-empty--center">No changes yet.</p>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<div className="inspector-changes__file" key={file.path}>
|
||||
<span className="inspector-changes__path">{file.path}</span>
|
||||
<span className="inspector-changes__add">+{file.additions}</span>
|
||||
<span className="inspector-changes__del">−{file.deletions}</span>
|
||||
<Square className={cn("inspector-changes__stage", file.staged && "is-staged")} aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="inspector-changes__commit">
|
||||
<input
|
||||
className="inspector-changes__input"
|
||||
defaultValue={session.commitMessage ?? ""}
|
||||
key={session.id}
|
||||
placeholder="Commit message"
|
||||
/>
|
||||
<textarea className="inspector-changes__textarea" placeholder="Description" rows={2} />
|
||||
<Button className="w-full" disabled={files.length === 0} variant="primary">
|
||||
<GitCommitHorizontal aria-hidden="true" />
|
||||
Commit & Push
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="inspector-changes__footer">
|
||||
<GitBranch aria-hidden="true" />
|
||||
<span className="inspector-changes__branch">{session.branch || "—"}</span>
|
||||
<button className="inspector-changes__pr" type="button">
|
||||
<Plus aria-hidden="true" />
|
||||
<GitPullRequest aria-hidden="true" />
|
||||
Create PR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserView() {
|
||||
return (
|
||||
<div role="tabpanel">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: (
|
|||
</div>
|
||||
{showBranch && <div className="px-[13px] pb-2.5 font-mono text-[10.5px] text-passive">{branch}</div>}
|
||||
<div className="border-t border-border px-[13px] py-2 font-mono text-[10.5px] text-passive">
|
||||
{session.pullRequest ? `PR #${session.pullRequest.number} · ${session.pullRequest.state}` : "no PR yet"}
|
||||
{prSummary(session)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const session: WorkspaceSession = {
|
|||
branch: "main",
|
||||
status: "working",
|
||||
updatedAt: "now",
|
||||
prs: [],
|
||||
};
|
||||
|
||||
type FakeMux = {
|
||||
|
|
|
|||
|
|
@ -20,15 +20,10 @@ 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, 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 () => {
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
|
|
@ -30,21 +36,11 @@ 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: sessions
|
||||
sessions: (sessionsData?.sessions ?? [])
|
||||
.filter((session) => session.projectId === project.id)
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
|
|
@ -58,7 +54,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
|
|||
status: toSessionStatus(session.status, session.isTerminated),
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
pullRequest: prBySession.get(session.id),
|
||||
prs: (session.prs ?? []).map(toPullRequestFacts),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>): WorkspaceSession {
|
|||
branch: "feat/x",
|
||||
status: "working",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
prs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const pr = (overrides: Partial<PullRequestFacts> & { 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"],
|
||||
|
|
|
|||
|
|
@ -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<PRState, number> = { 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[];
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue