FN-7179: show active PRs in pull request views

Add list mode so Pull Requests hosts without a selected PR display active project pull requests.

- Fetch and render the active pull request list for no-id PullRequestView mounts.
- Allow selecting a PR for detail view and returning to the active PR list.
- Add list loading, empty, error, styling, i18n, regression tests, and a patch changeset.

Files changed:
 .changeset/fn-7179-pr-sidebar-list.md              |   7 +
 .../app/__tests__/pull-request-view.test.tsx       | 122 ++++++++++++++-
 .../dashboard/app/components/PullRequestView.css   |  96 ++++++++++++
 .../dashboard/app/components/PullRequestView.tsx   | 163 ++++++++++++++++++---
 packages/i18n/locales/en/app.json                  |   6 +
 5 files changed, 370 insertions(+), 24 deletions(-)

Fusion-Task-Id: FN-7179
Fusion-Task-Lineage: 712dccae-eb03-4aea-b89e-0a333db7c3fc
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 23:08:10 -07:00
parent 987abc763b
commit 11e5dde6bd
5 changed files with 370 additions and 24 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show active project pull requests in the Pull Requests sidebar and main view.
category: fix
dev: Adds PullRequestView list mode for no-id hosts with selectable detail and back navigation.

View File

@@ -37,8 +37,9 @@ function makeSummary(over: Partial<PrDetail["summary"]> = {}): PrDetail["summary
}
function makeDetail(over: Partial<PrDetail> = {}): PrDetail {
const id = over.id ?? "PR-1";
return {
id: "PR-1",
id,
sourceType: "task",
sourceId: "FN-1",
repo: "owner/repo",
@@ -58,6 +59,125 @@ function makeDetail(over: Partial<PrDetail> = {}): PrDetail {
};
}
function makeList(): PrDetail[] {
return [
makeDetail({ id: "PR-1", repo: "owner/repo", headBranch: "feature/x", prNumber: 42 }),
makeDetail({ id: "PR-2", repo: "owner/other", headBranch: "feature/y", prNumber: 7 }),
];
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("PullRequestView list mode", () => {
it("renders project PRs when mounted without an id or detail", async () => {
render(<PullRequestView loadPullRequests={vi.fn(async () => makeList())} />);
const list = await screen.findByTestId("pr-list");
expect(list).toBeTruthy();
expect(screen.getAllByTestId("pr-list-item")).toHaveLength(2);
expect(screen.getByText("owner/repo")).toBeTruthy();
expect(screen.getByText("#42")).toBeTruthy();
expect(screen.queryByTestId("pr-view-empty")).toBeNull();
});
it("shows a bounded loading state while the list fetch is in flight", async () => {
const pending = deferred<PrDetail[]>();
render(<PullRequestView loadPullRequests={() => pending.promise} />);
expect(await screen.findByTestId("pr-list-loading")).toBeTruthy();
pending.resolve(makeList());
expect(await screen.findByTestId("pr-list")).toBeTruthy();
expect(screen.queryByTestId("pr-list-loading")).toBeNull();
});
it("shows the list empty state for zero active PRs without spinning", async () => {
render(<PullRequestView loadPullRequests={vi.fn(async () => [])} />);
expect(await screen.findByTestId("pr-list-empty")).toBeTruthy();
expect(screen.queryByTestId("pr-list-loading")).toBeNull();
expect(screen.queryByTestId("pr-view-empty")).toBeNull();
});
it("shows a list error state when the list fetch fails", async () => {
render(<PullRequestView loadPullRequests={vi.fn(async () => { throw new Error("network down"); })} />);
expect(await screen.findByTestId("pr-list-error")).toHaveTextContent("network down");
expect(screen.queryByTestId("pr-list-loading")).toBeNull();
});
it("loads selected PR detail and returns back to the list", async () => {
const list = makeList();
const loadPullRequest = vi.fn(async (id: string) => makeDetail({ id, repo: "owner/selected", prNumber: 99 }));
render(<PullRequestView loadPullRequests={vi.fn(async () => list)} loadPullRequest={loadPullRequest} />);
fireEvent.click((await screen.findAllByTestId("pr-list-item"))[1]);
await waitFor(() => expect(loadPullRequest).toHaveBeenCalledWith("PR-2"));
expect(await screen.findByTestId("pr-view")).toBeTruthy();
expect(screen.getByText("owner/selected")).toBeTruthy();
fireEvent.click(screen.getByTestId("pr-back-to-list"));
expect(await screen.findByTestId("pr-list")).toBeTruthy();
});
it("does not enter list mode for parent-supplied detail, explicit null detail, or explicit ids", async () => {
const loadPullRequests = vi.fn(async () => makeList());
const { rerender } = render(<PullRequestView detail={makeDetail()} loadPullRequests={loadPullRequests} />);
expect(screen.getByTestId("pr-view")).toBeTruthy();
expect(screen.queryByTestId("pr-list")).toBeNull();
expect(screen.queryByTestId("pr-back-to-list")).toBeNull();
expect(loadPullRequests).not.toHaveBeenCalled();
rerender(<PullRequestView detail={null} loadPullRequests={loadPullRequests} />);
expect(screen.getByTestId("pr-view-empty")).toBeTruthy();
expect(screen.queryByTestId("pr-list")).toBeNull();
expect(screen.queryByTestId("pr-back-to-list")).toBeNull();
expect(loadPullRequests).not.toHaveBeenCalled();
const loadPullRequest = vi.fn(async () => makeDetail({ id: "PR-9", repo: "owner/explicit" }));
rerender(<PullRequestView pullRequestId="PR-9" loadPullRequest={loadPullRequest} loadPullRequests={loadPullRequests} />);
await waitFor(() => expect(loadPullRequest).toHaveBeenCalledWith("PR-9"));
expect(await screen.findByText("owner/explicit")).toBeTruthy();
expect(screen.queryByTestId("pr-list")).toBeNull();
expect(screen.queryByTestId("pr-back-to-list")).toBeNull();
expect(loadPullRequests).not.toHaveBeenCalled();
});
it("enters list mode when a host forwards an undefined detail prop", async () => {
const loadPullRequests = vi.fn(async () => makeList());
render(<PullRequestView detail={undefined} loadPullRequests={loadPullRequests} />);
expect(await screen.findByTestId("pr-list")).toBeTruthy();
expect(screen.getAllByTestId("pr-list-item")).toHaveLength(2);
expect(screen.queryByTestId("pr-view-empty")).toBeNull();
expect(loadPullRequests).toHaveBeenCalledTimes(1);
});
it("refreshes the list on fusion store-changed events", async () => {
const loadPullRequests = vi
.fn<() => Promise<PrDetail[]>>()
.mockResolvedValueOnce([makeDetail({ id: "PR-1", repo: "owner/first", prNumber: 1 })])
.mockResolvedValueOnce([makeDetail({ id: "PR-2", repo: "owner/second", prNumber: 2 })]);
render(<PullRequestView loadPullRequests={loadPullRequests} />);
expect(await screen.findByText("owner/first")).toBeTruthy();
window.dispatchEvent(new Event("fusion:store-changed"));
await waitFor(() => expect(loadPullRequests).toHaveBeenCalledTimes(2));
expect(await screen.findByText("owner/second")).toBeTruthy();
expect(screen.queryByText("owner/first")).toBeNull();
});
});
describe("PullRequestView per-node-state rendering", () => {
it("creating → 'Creating PR…' placeholder", () => {
render(<PullRequestView detail={makeDetail({ state: "creating" })} />);

View File

@@ -26,6 +26,102 @@ The view now renders the shared ViewHeader at the top, which supplies the --spac
color: var(--color-error);
}
/* list mode */
.pr-list-title {
font-weight: 600;
color: var(--text);
}
.pr-list-items {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.pr-list-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md, var(--space-xs));
background: var(--surface, transparent);
color: var(--text);
text-align: left;
cursor: pointer;
}
.pr-list-item:hover,
.pr-list-item:focus-visible {
border-color: var(--accent, var(--border));
background: var(--surface-2, transparent);
}
.pr-list-item-main,
.pr-list-item-meta,
.pr-list-item-checks,
.pr-list-state {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.pr-list-item-main {
min-width: 0;
flex-wrap: wrap;
}
.pr-list-item-meta {
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
}
.pr-list-item-repo {
font-weight: 600;
}
.pr-list-item-number {
color: var(--accent, var(--text));
}
.pr-list-item-branch {
color: var(--text-muted);
font-family: var(--font-mono, monospace);
font-size: 0.85em;
overflow-wrap: anywhere;
}
.pr-list-item-checks {
color: var(--text-muted);
font-size: 0.85em;
}
.pr-list-state {
color: var(--text-muted);
}
.pr-list-state--error {
color: var(--color-error);
}
.pr-back-to-list {
align-self: flex-start;
}
@media (max-width: 768px) {
.pr-list-item {
flex-direction: column;
align-items: stretch;
}
.pr-list-item-meta {
justify-content: flex-start;
}
}
/* identity header */
.pr-identity {
display: flex;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
GitPullRequest,
@@ -71,8 +71,10 @@ export interface PullRequestViewProps {
projectId?: string;
/** Override the action dispatcher (tests). Defaults to the POST routes. */
onAction?: (kind: ActionKind, id: string, body?: Record<string, unknown>) => Promise<PrDetail>;
/** Override the fetcher (tests). */
/** Override the detail fetcher (tests). */
loadPullRequest?: (id: string) => Promise<PrDetail>;
/** Override the list fetcher (tests). */
loadPullRequests?: () => Promise<PrDetail[]>;
}
function defaultLoad(projectId?: string) {
@@ -83,6 +85,14 @@ function defaultLoad(projectId?: string) {
};
}
function defaultLoadList(projectId?: string) {
return async (): Promise<PrDetail[]> => {
const q = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const res = await api<{ pullRequests: PrDetail[] }>(`/pull-requests${q}`);
return res.pullRequests;
};
}
function defaultAction(projectId?: string) {
return async (kind: ActionKind, id: string, body?: Record<string, unknown>): Promise<PrDetail> => {
const path = kind === "automerge" ? "automerge" : kind;
@@ -102,46 +112,75 @@ function ChecksIcon({ rollup }: { rollup: string }) {
return <span className="pr-icon-none">—</span>;
}
const PR_LOAD_TIMEOUT_MS = 15000;
async function withPrTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
const timeout = new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(message)), PR_LOAD_TIMEOUT_MS),
);
return Promise.race([promise, timeout]);
}
export function PullRequestView(props: PullRequestViewProps) {
const { t } = useTranslation("app");
const { detail: detailProp, pullRequestId, projectId, onAction, loadPullRequest } = props;
const { detail: detailProp, pullRequestId, projectId, onAction, loadPullRequest, loadPullRequests } = props;
// FNXC:PullRequests 2026-06-27-22:59: Optional host props may forward `detail={undefined}` while no PR is selected. Treat only concrete detail values (including explicit null) as controlled detail mode so undefined still follows the no-id list invariant.
const hasDetailProp = detailProp !== undefined;
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<PrDetail | null>(detailProp ?? null);
const [error, setError] = useState<string | null>(null);
const [pullRequests, setPullRequests] = useState<PrDetail[]>([]);
const [listError, setListError] = useState<string | null>(null);
const [busy, setBusy] = useState<ActionKind | null>(null);
const [confirmingMerge, setConfirmingMerge] = useState(false);
// FNXC:PullRequests 2026-06-23-00:45: `loading` is true ONLY while a fetch is in flight. Previously detail===null always rendered the spinner, so with no pullRequestId (nothing to load) the view hung on "Loading PR…" forever. Now no-id → empty state, and the fetch is time-bounded so a hung request surfaces an error instead of spinning indefinitely.
// FNXC:PullRequests 2026-06-27-00:00: `loading` and `listLoading` are true ONLY while a fetch is in flight. No-id now enters bounded list mode so the sidebar shows active project PRs; explicit empty/detail states still never hang on an endless spinner.
const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(false);
const load = loadPullRequest ?? defaultLoad(projectId);
const dispatch = onAction ?? defaultAction(projectId);
const load = useMemo(() => loadPullRequest ?? defaultLoad(projectId), [loadPullRequest, projectId]);
const loadList = useMemo(() => loadPullRequests ?? defaultLoadList(projectId), [loadPullRequests, projectId]);
const dispatch = useMemo(() => onAction ?? defaultAction(projectId), [onAction, projectId]);
const activePullRequestId = pullRequestId ?? selectedId ?? undefined;
const isListMode = !hasDetailProp && !pullRequestId && !selectedId;
const canReturnToList = !hasDetailProp && !pullRequestId && Boolean(selectedId);
const refresh = useCallback(async () => {
if (detailProp) {
setDetail(detailProp);
return;
}
if (!pullRequestId) {
// Nothing to load — show the empty state, never an indefinite spinner.
if (hasDetailProp) {
setDetail(detailProp ?? null);
setError(null);
setLoading(false);
setDetail(null);
return;
}
if (!activePullRequestId) {
/*
FNXC:PullRequests 2026-06-27-00:00:
The right-dock Pull Requests tab and main-content pull-requests view mount this component with no PR id. No-id must mean project PR list mode, not the old empty detail state, so active PRs remain visible and selectable from every host.
*/
try {
setError(null);
setListError(null);
setListLoading(true);
setDetail(null);
setPullRequests(await withPrTimeout(loadList(), "Timed out loading pull requests"));
} catch (err) {
setPullRequests([]);
setListError(err instanceof Error ? err.message : t("pr.view.listError", "Failed to load pull requests"));
} finally {
setListLoading(false);
}
return;
}
try {
setError(null);
setLoading(true);
// Time-bound the fetch (15s) so a hung request resolves into an error state.
const PR_LOAD_TIMEOUT_MS = 15000;
const timeout = new Promise<PrDetail>((_, reject) =>
setTimeout(() => reject(new Error("Timed out loading pull request")), PR_LOAD_TIMEOUT_MS),
);
setDetail(await Promise.race([load(pullRequestId), timeout]));
setDetail(await withPrTimeout(load(activePullRequestId), "Timed out loading pull request"));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load PR");
} finally {
setLoading(false);
}
}, [detailProp, pullRequestId, load]);
}, [activePullRequestId, detailProp, hasDetailProp, load, loadList, t]);
useEffect(() => {
void refresh();
@@ -151,11 +190,11 @@ export function PullRequestView(props: PullRequestViewProps) {
// uses. We listen for the lightweight "store-changed" window event the SSE
// bridge dispatches; each tick re-reads authoritative state from the route.
useEffect(() => {
if (detailProp || !pullRequestId) return;
if (hasDetailProp) return;
const handler = () => void refresh();
window.addEventListener("fusion:store-changed", handler);
return () => window.removeEventListener("fusion:store-changed", handler);
}, [detailProp, pullRequestId, refresh]);
}, [hasDetailProp, refresh]);
const runAction = useCallback(
async (kind: ActionKind, body?: Record<string, unknown>) => {
@@ -175,6 +214,76 @@ export function PullRequestView(props: PullRequestViewProps) {
[detail, dispatch],
);
const viewHeader = <ViewHeader icon={GitPullRequest} title={t("pr.view.title", "Pull Requests")} />;
if (isListMode) {
if (listError) {
return (
<div className="pr-view pr-view--error" data-testid="pr-list-error">
{viewHeader}
<div className="pr-list-state pr-list-state--error">
<AlertTriangle size={16} /> {listError}
</div>
</div>
);
}
if (listLoading) {
return (
<div className="pr-view pr-view--loading" data-testid="pr-list-loading">
{viewHeader}
<div className="pr-list-state">{t("pr.view.listLoading", "Loading pull requests…")}</div>
</div>
);
}
if (pullRequests.length === 0) {
return (
<div className="pr-view pr-view--empty" data-testid="pr-list-empty">
{viewHeader}
<div className="pr-list-state">
<GitPullRequest size={16} /> {t("pr.view.listEmpty", "No active pull requests to show.")}
</div>
</div>
);
}
return (
<div className="pr-view" data-testid="pr-list">
{viewHeader}
<div className="pr-list-title">{t("pr.view.listTitle", "Active pull requests")}</div>
<div className="pr-list-items">
{pullRequests.map((pullRequest) => (
<button
key={pullRequest.id}
type="button"
className="pr-list-item"
data-testid="pr-list-item"
aria-label={t("pr.view.listItemLabel", "Open pull request {{repo}} {{number}}", {
repo: pullRequest.repo,
number: pullRequest.prNumber != null ? `#${pullRequest.prNumber}` : pullRequest.headBranch,
})}
onClick={() => {
setError(null);
setLoading(true);
setSelectedId(pullRequest.id);
}}
>
<span className="pr-list-item-main">
<span className="pr-list-item-repo">{pullRequest.repo}</span>
{pullRequest.prNumber != null && <span className="pr-list-item-number">#{pullRequest.prNumber}</span>}
<span className="pr-list-item-branch">{pullRequest.headBranch}</span>
</span>
<span className="pr-list-item-meta">
<span className={`pr-identity-state pr-identity-state--${pullRequest.state}`}>{pullRequest.state}</span>
<span className="pr-list-item-checks">
<ChecksIcon rollup={pullRequest.summary.checksRollup} /> {pullRequest.summary.checksRollup}
</span>
</span>
</button>
))}
</div>
</div>
);
}
if (error && !detail) {
return (
<div className="pr-view pr-view--error" data-testid="pr-view-error">
@@ -183,7 +292,7 @@ export function PullRequestView(props: PullRequestViewProps) {
);
}
if (!detail) {
// FNXC:PullRequests 2026-06-23-00:45: Only show the spinner while actually fetching; otherwise (no PR id / nothing to load / timed out) show the empty state so the view never hangs on an endless "Loading PR…".
// FNXC:PullRequests 2026-06-27-00:00: Only show the detail spinner while actually fetching an explicit PR; list mode owns no-id loading/empty/error so the view never hangs on an endless "Loading PR…".
if (loading) {
return (
<div className="pr-view pr-view--loading" data-testid="pr-view-loading">
@@ -204,13 +313,18 @@ export function PullRequestView(props: PullRequestViewProps) {
FNXC:PullRequests 2026-06-22-01:00:
Added the shared ViewHeader (GitPullRequest icon, matching the left-sidebar nav) at the top of every populated PR state so the view reads consistently with other main-content views. The PR-specific identity row (repo/number/branch/state) stays below it. ViewHeader supplies the standard --space-lg top/side padding; the view body must not repeat the top padding.
*/
const viewHeader = <ViewHeader icon={GitPullRequest} title={t("pr.view.title", "Pull Requests")} />;
const backToListControl = canReturnToList ? (
<button type="button" className="pr-back-to-list btn" data-testid="pr-back-to-list" onClick={() => setSelectedId(null)}>
{t("pr.view.backToList", "Back to list")}
</button>
) : null;
// ── creating ───────────────────────────────────────────────────────────────
if (state === "creating") {
return (
<div className="pr-view" data-testid="pr-view" data-state="creating">
{viewHeader}
{backToListControl}
<PrIdentityHeader detail={detail} />
<div className="pr-placeholder" data-testid="pr-creating">
<Clock size={16} /> {t("pr.view.creating", "Creating PR…")}
@@ -224,6 +338,7 @@ export function PullRequestView(props: PullRequestViewProps) {
return (
<div className="pr-view" data-testid="pr-view" data-state="failed">
{viewHeader}
{backToListControl}
<PrIdentityHeader detail={detail} />
<div className="pr-error-reason" data-testid="pr-failed">
<AlertTriangle size={16} className="pr-icon-failure" />
@@ -250,6 +365,7 @@ export function PullRequestView(props: PullRequestViewProps) {
return (
<div className="pr-view" data-testid="pr-view" data-state="unverified">
{viewHeader}
{backToListControl}
<PrIdentityHeader detail={detail} />
<div className="pr-notice pr-notice--unverified" data-testid="pr-unverified">
<Clock size={16} /> {t("pr.view.verifyingGithub", "Verifying with GitHub…")}
@@ -275,6 +391,7 @@ export function PullRequestView(props: PullRequestViewProps) {
return (
<div className="pr-view" data-testid="pr-view" data-state={state}>
{viewHeader}
{backToListControl}
<PrIdentityHeader detail={detail} />
{/* responding banner */}

View File

@@ -4808,6 +4808,12 @@
"confirmMerge": "Confirm merge",
"creating": "Creating PR…",
"creationFailed": "PR creation failed",
"backToList": "Back to list",
"listEmpty": "No active pull requests to show.",
"listError": "Failed to load pull requests",
"listItemLabel": "Open pull request {{repo}} {{number}}",
"listLoading": "Loading pull requests…",
"listTitle": "Active pull requests",
"loading": "Loading PR…",
"merge": "Merge",
"mergeableLabel": "Mergeable:",