import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react"; import { AlertTriangle, CheckCircle2, RefreshCw, Sparkles, X, XCircle } from "lucide-react"; import { getErrorMessage, type PrInfo, type StructuredGhError } from "@fusion/core"; import { createPr, fetchPrOptions, fetchPrPreflight, generatePrMetadata, type PrOptionsLabel, type PrOptionsResponse, type PrOptionsUser, type PrPreflightResponse, } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; 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; }; 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()}`} /> {filtered.length > 0 && (
{filtered.map((item) => ( ))}
)}
); } export function PrCreateModal({ open, taskId, projectId, defaultBaseBranch, onClose, onCreated, addToast, }: PrCreateModalProps) { const headingId = useId(); const modalRef = useRef(null); const restoreFocusRef = useRef(null); const requestSeqRef = useRef(0); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [lastGhError, setLastGhError] = useState(null); const [aiTitle, setAiTitle] = useState(""); const [aiBody, setAiBody] = useState(""); const [title, setTitle] = useState(""); const [body, setBody] = useState(""); 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 [reviewers, setReviewers] = useState([]); const [assignees, setAssignees] = useState([]); const [labels, setLabels] = useState([]); useModalResizePersist(modalRef, open, "fusion:pr-create-modal-size"); const loadData = useCallback(async (baseOverride?: string) => { const requestId = ++requestSeqRef.current; setLoading(true); setError(null); try { const [metadata, preflightData, optionsData] = await Promise.all([ generatePrMetadata(taskId, projectId), fetchPrPreflight(taskId, projectId, baseOverride), fetchPrOptions(taskId, projectId), ]); if (requestId !== requestSeqRef.current) { return; } setAiTitle(metadata.title); setAiBody(metadata.body); setTitle((current) => (current || metadata.title)); setBody((current) => (current || metadata.body)); setTemplateUsed(metadata.templateUsed); setPreflight(preflightData); setOptions(optionsData); const preferredBase = baseOverride ?? defaultBaseBranch ?? preflightData.defaultBaseBranch ?? optionsData.baseBranches[0] ?? ""; setBaseBranch(preferredBase); } catch (loadError) { if (requestId === requestSeqRef.current) { setError(getErrorMessage(loadError)); } } finally { if (requestId === requestSeqRef.current) { setLoading(false); } } }, [defaultBaseBranch, projectId, taskId]); useEffect(() => { if (!open) return; void loadData(); return () => { requestSeqRef.current += 1; }; }, [loadData, open]); 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 () => { try { const metadata = await generatePrMetadata(taskId, projectId); setAiTitle(metadata.title); setAiBody(metadata.body); setTitle(metadata.title); setBody(metadata.body); setTemplateUsed(metadata.templateUsed); setUserEditedTitle(false); setUserEditedBody(false); } catch (regenerateError) { setError(getErrorMessage(regenerateError)); } }, [projectId, taskId]); const checks = useMemo(() => { if (!preflight) return []; return [ { key: "branch", label: "Branch pushed to remote", ok: preflight.branchOnRemote, message: preflight.branchOnRemote ? "Remote branch is available." : "Push branch to remote before creating a PR.", }, { key: "commits", label: "Commits available", ok: preflight.commitsPresent, message: preflight.commitsPresent ? "Commits are ready to submit." : "No commits found for this branch.", }, { key: "conflicts", label: "No conflicts with base", ok: !preflight.conflictsWithBase, warning: preflight.conflictsWithBase, message: preflight.conflictsWithBase ? "Conflicts detected. Resolve conflicts or re-run preflight." : "No merge conflicts detected.", }, { key: "auth", label: "GitHub auth available", ok: preflight.ghAuthOk, message: preflight.ghAuthOk ? "GitHub CLI auth is available." : "Run gh auth login and try again.", }, ]; }, [preflight]); const canSubmit = useMemo(() => checks.every((check) => check.ok), [checks]); const handleBaseChange = useCallback(async (nextBase: string) => { setBaseBranch(nextBase); try { const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase); setPreflight(nextPreflight); } catch (loadError) { setError(getErrorMessage(loadError)); } }, [projectId, 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 || submitting) return; setSubmitting(true); setError(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); setError(structured.message); } finally { setSubmitting(false); } }, [addToast, onClose, onCreated, payload, projectId, submitting, taskId]); if (!open) return null; return (
event.target === event.currentTarget && onClose()}>

Create Pull Request

{loading ?
Loading PR metadata…
: ( <>

Pre-flight checks

{checks.map((check) => (
))}
{userEditedTitle && }
{ setTitle(event.target.value); setUserEditedTitle(true); }} />
{userEditedBody && }