import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react"; import ReactMarkdown from "react-markdown"; import { useTranslation } from "react-i18next"; import { AlertTriangle, CheckCircle2, RefreshCw, Sparkles, X, XCircle } from "lucide-react"; import remarkGfm from "remark-gfm"; import { getErrorMessage, type PrInfo, type StructuredGhError } from "@fusion/core"; import { createPr, fetchPrOptions, fetchPrPreflight, generatePrMetadata, pushPrBranch, resolvePrConflicts, type PrOptionsLabel, type PrOptionsResponse, type PrOptionsUser, type PrPreflightResponse, } from "../api"; import type { ToastType } from "../hooks/useToast"; import { FloatingWindow } from "./FloatingWindow"; import { sharedRehypePlugins } from "./markdownPipeline"; import "./PrCreateModal.css"; interface PrCreateModalProps { open: boolean; taskId: string; projectId?: string; defaultBaseBranch?: string; onClose: () => void; onCreated: (prInfo: PrInfo) => void; addToast: (message: string, type?: ToastType) => void; } type ModalGhError = StructuredGhError & { operation: "create" }; type PreflightCheck = { key: string; label: string; ok: boolean; message: string; warning?: boolean; }; const PR_METADATA_TIMEOUT_MS = 15000; const PR_CREATE_BODY_PREVIEW_STORAGE_KEY = "fn-pr-create-body-preview"; function readBooleanPref(key: string, defaultValue: boolean): boolean { if (typeof window === "undefined") return defaultValue; try { const raw = window.localStorage.getItem(key); if (raw === null) return defaultValue; return raw === "true"; } catch { return defaultValue; } } function writeBooleanPref(key: string, value: boolean): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(key, value ? "true" : "false"); } catch { // ignore storage failures (quota, private mode, etc.) } } /* FNXC:PrCreateModal 2026-06-27-23:48: AI PR metadata generation must never leave the Create PR dialog in a permanent loading state. Bound the call to the same 15s budget as PR view fetches, then route timeout failures through the existing metadata error/manual-body fallback path so users can recover manually. */ async function withPrMetadataTimeout(promise: Promise): Promise { let timeoutId: ReturnType | undefined; const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error("Timed out generating PR metadata")), PR_METADATA_TIMEOUT_MS); }); try { return await Promise.race([promise, timeout]); } finally { if (timeoutId !== undefined) { clearTimeout(timeoutId); } } } /* FNXC:PrCreateModal 2026-06-23-00:00: The Create PR modal must stay manually usable after metadata generation fails, but non-interactive GitHub PR creation cannot accept a title-only payload. Seed an editable body fallback with the required sections so users can complete or revise the PR instead of submitting an empty body. */ function buildManualPrBodyFallback(taskId: string): string { return [ "## Summary", "", "Summary unavailable. Add context before creating this PR.", "", "## Changes", "", "- Details unavailable.", "", "## Testing", "", "- Not provided.", "", "## Linked Task", "", `Closes ${taskId}`, ].join("\n"); } function OptionChips( { label, options, selected, onChange, getKey, getLabel, includeColor, }: { label: string; options: T[]; selected: T[]; onChange: (next: T[]) => void; getKey: (option: T) => string; getLabel: (option: T) => string; includeColor?: boolean; }, ) { const [query, setQuery] = useState(""); const available = useMemo(() => options.filter((opt) => !selected.some((value) => getKey(value) === getKey(opt))), [getKey, options, selected]); const filtered = useMemo(() => { const normalized = query.trim().toLowerCase(); if (!normalized) return available; return available.filter((opt) => getLabel(opt).toLowerCase().includes(normalized)); }, [available, getLabel, query]); return (
{selected.map((item) => { const key = getKey(item); const hasColor = Boolean(includeColor && item.color); const chipStyle = hasColor ? ({ "--pr-chip-label-color": "#" + item.color } as CSSProperties) : undefined; return ( {getLabel(item)} ); })}
setQuery(event.target.value)} placeholder={`Filter ${label.toLowerCase()}`} aria-label={`Filter ${label.toLowerCase()}`} /> {filtered.length > 0 && (
{filtered.map((item) => ( ))}
)}
); } export function PrCreateModal({ open, taskId, projectId, defaultBaseBranch, onClose, onCreated, addToast, }: PrCreateModalProps) { const { t } = useTranslation("app"); const headingId = useId(); const modalRef = useRef(null); const restoreFocusRef = useRef(null); const requestSeqRef = useRef({ metadata: 0, preflight: 0, options: 0 }); const preflightRef = useRef(null); const optionsRef = useRef(null); const baseBranchTouchedRef = useRef(false); const [metadataLoading, setMetadataLoading] = useState(false); const [preflightLoading, setPreflightLoading] = useState(false); const [optionsLoading, setOptionsLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [metadataError, setMetadataError] = useState(null); const [preflightError, setPreflightError] = useState(null); const [optionsError, setOptionsError] = useState(null); const [pushBranchError, setPushBranchError] = useState(null); const [resolveConflictError, setResolveConflictError] = useState(null); const [lastGhError, setLastGhError] = useState(null); const [aiTitle, setAiTitle] = useState(""); const [aiBody, setAiBody] = useState(""); const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const [showBodyPreview, setShowBodyPreview] = useState(() => readBooleanPref(PR_CREATE_BODY_PREVIEW_STORAGE_KEY, false)); const [userEditedTitle, setUserEditedTitle] = useState(false); const [userEditedBody, setUserEditedBody] = useState(false); const [templateUsed, setTemplateUsed] = useState(false); const [options, setOptions] = useState(null); const [preflight, setPreflight] = useState(null); const [baseBranch, setBaseBranch] = useState(""); const [draft, setDraft] = useState(false); const [pushingBranch, setPushingBranch] = useState(false); const [resolvingConflicts, setResolvingConflicts] = useState(false); const [reviewers, setReviewers] = useState([]); const [assignees, setAssignees] = useState([]); const [labels, setLabels] = useState([]); const applyPreferredBase = useCallback((baseOverride?: string, nextPreflight?: PrPreflightResponse | null, nextOptions?: PrOptionsResponse | null) => { if (baseBranchTouchedRef.current) { return; } const preferredBase = baseOverride ?? defaultBaseBranch ?? nextPreflight?.defaultBaseBranch ?? nextOptions?.baseBranches[0] ?? ""; setBaseBranch(preferredBase); }, [defaultBaseBranch]); const loadMetadata = useCallback(async (resetContent = false) => { const requestId = ++requestSeqRef.current.metadata; setMetadataLoading(true); setMetadataError(null); if (resetContent) { setAiTitle(""); setAiBody(""); setTitle(""); setBody(""); setTemplateUsed(false); setUserEditedTitle(false); setUserEditedBody(false); } try { const metadata = await withPrMetadataTimeout(generatePrMetadata(taskId, projectId)); if (requestId !== requestSeqRef.current.metadata) { return; } setAiTitle(metadata.title); setAiBody(metadata.body); setTitle((current) => (current.trim() ? current : metadata.title)); setBody((current) => (current.trim() ? current : metadata.body)); setTemplateUsed(metadata.templateUsed); } catch (loadError) { if (requestId === requestSeqRef.current.metadata) { setMetadataError(getErrorMessage(loadError)); setBody((current) => (current.trim() ? current : buildManualPrBodyFallback(taskId))); setAiBody((current) => current || buildManualPrBodyFallback(taskId)); } } finally { if (requestId === requestSeqRef.current.metadata) { setMetadataLoading(false); } } }, [projectId, taskId]); const loadPreflight = useCallback(async (baseOverride?: string, resetData = false) => { const requestId = ++requestSeqRef.current.preflight; setPreflightLoading(true); setPreflightError(null); setPushBranchError(null); setResolveConflictError(null); if (resetData) { preflightRef.current = null; setPreflight(null); } try { const preflightData = await fetchPrPreflight(taskId, projectId, baseOverride); if (requestId !== requestSeqRef.current.preflight) { return; } preflightRef.current = preflightData; setPreflight(preflightData); applyPreferredBase(baseOverride, preflightData, optionsRef.current); } catch (loadError) { if (requestId === requestSeqRef.current.preflight) { setPreflightError(getErrorMessage(loadError)); } } finally { if (requestId === requestSeqRef.current.preflight) { setPreflightLoading(false); } } }, [applyPreferredBase, projectId, taskId]); const loadOptions = useCallback(async (resetData = false) => { const requestId = ++requestSeqRef.current.options; setOptionsLoading(true); setOptionsError(null); if (resetData) { optionsRef.current = null; setOptions(null); } try { const optionsData = await fetchPrOptions(taskId, projectId); if (requestId !== requestSeqRef.current.options) { return; } optionsRef.current = optionsData; setOptions(optionsData); applyPreferredBase(undefined, preflightRef.current, optionsData); } catch (loadError) { if (requestId === requestSeqRef.current.options) { setOptionsError(getErrorMessage(loadError)); } } finally { if (requestId === requestSeqRef.current.options) { setOptionsLoading(false); } } }, [applyPreferredBase, projectId, taskId]); const loadData = useCallback((baseOverride?: string) => { baseBranchTouchedRef.current = false; setSubmitError(null); setLastGhError(null); setPushBranchError(null); setResolveConflictError(null); setDraft(false); setReviewers([]); setAssignees([]); setLabels([]); setBaseBranch(""); void loadMetadata(true); void loadPreflight(baseOverride, true); void loadOptions(true); }, [loadMetadata, loadOptions, loadPreflight]); useEffect(() => { if (!open) return; loadData(); return () => { requestSeqRef.current.metadata += 1; requestSeqRef.current.preflight += 1; requestSeqRef.current.options += 1; }; }, [loadData, open]); useEffect(() => { writeBooleanPref(PR_CREATE_BODY_PREVIEW_STORAGE_KEY, showBodyPreview); }, [showBodyPreview]); useEffect(() => { if (!open) return; restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; const focusable = modalRef.current?.querySelector("input, textarea, select, button, [tabindex]:not([tabindex='-1'])"); focusable?.focus(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); onClose(); return; } if (event.key !== "Tab" || !modalRef.current) return; const elements = Array.from(modalRef.current.querySelectorAll("button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])")); if (elements.length === 0) return; const first = elements[0]; const last = elements[elements.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); restoreFocusRef.current?.focus(); }; }, [onClose, open]); const regenerate = useCallback(async () => { const requestId = ++requestSeqRef.current.metadata; setMetadataLoading(true); setMetadataError(null); try { const metadata = await withPrMetadataTimeout(generatePrMetadata(taskId, projectId)); if (requestId !== requestSeqRef.current.metadata) { return; } setAiTitle(metadata.title); setAiBody(metadata.body); setTitle(metadata.title); setBody(metadata.body); setTemplateUsed(metadata.templateUsed); setUserEditedTitle(false); setUserEditedBody(false); } catch (regenerateError) { if (requestId === requestSeqRef.current.metadata) { setMetadataError(getErrorMessage(regenerateError)); setBody((current) => (current.trim() ? current : buildManualPrBodyFallback(taskId))); setAiBody((current) => current || buildManualPrBodyFallback(taskId)); } } finally { if (requestId === requestSeqRef.current.metadata) { setMetadataLoading(false); } } }, [projectId, taskId]); const checks = useMemo(() => { if (!preflight) return []; return [ { key: "branch", label: t("pr.checkBranch", "Branch pushed to remote"), ok: preflight.branchOnRemote, message: preflight.branchOnRemote ? t("pr.branchRemoteOk", "Remote branch is available.") : t("pr.branchRemoteFail", "Push branch to remote before creating a PR."), }, { key: "commits", label: t("pr.checkCommits", "Commits available"), ok: preflight.commitsPresent, message: preflight.commitsPresent ? t("pr.commitsOk", "Commits are ready to submit.") : t("pr.commitsFail", "No commits found for this branch."), }, { key: "conflicts", label: t("pr.checkConflicts", "No conflicts with base"), ok: !preflight.conflictsWithBase, warning: preflight.conflictsWithBase, message: preflight.conflictsWithBase ? t("pr.conflictsDetected", "Conflicts detected. Resolve conflicts or re-run preflight.") : t("pr.noConflicts", "No merge conflicts detected."), }, { key: "auth", label: t("pr.checkAuth", "GitHub auth available"), ok: preflight.ghAuthOk, message: preflight.ghAuthOk ? t("pr.authOk", "GitHub CLI auth is available.") : t("pr.authFail", "Run gh auth login and try again."), }, ]; }, [preflight, t]); const canSubmit = useMemo(() => checks.every((check) => check.ok), [checks]); const handleBaseChange = useCallback(async (nextBase: string) => { baseBranchTouchedRef.current = true; setBaseBranch(nextBase); setPushBranchError(null); setResolveConflictError(null); await loadPreflight(nextBase); }, [loadPreflight]); const handlePushBranch = useCallback(async () => { if (!baseBranch || pushingBranch) return; setPushingBranch(true); setPushBranchError(null); try { const response = await pushPrBranch(taskId, baseBranch, projectId); preflightRef.current = response.preflight; setPreflight(response.preflight); setPreflightError(null); addToast(response.result.message, "success"); } catch (pushError) { setPushBranchError(getErrorMessage(pushError)); } finally { setPushingBranch(false); } }, [addToast, baseBranch, projectId, pushingBranch, taskId]); const handleResolveConflicts = useCallback(async () => { if (!baseBranch || resolvingConflicts) return; setResolvingConflicts(true); setResolveConflictError(null); try { const response = await resolvePrConflicts(taskId, baseBranch, projectId); preflightRef.current = response.preflight; setPreflight(response.preflight); setPreflightError(null); addToast("Resolved PR conflicts and pushed branch", "success"); } catch (resolveError) { setResolveConflictError(getErrorMessage(resolveError)); } finally { setResolvingConflicts(false); } }, [addToast, baseBranch, projectId, resolvingConflicts, taskId]); const payload = useMemo(() => ({ title: title.trim(), body: body.trim(), base: baseBranch || undefined, draft, reviewers: reviewers.map((value) => value.login), assignees: assignees.map((value) => value.login), labels: labels.map((value) => value.name), }), [assignees, baseBranch, body, draft, labels, reviewers, title]); const submit = useCallback(async () => { if (!payload.title || !payload.body || submitting) return; setSubmitting(true); setSubmitError(null); setLastGhError(null); try { const prInfo = await createPr(taskId, payload, projectId); onCreated(prInfo); addToast(`Created PR #${prInfo.number}`, "success"); onClose(); } catch (submitError) { const details = (submitError as { details?: { githubError?: StructuredGhError } })?.details?.githubError; const structured: ModalGhError = details ? { ...details, operation: "create" } : { code: "unknown", message: getErrorMessage(submitError), retryable: true, action: { kind: "retry" }, operation: "create" }; setLastGhError(structured); setSubmitError(structured.message); } finally { setSubmitting(false); } }, [addToast, onClose, onCreated, payload, projectId, submitting, taskId]); const hasRequiredPrContent = title.trim().length > 0 && body.trim().length > 0; if (!open) return null; return ( {/** * FNXC:PrCreateModal 2026-06-27-00:00: * FN-7170 moves Create PR onto the shared FloatingWindow shell so it matches Plan Mission, Automations, and New Task: desktop users can drag the embedded modal header and resize from every FloatingWindow edge/corner, mobile stays full-screen through CSS, and geometry persists with persistGeometryKey="floating-window:pr-create". Overlay click-to-dismiss is intentionally dropped because FloatingWindow is non-blocking/click-through; close remains available via X, Cancel, and Escape. * * FNXC:PrCreateModal 2026-06-27-23:48: * Do not reintroduce a naive overlay onClick target check here. Before FloatingWindow, self-removing buttons and resize-grip releases could retarget synthesized clicks to the backdrop and close the dialog; the floating shell avoids that footgun by having no backdrop-dismiss path for Create PR. */}

{t("pr.createTitle", "Create Pull Request")}

<>

{t("pr.preflightChecks", "Pre-flight checks")}

{preflightLoading ?
: null} {preflightError ?

{preflightError}

: null} {!preflightLoading && !preflightError ? ( <>
{checks.map((check) => (
))}
{!preflight?.branchOnRemote ? (

{t("pr.pushBranch.title", "Push branch to remote")}

{t("pr.pushBranch.message", "Fusion will push this task's branch to origin so the PR can be created.")}

) : null} {preflight?.conflictsWithBase ? (

{t("pr.resolveConflicts.title", "Resolve conflicts with AI")}

{t("pr.resolveConflicts.message", "Fusion will use AI to resolve conflicts on this branch and push it.")}

) : null} ) : null}
{/** * FNXC:PrCreateModal 2026-06-28-00:09: * PR title/body generation must read as active description work, not as empty editable fields. Keep both fields disabled with aria-busy plus a skeleton while metadataLoading is true, then clear the affordance into AI content or the existing metadataError/manual-fallback state. */}
{userEditedTitle && }
{metadataLoading ?
: null} {metadataError ?

{metadataError}

: null}
{ setTitle(event.target.value); setUserEditedTitle(true); }} disabled={metadataLoading} aria-busy={metadataLoading ? "true" : undefined} /> {metadataLoading ?
{userEditedBody && }
{metadataLoading ?
: null} {/** * FNXC:PrCreateModal 2026-06-28-00:00: * PR authors need to preview description markdown before creating the PR. The preview is render-only, uses the shared sanitized markdown pipeline, and submission/regeneration/revert always read and write the raw `body` state. * * FNXC:PrCreateModal 2026-06-28-00:16: * While AI metadata is generating, disable raw editing and show skeleton affordances even when Preview is selected so users see that the submitted body is still pending generation. */} {showBodyPreview && !metadataLoading ? (
{body}
) : (