From 11e5dde6bd03da8f15bcea3312da99dfc18347ef Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 23:08:10 -0700 Subject: [PATCH] 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) --- .changeset/fn-7179-pr-sidebar-list.md | 7 + .../app/__tests__/pull-request-view.test.tsx | 122 ++++++++++++- .../app/components/PullRequestView.css | 96 +++++++++++ .../app/components/PullRequestView.tsx | 163 +++++++++++++++--- packages/i18n/locales/en/app.json | 6 + 5 files changed, 370 insertions(+), 24 deletions(-) create mode 100644 .changeset/fn-7179-pr-sidebar-list.md diff --git a/.changeset/fn-7179-pr-sidebar-list.md b/.changeset/fn-7179-pr-sidebar-list.md new file mode 100644 index 0000000000..22f67a4774 --- /dev/null +++ b/.changeset/fn-7179-pr-sidebar-list.md @@ -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. diff --git a/packages/dashboard/app/__tests__/pull-request-view.test.tsx b/packages/dashboard/app/__tests__/pull-request-view.test.tsx index 06d55dcfc4..61d56c5a34 100644 --- a/packages/dashboard/app/__tests__/pull-request-view.test.tsx +++ b/packages/dashboard/app/__tests__/pull-request-view.test.tsx @@ -37,8 +37,9 @@ function makeSummary(over: Partial = {}): PrDetail["summary } function makeDetail(over: Partial = {}): 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 { }; } +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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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( 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(); + render( 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( [])} />); + + 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( { 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( 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(); + + 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(); + 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(); + + 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(); + + 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>() + .mockResolvedValueOnce([makeDetail({ id: "PR-1", repo: "owner/first", prNumber: 1 })]) + .mockResolvedValueOnce([makeDetail({ id: "PR-2", repo: "owner/second", prNumber: 2 })]); + render(); + + 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(); diff --git a/packages/dashboard/app/components/PullRequestView.css b/packages/dashboard/app/components/PullRequestView.css index 2449e5592b..6f3b6a4f2a 100644 --- a/packages/dashboard/app/components/PullRequestView.css +++ b/packages/dashboard/app/components/PullRequestView.css @@ -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; diff --git a/packages/dashboard/app/components/PullRequestView.tsx b/packages/dashboard/app/components/PullRequestView.tsx index c2c853e539..fb6de1a80b 100644 --- a/packages/dashboard/app/components/PullRequestView.tsx +++ b/packages/dashboard/app/components/PullRequestView.tsx @@ -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) => Promise; - /** Override the fetcher (tests). */ + /** Override the detail fetcher (tests). */ loadPullRequest?: (id: string) => Promise; + /** Override the list fetcher (tests). */ + loadPullRequests?: () => Promise; } function defaultLoad(projectId?: string) { @@ -83,6 +85,14 @@ function defaultLoad(projectId?: string) { }; } +function defaultLoadList(projectId?: string) { + return async (): Promise => { + 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): Promise => { const path = kind === "automerge" ? "automerge" : kind; @@ -102,46 +112,75 @@ function ChecksIcon({ rollup }: { rollup: string }) { return —; } +const PR_LOAD_TIMEOUT_MS = 15000; + +async function withPrTimeout(promise: Promise, message: string): Promise { + const timeout = new Promise((_, 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(null); const [detail, setDetail] = useState(detailProp ?? null); const [error, setError] = useState(null); + const [pullRequests, setPullRequests] = useState([]); + const [listError, setListError] = useState(null); const [busy, setBusy] = useState(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((_, 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) => { @@ -175,6 +214,76 @@ export function PullRequestView(props: PullRequestViewProps) { [detail, dispatch], ); + const viewHeader = ; + + if (isListMode) { + if (listError) { + return ( +
+ {viewHeader} +
+ {listError} +
+
+ ); + } + if (listLoading) { + return ( +
+ {viewHeader} +
{t("pr.view.listLoading", "Loading pull requests…")}
+
+ ); + } + if (pullRequests.length === 0) { + return ( +
+ {viewHeader} +
+ {t("pr.view.listEmpty", "No active pull requests to show.")} +
+
+ ); + } + return ( +
+ {viewHeader} +
{t("pr.view.listTitle", "Active pull requests")}
+
+ {pullRequests.map((pullRequest) => ( + + ))} +
+
+ ); + } + if (error && !detail) { return (
@@ -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 (
@@ -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 = ; + const backToListControl = canReturnToList ? ( + + ) : null; // ── creating ─────────────────────────────────────────────────────────────── if (state === "creating") { return (
{viewHeader} + {backToListControl}
{t("pr.view.creating", "Creating PR…")} @@ -224,6 +338,7 @@ export function PullRequestView(props: PullRequestViewProps) { return (
{viewHeader} + {backToListControl}
@@ -250,6 +365,7 @@ export function PullRequestView(props: PullRequestViewProps) { return (
{viewHeader} + {backToListControl}
{t("pr.view.verifyingGithub", "Verifying with GitHub…")} @@ -275,6 +391,7 @@ export function PullRequestView(props: PullRequestViewProps) { return (
{viewHeader} + {backToListControl} {/* responding banner */} diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 0689d6cba8..6145143636 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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:",