import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { GitPullRequest, GitMerge, CheckCircle, XCircle, Clock, AlertTriangle, ExternalLink, RotateCcw, ThumbsUp, MessageSquare, } from "lucide-react"; import { api } from "../api"; import { ViewHeader } from "./ViewHeader"; import "./PullRequestView.css"; // Mirrors the route's serialized entity (register-pull-requests-routes.ts). export type PrThread = { prEntityId: string; threadId: string; headOid: string; outcome: "fixed" | "disagreed" | "pending"; fixCommitSha?: string; updatedAt: number; }; export type PrSummary = { mergeable: string; reviewDecision: string | null; checksRollup: string; conflicting: boolean; autoMerge: boolean; autoMergeReason: string; autoMergeReady: boolean; actionable: boolean; active: boolean; pendingThreads: number; disagreedThreads: number; }; export type PrDetail = { id: string; sourceType: "task" | "branch-group"; sourceId: string; repo: string; headBranch: string; baseBranch?: string; state: "creating" | "open" | "responding" | "merged" | "closed" | "failed"; prNumber?: number; prUrl?: string; mergeable?: string; checksRollup?: string; reviewDecision?: string | null; autoMerge: boolean; unverified: boolean; failureReason?: string; responseRounds: number; threads: PrThread[]; summary: PrSummary; }; type ActionKind = "approve" | "merge" | "retry" | "close" | "automerge" | "retry-create"; export interface PullRequestViewProps { /** When provided, render this detail directly (tests / parent-supplied data). */ detail?: PrDetail | null; /** Entity id to self-fetch when `detail` is not provided. */ pullRequestId?: string; projectId?: string; /** Override the action dispatcher (tests). Defaults to the POST routes. */ onAction?: (kind: ActionKind, id: string, body?: Record) => Promise; /** Override the detail fetcher (tests). */ loadPullRequest?: (id: string) => Promise; /** Override the list fetcher (tests). */ loadPullRequests?: () => Promise; } function defaultLoad(projectId?: string) { return async (id: string): Promise => { const q = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const res = await api<{ pullRequest: PrDetail }>(`/pull-requests/${id}${q}`); return res.pullRequest; }; } 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; const res = await api<{ pullRequest: PrDetail }>(`/pull-requests/${id}/${path}`, { method: "POST", body: JSON.stringify({ ...(body ?? {}), ...(projectId ? { projectId } : {}) }), headers: { "content-type": "application/json" }, }); return res.pullRequest; }; } function ChecksIcon({ rollup }: { rollup: string }) { if (rollup === "success") return ; if (rollup === "failure") return ; if (rollup === "pending") return ; 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, 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-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 = 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 (hasDetailProp) { setDetail(detailProp ?? null); setError(null); setLoading(false); 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. 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); } }, [activePullRequestId, detailProp, hasDetailProp, load, loadList, t]); useEffect(() => { void refresh(); }, [refresh]); // Live updates: re-poll on the store-event / SSE channel the rest of the app // 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 (hasDetailProp) return; const handler = () => void refresh(); window.addEventListener("fusion:store-changed", handler); return () => window.removeEventListener("fusion:store-changed", handler); }, [hasDetailProp, refresh]); const runAction = useCallback( async (kind: ActionKind, body?: Record) => { if (!detail) return; try { setBusy(kind); setError(null); const fresh = await dispatch(kind, detail.id, body); setDetail(fresh); } catch (err) { setError(err instanceof Error ? err.message : `Action ${kind} failed`); } finally { setBusy(null); setConfirmingMerge(false); } }, [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 (
{error}
); } if (!detail) { // 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 (
{t("pr.view.loading", "Loading PR…")}
); } return (
{t("pr.view.empty", "No pull request to show.")}
); } const { state, summary } = detail; /* 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 backToListControl = canReturnToList ? ( ) : null; // ── creating ─────────────────────────────────────────────────────────────── if (state === "creating") { return (
{viewHeader} {backToListControl}
{t("pr.view.creating", "Creating PR…")}
); } // ── failed ─────────────────────────────────────────────────────────────── if (state === "failed") { return (
{viewHeader} {backToListControl}
{detail.failureReason ?? t("pr.view.creationFailed", "PR creation failed")}
{error &&
{error}
}
); } // ── unverified ───────────────────────────────────────────────────────────── if (detail.unverified) { return (
{viewHeader} {backToListControl}
{t("pr.view.verifyingGithub", "Verifying with GitHub…")}
{/* checks/threads hidden while unverified */}
); } const conflicting = summary.conflicting; return (
{viewHeader} {backToListControl} {/* responding banner */} {state === "responding" && (
{t("pr.view.responsePending", "Response run in progress — {{count}} threads pending", { count: summary.pendingThreads })}
)} {/* ── action bar ──────────────────────────────────────────────────── */}
{!confirmingMerge ? ( ) : ( )}
{/* conflict link */} {conflicting && detail.prUrl && ( {t("pr.view.resolveConflictsOnGithub", "Resolve conflicts on GitHub")} )} {/* ── merge-readiness summary ─────────────────────────────────────── */}
{t("pr.view.mergeableLabel", "Mergeable:")} {summary.mergeable} {t("pr.view.reviewLabel", "Review:")} {summary.reviewDecision ?? t("pr.view.none", "none")} {summary.checksRollup}
{/* ── threads (agent replies nested) ───────────────────────────────── */}
{detail.threads.length === 0 ? (
{t("pr.view.noReviewThreads", "No review threads.")}
) : ( detail.threads.map((thread) => (
{thread.outcome === "pending" && ( {t("pr.view.threadPending", "pending")} )} {thread.outcome === "disagreed" && ( {t("pr.view.agentDisagreed", "agent disagreed")} )} {thread.outcome === "fixed" && ( {t("pr.view.threadFixed", "fixed")} )} {thread.threadId}
{thread.fixCommitSha && (
{t("pr.view.agentReplyFix", "Agent reply — fix {{sha}}", { sha: thread.fixCommitSha.slice(0, 8) })}
)}
)) )}
{error &&
{error}
}
); } function PrIdentityHeader({ detail }: { detail: PrDetail }) { return (
{detail.repo} {detail.prNumber != null ? ( detail.prUrl ? ( #{detail.prNumber} ) : ( #{detail.prNumber} ) ) : null} {detail.headBranch} {detail.state}
); }