import { useCallback, useEffect, 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 fetcher (tests). */ loadPullRequest?: (id: string) => 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 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 —; } export function PullRequestView(props: PullRequestViewProps) { const { t } = useTranslation("app"); const { detail: detailProp, pullRequestId, projectId, onAction, loadPullRequest } = props; const [detail, setDetail] = useState(detailProp ?? null); const [error, setError] = 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. const [loading, setLoading] = useState(false); const load = loadPullRequest ?? defaultLoad(projectId); const dispatch = onAction ?? defaultAction(projectId); const refresh = useCallback(async () => { if (detailProp) { setDetail(detailProp); return; } if (!pullRequestId) { // Nothing to load — show the empty state, never an indefinite spinner. setError(null); setLoading(false); setDetail(null); 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])); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load PR"); } finally { setLoading(false); } }, [detailProp, pullRequestId, load]); 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 (detailProp || !pullRequestId) return; const handler = () => void refresh(); window.addEventListener("fusion:store-changed", handler); return () => window.removeEventListener("fusion:store-changed", handler); }, [detailProp, pullRequestId, 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], ); if (error && !detail) { return (
{error}
); } 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…". 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 viewHeader = ; // ── creating ─────────────────────────────────────────────────────────────── if (state === "creating") { return (
{viewHeader}
{t("pr.view.creating", "Creating PR…")}
); } // ── failed ─────────────────────────────────────────────────────────────── if (state === "failed") { return (
{viewHeader}
{detail.failureReason ?? t("pr.view.creationFailed", "PR creation failed")}
{error &&
{error}
}
); } // ── unverified ───────────────────────────────────────────────────────────── if (detail.unverified) { return (
{viewHeader}
{t("pr.view.verifyingGithub", "Verifying with GitHub…")}
{/* checks/threads hidden while unverified */}
); } const conflicting = summary.conflicting; return (
{viewHeader} {/* 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}
); }