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:
7
.changeset/fn-7179-pr-sidebar-list.md
Normal file
7
.changeset/fn-7179-pr-sidebar-list.md
Normal 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.
|
||||
@@ -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" })} />);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user