Document the evidence required before activating conditional goals refinement work. - add a dedicated goals refinement evidence pack doc with required observation fields and activation thresholds - link the new evidence pack from the docs index and dashboard guide - cover the doc with a dashboard test and keep PR conflict-resolution modal styling aligned after merge Files changed: docs/README.md | 1 + docs/dashboard-guide.md | 2 +- docs/goals-refinement-evidence-pack.md | 74 ++++++++++++++++++++++ .../goals-refinement-evidence-pack-docs.test.ts | 22 +++++++ .../dashboard/app/components/PrCreateModal.css | 6 +- .../dashboard/app/components/PrCreateModal.tsx | 2 +- 6 files changed, 103 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-5963 Fusion-Task-Lineage: 22cefe55-6b1e-4ff7-8fb1-5734f2b5a29b
572 lines
24 KiB
TypeScript
572 lines
24 KiB
TypeScript
import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import { AlertTriangle, CheckCircle2, RefreshCw, Sparkles, X, XCircle } from "lucide-react";
|
||
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 { 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<T extends { login?: string; name?: string; color?: string }>(
|
||
{
|
||
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 (
|
||
<div className="pr-create-modal__section">
|
||
<label className="pr-create-modal__label">{label}</label>
|
||
<div className="pr-create-modal__chips">
|
||
{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 (
|
||
<span
|
||
key={key}
|
||
className={`pr-create-modal__chip${hasColor ? " pr-create-modal__chip--colored" : ""}`}
|
||
style={chipStyle}
|
||
>
|
||
<span className="pr-create-modal__chip-label">{getLabel(item)}</span>
|
||
<button
|
||
type="button"
|
||
className="btn btn-icon pr-create-modal__chip-remove"
|
||
onClick={() => onChange(selected.filter((value) => getKey(value) !== key))}
|
||
aria-label={`Remove ${getLabel(item)}`}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
<input
|
||
className="input"
|
||
value={query}
|
||
onChange={(event) => setQuery(event.target.value)}
|
||
placeholder={`Filter ${label.toLowerCase()}`}
|
||
aria-label={`Filter ${label.toLowerCase()}`}
|
||
/>
|
||
{filtered.length > 0 && (
|
||
<div className="pr-create-modal__option-list">
|
||
{filtered.map((item) => (
|
||
<button
|
||
key={getKey(item)}
|
||
type="button"
|
||
className="btn btn-sm pr-create-modal__option-item"
|
||
onClick={() => {
|
||
onChange([...selected, item]);
|
||
setQuery("");
|
||
}}
|
||
>
|
||
{getLabel(item)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PrCreateModal({
|
||
open,
|
||
taskId,
|
||
projectId,
|
||
defaultBaseBranch,
|
||
onClose,
|
||
onCreated,
|
||
addToast,
|
||
}: PrCreateModalProps) {
|
||
const { t } = useTranslation("app");
|
||
const headingId = useId();
|
||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||
const requestSeqRef = useRef(0);
|
||
const [loading, setLoading] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [pushBranchError, setPushBranchError] = useState<string | null>(null);
|
||
const [resolveConflictError, setResolveConflictError] = useState<string | null>(null);
|
||
const [lastGhError, setLastGhError] = useState<ModalGhError | null>(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<PrOptionsResponse | null>(null);
|
||
const [preflight, setPreflight] = useState<PrPreflightResponse | null>(null);
|
||
const [baseBranch, setBaseBranch] = useState("");
|
||
const [draft, setDraft] = useState(false);
|
||
const [pushingBranch, setPushingBranch] = useState(false);
|
||
const [resolvingConflicts, setResolvingConflicts] = useState(false);
|
||
const [reviewers, setReviewers] = useState<PrOptionsUser[]>([]);
|
||
const [assignees, setAssignees] = useState<PrOptionsUser[]>([]);
|
||
const [labels, setLabels] = useState<PrOptionsLabel[]>([]);
|
||
|
||
useModalResizePersist(modalRef, open, "fusion:pr-create-modal-size");
|
||
|
||
const loadData = useCallback(async (baseOverride?: string) => {
|
||
const requestId = ++requestSeqRef.current;
|
||
setLoading(true);
|
||
setError(null);
|
||
setPushBranchError(null);
|
||
setResolveConflictError(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<HTMLElement>("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<HTMLElement>("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<PreflightCheck[]>(() => {
|
||
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) => {
|
||
setBaseBranch(nextBase);
|
||
setPushBranchError(null);
|
||
setResolveConflictError(null);
|
||
try {
|
||
const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase);
|
||
setPreflight(nextPreflight);
|
||
} catch (loadError) {
|
||
setError(getErrorMessage(loadError));
|
||
}
|
||
}, [projectId, taskId]);
|
||
|
||
const handlePushBranch = useCallback(async () => {
|
||
if (!baseBranch || pushingBranch) return;
|
||
setPushingBranch(true);
|
||
setPushBranchError(null);
|
||
try {
|
||
const response = await pushPrBranch(taskId, baseBranch, projectId);
|
||
setPreflight(response.preflight);
|
||
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);
|
||
setPreflight(response.preflight);
|
||
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 || 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 createPortal(
|
||
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()}>
|
||
<div
|
||
ref={modalRef}
|
||
className="modal modal-lg pr-create-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={headingId}
|
||
>
|
||
<div className="modal-header">
|
||
<h2 id={headingId}>{t("pr.createTitle", "Create Pull Request")}</h2>
|
||
<button type="button" className="modal-close" onClick={onClose} aria-label={t("actions.close", "Close")}>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="pr-create-modal__body">
|
||
{loading ? <div className="pr-create-modal__loading">{t("pr.loadingMetadata", "Loading PR metadata…")}</div> : (
|
||
<>
|
||
<section className="pr-create-modal__section">
|
||
<h3 className="pr-create-modal__section-title">{t("pr.preflightChecks", "Pre-flight checks")}</h3>
|
||
<div className="pr-create-modal__preflight">
|
||
{checks.map((check) => (
|
||
<div key={check.key} className={`pr-create-modal__preflight-row ${check.ok ? "is-ok" : "is-failed"}`}>
|
||
<span className={`status-dot ${check.ok ? "status-dot--online" : check.warning ? "status-dot--pending" : "status-dot--error"}`} aria-hidden="true" />
|
||
{check.ok ? <CheckCircle2 size={16} /> : check.warning ? <AlertTriangle size={16} /> : <XCircle size={16} />}
|
||
<div>
|
||
<p className="pr-create-modal__preflight-label">{check.label}</p>
|
||
<p className="pr-create-modal__preflight-message">{check.message}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button type="button" className="btn btn-sm" onClick={() => void handleBaseChange(baseBranch)}>
|
||
{t("pr.rerunPreflight", "Re-run preflight")}
|
||
</button>
|
||
{!preflight?.branchOnRemote ? (
|
||
<div className="card pr-create-modal__preflight-remediation">
|
||
<div className="pr-create-modal__conflict-copy">
|
||
<p className="pr-create-modal__conflict-title">Push branch to remote</p>
|
||
<p className="pr-create-modal__conflict-message">Fusion will push this task's branch to origin so the PR can be created.</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm"
|
||
onClick={() => void handlePushBranch()}
|
||
disabled={pushingBranch || loading}
|
||
>
|
||
{pushingBranch ? <RefreshCw size={14} className="spin" /> : null}
|
||
Push branch to remote
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{preflight?.conflictsWithBase ? (
|
||
<div className="card pr-create-modal__conflict-resolution">
|
||
<div className="pr-create-modal__conflict-copy">
|
||
<p className="pr-create-modal__conflict-title">Resolve conflicts with AI</p>
|
||
<p className="pr-create-modal__conflict-message">Fusion will use AI to resolve conflicts on this branch and push it.</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm"
|
||
onClick={() => void handleResolveConflicts()}
|
||
disabled={resolvingConflicts || loading}
|
||
>
|
||
{resolvingConflicts ? <RefreshCw size={14} className="spin" /> : null}
|
||
Resolve conflicts with AI
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="pr-create-modal__section">
|
||
<div className="pr-create-modal__title-row">
|
||
<label className="pr-create-modal__label" htmlFor="pr-create-modal-title">{t("pr.titleLabel", "Title")}</label>
|
||
<div className="pr-create-modal__inline-actions">
|
||
<button type="button" className="btn btn-sm" onClick={() => void regenerate()}><Sparkles size={14} />{t("pr.regenerate", "Regenerate")}</button>
|
||
{userEditedTitle && <button type="button" className="btn btn-sm" onClick={() => { setTitle(aiTitle); setUserEditedTitle(false); }}>{t("pr.revertToAi", "Revert to AI version")}</button>}
|
||
</div>
|
||
</div>
|
||
<input id="pr-create-modal-title" className="input" value={title} onChange={(event) => { setTitle(event.target.value); setUserEditedTitle(true); }} />
|
||
</section>
|
||
|
||
<section className="pr-create-modal__section">
|
||
<div className="pr-create-modal__title-row">
|
||
<label className="pr-create-modal__label" htmlFor="pr-create-modal-body">{t("pr.bodyLabel", "Body")}</label>
|
||
<div className="pr-create-modal__inline-actions">
|
||
<button type="button" className="btn btn-sm" onClick={() => void regenerate()}><Sparkles size={14} />{t("pr.regenerate", "Regenerate")}</button>
|
||
{userEditedBody && <button type="button" className="btn btn-sm" onClick={() => { setBody(aiBody); setUserEditedBody(false); }}>{t("pr.revertToAi", "Revert to AI version")}</button>}
|
||
</div>
|
||
</div>
|
||
<textarea id="pr-create-modal-body" className="input pr-create-modal__body-input" value={body} onChange={(event) => { setBody(event.target.value); setUserEditedBody(true); }} rows={8} />
|
||
{templateUsed && <p className="pr-create-template-hint">{t("pr.usingTemplate", "Using <code>.github/pull_request_template.md</code>")}</p>}
|
||
</section>
|
||
|
||
<section className="pr-create-modal__section pr-create-modal__grid-two">
|
||
<div>
|
||
<label className="pr-create-modal__label" htmlFor="pr-create-modal-base">{t("pr.baseBranch", "Base branch")}</label>
|
||
<select id="pr-create-modal-base" className="select" value={baseBranch} onChange={(event) => void handleBaseChange(event.target.value)}>
|
||
{(options?.baseBranches ?? []).map((branch) => <option key={branch} value={branch}>{branch}</option>)}
|
||
</select>
|
||
</div>
|
||
<label className="checkbox-label pr-create-modal__draft">
|
||
<input type="checkbox" checked={draft} onChange={(event) => setDraft(event.target.checked)} />
|
||
{t("pr.createAsDraft", "Create as draft")}
|
||
</label>
|
||
</section>
|
||
|
||
<OptionChips
|
||
label="Reviewers"
|
||
options={options?.reviewers ?? []}
|
||
selected={reviewers}
|
||
onChange={setReviewers}
|
||
getKey={(option) => option.login}
|
||
getLabel={(option) => option.name ? `${option.name} (@${option.login})` : `@${option.login}`}
|
||
/>
|
||
<OptionChips
|
||
label="Assignees"
|
||
options={options?.assignees ?? []}
|
||
selected={assignees}
|
||
onChange={setAssignees}
|
||
getKey={(option) => option.login}
|
||
getLabel={(option) => option.name ? `${option.name} (@${option.login})` : `@${option.login}`}
|
||
/>
|
||
<OptionChips
|
||
label="Labels"
|
||
options={options?.labels ?? []}
|
||
selected={labels}
|
||
onChange={setLabels}
|
||
getKey={(option) => option.name}
|
||
getLabel={(option) => option.name}
|
||
includeColor
|
||
/>
|
||
|
||
<details className="pr-create-collapsible" open>
|
||
<summary>{t("pr.previewTitle", "Diff & commit preview")}</summary>
|
||
<div className="pr-create-modal__preview">
|
||
<h4>{t("pr.commitsLabel", "Commits")}</h4>
|
||
{(preflight?.commits ?? []).map((commit) => (
|
||
<div className="pr-create-modal__commit-row" key={commit.sha}>
|
||
<code>{commit.sha.slice(0, 7)}</code>
|
||
<span>{commit.subject}</span>
|
||
<span>{commit.author}</span>
|
||
</div>
|
||
))}
|
||
<h4>{t("pr.changedFilesLabel", "Changed files")}</h4>
|
||
{(preflight?.changedFiles ?? []).map((file) => (
|
||
<div className="pr-create-modal__file-row" key={file.path}>
|
||
<span>{file.path}</span>
|
||
<span>+{file.additions} / −{file.deletions}</span>
|
||
<span className={`card-status-badge card-status-badge--${file.status === "added" ? "done" : file.status === "deleted" ? "archived" : file.status === "renamed" ? "in-review" : "todo"}`}>{file.status}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</details>
|
||
|
||
{pushBranchError ? (
|
||
<div className="form-error pr-error" role="alert">
|
||
<p>{pushBranchError}</p>
|
||
<div className="pr-error__actions">
|
||
<button type="button" className="btn btn-sm pr-error__dismiss" onClick={() => setPushBranchError(null)} aria-label="Dismiss push branch error">×</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{resolveConflictError ? (
|
||
<div className="form-error pr-error" role="alert">
|
||
<p>{resolveConflictError}</p>
|
||
<div className="pr-error__actions">
|
||
<button type="button" className="btn btn-sm pr-error__dismiss" onClick={() => setResolveConflictError(null)} aria-label="Dismiss conflict resolution error">×</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{error && (
|
||
<div className="form-error pr-error" role="alert">
|
||
<p>{error}</p>
|
||
{lastGhError?.hint ? <p className="pr-error__hint">{lastGhError.hint}</p> : null}
|
||
<div className="pr-error__actions">
|
||
{lastGhError?.action?.kind === "shell" ? <p>Action: run <code>{lastGhError.action.command}</code></p> : null}
|
||
{lastGhError?.action?.kind === "open" ? <p>Action: open <a href={lastGhError.action.url} target="_blank" rel="noreferrer">docs</a></p> : null}
|
||
{lastGhError?.retryable ? <button type="button" className="btn btn-sm pr-error__retry" onClick={() => void submit()}>{t("actions.retry", "Retry")}</button> : null}
|
||
<button type="button" className="btn btn-sm pr-error__dismiss" onClick={() => { setLastGhError(null); setError(null); }} aria-label={t("pr.dismissError", "Dismiss PR error")}>×</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="modal-actions">
|
||
<button type="button" className="btn" onClick={onClose} disabled={submitting}>{t("actions.cancel", "Cancel")}</button>
|
||
<button type="button" className="btn btn-primary" onClick={() => void submit()} disabled={!canSubmit || !title.trim() || submitting || loading}>
|
||
{submitting ? <RefreshCw size={14} className="spin" /> : null}
|
||
{draft ? t("pr.createDraftPr", "Create draft PR") : t("pr.createPr", "Create PR")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|