FN-5954: decouple PR metadata loading from preflight options

Make the Create Pull Request modal interactive before AI metadata finishes loading.

- load preflight checks and PR options independently from AI title/body generation with separate loading and error states
- preserve base-branch selection and remediation actions while refreshing preflight or regenerating metadata
- cover the faster modal flow in tests and document the immediate-render behavior in the dashboard guide

Files changed:
 docs/dashboard-guide.md                            |   3 +-
 packages/dashboard/app/components/PrCreateModal.css     |  11 +
 packages/dashboard/app/components/PrCreateModal.tsx     | 357 ++++++++++++++-------
 packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx    | 110 ++++++-
 4 files changed, 355 insertions(+), 126 deletions(-)

Fusion-Task-Id: FN-5954

Fusion-Task-Lineage: 2595d220-ca7a-403c-935c-900034a20c67
This commit is contained in:
gsxdsm
2026-06-03 16:53:54 -07:00
parent a4d3589a14
commit 4625d644a8
4 changed files with 357 additions and 128 deletions

View File

@@ -613,7 +613,8 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**.
- From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults.
- In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab.
- The **Create Pull Request** modal now offers in-app remediation for blocking preflight checks. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/<task-id-lower>` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will use an AI coding agent to resolve merge markers on the task branch, commit the result, push the branch, and refresh preflight so normal PR creation can continue once all checks pass.
- The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/<task-id-lower>` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will use an AI coding agent to resolve merge markers on the task branch, commit the result, push the branch, and refresh preflight so normal PR creation can continue once all checks pass.
- The modal shell renders immediately: preflight checks and PR options load independently of AI-generated title/body metadata, so slow AI suggestions no longer block base-branch selection, diagnostics, or manual PR authoring.
- The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread.
- **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass.
- Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call).

View File

@@ -26,6 +26,13 @@
color: var(--text-muted);
}
.pr-create-modal__section-loading {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) 0;
}
.pr-create-modal__section {
display: flex;
flex-direction: column;
@@ -292,4 +299,8 @@
width: 100%;
flex-wrap: wrap;
}
.pr-create-modal__section-loading {
width: 100%;
}
}

View File

@@ -136,10 +136,18 @@ export function PrCreateModal({
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 requestSeqRef = useRef({ metadata: 0, preflight: 0, options: 0 });
const preflightRef = useRef<PrPreflightResponse | null>(null);
const optionsRef = useRef<PrOptionsResponse | null>(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 [error, setError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [metadataError, setMetadataError] = useState<string | null>(null);
const [preflightError, setPreflightError] = useState<string | null>(null);
const [optionsError, setOptionsError] = 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);
@@ -162,50 +170,131 @@ export function PrCreateModal({
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);
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, preflightData, optionsData] = await Promise.all([
generatePrMetadata(taskId, projectId),
fetchPrPreflight(taskId, projectId, baseOverride),
fetchPrOptions(taskId, projectId),
]);
if (requestId !== requestSeqRef.current) {
const metadata = await generatePrMetadata(taskId, projectId);
if (requestId !== requestSeqRef.current.metadata) {
return;
}
setAiTitle(metadata.title);
setAiBody(metadata.body);
setTitle((current) => (current || metadata.title));
setBody((current) => (current || metadata.body));
setTitle((current) => (current.trim() ? current : metadata.title));
setBody((current) => (current.trim() ? 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));
if (requestId === requestSeqRef.current.metadata) {
setMetadataError(getErrorMessage(loadError));
}
} finally {
if (requestId === requestSeqRef.current) {
setLoading(false);
if (requestId === requestSeqRef.current.metadata) {
setMetadataLoading(false);
}
}
}, [defaultBaseBranch, projectId, taskId]);
}, [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;
void loadData();
loadData();
return () => {
requestSeqRef.current += 1;
requestSeqRef.current.metadata += 1;
requestSeqRef.current.preflight += 1;
requestSeqRef.current.options += 1;
};
}, [loadData, open]);
@@ -244,8 +333,14 @@ export function PrCreateModal({
}, [onClose, open]);
const regenerate = useCallback(async () => {
const requestId = ++requestSeqRef.current.metadata;
setMetadataLoading(true);
setMetadataError(null);
try {
const metadata = await generatePrMetadata(taskId, projectId);
if (requestId !== requestSeqRef.current.metadata) {
return;
}
setAiTitle(metadata.title);
setAiBody(metadata.body);
setTitle(metadata.title);
@@ -254,7 +349,13 @@ export function PrCreateModal({
setUserEditedTitle(false);
setUserEditedBody(false);
} catch (regenerateError) {
setError(getErrorMessage(regenerateError));
if (requestId === requestSeqRef.current.metadata) {
setMetadataError(getErrorMessage(regenerateError));
}
} finally {
if (requestId === requestSeqRef.current.metadata) {
setMetadataLoading(false);
}
}
}, [projectId, taskId]);
@@ -292,16 +393,10 @@ export function PrCreateModal({
const canSubmit = useMemo(() => checks.every((check) => check.ok), [checks]);
const handleBaseChange = useCallback(async (nextBase: string) => {
baseBranchTouchedRef.current = true;
setBaseBranch(nextBase);
setPushBranchError(null);
setResolveConflictError(null);
try {
const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase);
setPreflight(nextPreflight);
} catch (loadError) {
setError(getErrorMessage(loadError));
}
}, [projectId, taskId]);
await loadPreflight(nextBase);
}, [loadPreflight]);
const handlePushBranch = useCallback(async () => {
if (!baseBranch || pushingBranch) return;
@@ -309,7 +404,9 @@ export function PrCreateModal({
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));
@@ -324,7 +421,9 @@ export function PrCreateModal({
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));
@@ -346,7 +445,7 @@ export function PrCreateModal({
const submit = useCallback(async () => {
if (!payload.title || submitting) return;
setSubmitting(true);
setError(null);
setSubmitError(null);
setLastGhError(null);
try {
const prInfo = await createPr(taskId, payload, projectId);
@@ -359,7 +458,7 @@ export function PrCreateModal({
? { ...details, operation: "create" }
: { code: "unknown", message: getErrorMessage(submitError), retryable: true, action: { kind: "retry" }, operation: "create" };
setLastGhError(structured);
setError(structured.message);
setSubmitError(structured.message);
} finally {
setSubmitting(false);
}
@@ -384,58 +483,64 @@ export function PrCreateModal({
</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">
<>
<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>
{preflightLoading ? <div className="pr-create-modal__loading pr-create-modal__section-loading"><span className="status-dot status-dot--pending" aria-hidden="true" />Loading pre-flight checks…</div> : null}
{preflightError ? <div className="form-error pr-error" role="alert"><p>{preflightError}</p></div> : null}
{!preflightLoading && !preflightError ? (
<>
<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)} disabled={preflightLoading}>
{preflightLoading ? <RefreshCw size={14} className="spin" /> : null}
{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&apos;s branch to origin so the PR can be created.</p>
</div>
<button
type="button"
className="btn btn-sm"
onClick={() => void handlePushBranch()}
disabled={pushingBranch || preflightLoading || !baseBranch}
>
{pushingBranch ? <RefreshCw size={14} className="spin" /> : null}
Push branch to remote
</button>
</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&apos;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}
{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 || preflightLoading || !baseBranch}
>
{resolvingConflicts ? <RefreshCw size={14} className="spin" /> : null}
Resolve conflicts with AI
</button>
</div>
) : null}
</>
) : null}
</section>
@@ -443,10 +548,12 @@ export function PrCreateModal({
<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>
<button type="button" className="btn btn-sm" onClick={() => void regenerate()} disabled={metadataLoading}><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>
{metadataLoading ? <div className="pr-create-modal__loading pr-create-modal__section-loading"><span className="status-dot status-dot--pending" aria-hidden="true" />Generating AI title…</div> : null}
{metadataError ? <div className="form-error pr-error" role="alert"><p>{metadataError}</p></div> : null}
<input id="pr-create-modal-title" className="input" value={title} onChange={(event) => { setTitle(event.target.value); setUserEditedTitle(true); }} />
</section>
@@ -454,10 +561,11 @@ export function PrCreateModal({
<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>
<button type="button" className="btn btn-sm" onClick={() => void regenerate()} disabled={metadataLoading}><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>
{metadataLoading ? <div className="pr-create-modal__loading pr-create-modal__section-loading"><span className="status-dot status-dot--pending" aria-hidden="true" />Generating AI body…</div> : null}
<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>
@@ -465,7 +573,9 @@ export function PrCreateModal({
<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)}>
{optionsLoading ? <div className="pr-create-modal__loading pr-create-modal__section-loading"><span className="status-dot status-dot--pending" aria-hidden="true" />Loading PR options…</div> : null}
{optionsError ? <div className="form-error pr-error" role="alert"><p>{optionsError}</p></div> : null}
<select id="pr-create-modal-base" className="select" value={baseBranch} onChange={(event) => void handleBaseChange(event.target.value)} disabled={optionsLoading || Boolean(optionsError) || (options?.baseBranches?.length ?? 0) === 0}>
{(options?.baseBranches ?? []).map((branch) => <option key={branch} value={branch}>{branch}</option>)}
</select>
</div>
@@ -505,6 +615,7 @@ export function PrCreateModal({
<summary>{t("pr.previewTitle", "Diff & commit preview")}</summary>
<div className="pr-create-modal__preview">
<h4>{t("pr.commitsLabel", "Commits")}</h4>
{!preflightLoading && !preflightError && (preflight?.commits?.length ?? 0) === 0 ? <p className="pr-create-template-hint">No commits found.</p> : null}
{(preflight?.commits ?? []).map((commit) => (
<div className="pr-create-modal__commit-row" key={commit.sha}>
<code>{commit.sha.slice(0, 7)}</code>
@@ -513,6 +624,7 @@ export function PrCreateModal({
</div>
))}
<h4>{t("pr.changedFilesLabel", "Changed files")}</h4>
{!preflightLoading && !preflightError && (preflight?.changedFiles?.length ?? 0) === 0 ? <p className="pr-create-template-hint">No changed files detected.</p> : null}
{(preflight?.changedFiles ?? []).map((file) => (
<div className="pr-create-modal__file-row" key={file.path}>
<span>{file.path}</span>
@@ -523,43 +635,42 @@ export function PrCreateModal({
</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>
{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>
) : null}
</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>
{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>
) : null}
</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>
{submitError && (
<div className="form-error pr-error" role="alert">
<p>{submitError}</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); setSubmitError(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}>
<button type="button" className="btn btn-primary" onClick={() => void submit()} disabled={!preflight || preflightLoading || !canSubmit || !title.trim() || submitting}>
{submitting ? <RefreshCw size={14} className="spin" /> : null}
{draft ? t("pr.createDraftPr", "Create draft PR") : t("pr.createPr", "Create PR")}
</button>

View File

@@ -41,6 +41,16 @@ const options = {
labels: [{ name: "bug", color: "ff0000" }],
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function renderModal(overrides?: Partial<ComponentProps<typeof PrCreateModal>>) {
const onClose = vi.fn();
const onCreated = vi.fn();
@@ -159,13 +169,90 @@ describe("PrCreateModal", () => {
expect(screen.getByText(/using/i)).toBeInTheDocument();
});
it("regenerates and reverts AI content", async () => {
mocks.generatePrMetadata.mockResolvedValueOnce(metadata).mockResolvedValueOnce({ title: "New title", body: "New body", templateUsed: false });
it("renders preflight and options before metadata resolves", async () => {
const metadataDeferred = createDeferred<typeof metadata>();
mocks.generatePrMetadata.mockReturnValueOnce(metadataDeferred.promise);
renderModal();
expect(await screen.findByText("Branch pushed to remote")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeEnabled();
expect(screen.getByText("Reviewers")).toBeInTheDocument();
expect(screen.getByText(/generating ai title/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Manual PR title" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.change(screen.getByPlaceholderText("Filter reviewers"), { target: { value: "rev" } });
fireEvent.click(screen.getByRole("button", { name: /reviewer 1/i }));
expect(screen.getByRole("button", { name: /remove reviewer 1/i })).toBeInTheDocument();
metadataDeferred.resolve(metadata);
expect(await screen.findByDisplayValue("Manual PR title")).toBeInTheDocument();
});
it("keeps submit disabled while preflight is pending or failed", async () => {
const preflightDeferred = createDeferred<typeof preflight>();
mocks.fetchPrPreflight.mockReturnValueOnce(preflightDeferred.promise);
renderModal();
expect(await screen.findByDisplayValue("AI title")).toBeInTheDocument();
expect(screen.getByText(/loading pre-flight checks/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
preflightDeferred.reject(new Error("preflight blew up"));
expect(await screen.findByText("preflight blew up")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
});
it("keeps metadata failure scoped so the modal remains usable", async () => {
mocks.generatePrMetadata.mockRejectedValueOnce(new Error("metadata blew up"));
renderModal();
expect(await screen.findByText("metadata blew up")).toBeInTheDocument();
expect(screen.getByText("Branch pushed to remote")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeEnabled();
fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Manual fallback title" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
});
it("keeps options failure scoped so metadata and preflight still render", async () => {
mocks.fetchPrOptions.mockRejectedValueOnce(new Error("options blew up"));
renderModal();
expect(await screen.findByDisplayValue("AI title")).toBeInTheDocument();
expect(screen.getByText("Branch pushed to remote")).toBeInTheDocument();
expect(await screen.findByText("options blew up")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeDisabled();
expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled();
});
it("renders empty option and preview states without throwing", async () => {
mocks.fetchPrPreflight.mockResolvedValueOnce({ ...preflight, commits: [], changedFiles: [] });
mocks.fetchPrOptions.mockResolvedValueOnce({ ...options, baseBranches: [], reviewers: [], assignees: [], labels: [] });
await renderModalLoaded();
expect(screen.getByText("No commits found.")).toBeInTheDocument();
expect(screen.getByText("No changed files detected.")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeDisabled();
});
it("regenerates AI content without re-blocking preflight and options", async () => {
const regenerateDeferred = createDeferred<typeof metadata>();
mocks.generatePrMetadata.mockResolvedValueOnce(metadata).mockReturnValueOnce(regenerateDeferred.promise);
await renderModalLoaded();
const titleInput = screen.getByDisplayValue("AI title");
fireEvent.change(titleInput, { target: { value: "custom" } });
expect(screen.getByRole("button", { name: /revert to ai version/i })).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: /^regenerate$/i })[0]);
expect(screen.getByText("Branch pushed to remote")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeEnabled();
expect(screen.getByText(/generating ai title/i)).toBeInTheDocument();
regenerateDeferred.resolve({ title: "New title", body: "New body", templateUsed: false });
await screen.findByDisplayValue("New title");
fireEvent.change(screen.getByDisplayValue("New title"), { target: { value: "edited" } });
fireEvent.click(screen.getByRole("button", { name: /revert to ai version/i }));
@@ -181,6 +268,24 @@ describe("PrCreateModal", () => {
expect(screen.getByRole("button", { name: "Create draft PR" })).toBeInTheDocument();
});
it("refreshes preflight independently when the base branch changes", async () => {
const baseChangeDeferred = createDeferred<typeof preflight>();
mocks.fetchPrPreflight
.mockResolvedValueOnce(preflight)
.mockReturnValueOnce(baseChangeDeferred.promise);
await renderModalLoaded();
fireEvent.change(screen.getByLabelText(/base branch/i), { target: { value: "develop" } });
expect(mocks.fetchPrPreflight).toHaveBeenLastCalledWith("FN-4756", undefined, "develop");
expect(screen.getByText(/loading pre-flight checks/i)).toBeInTheDocument();
expect(screen.getByDisplayValue("AI title")).toBeInTheDocument();
baseChangeDeferred.resolve({ ...preflight, defaultBaseBranch: "develop" });
await waitFor(() => expect(screen.queryByText(/loading pre-flight checks/i)).not.toBeInTheDocument());
expect(screen.getByLabelText(/base branch/i)).toHaveValue("develop");
});
it("adds and removes chips and submits payload", async () => {
const { onCreated, addToast, onClose } = await renderModalLoaded();
@@ -195,6 +300,7 @@ describe("PrCreateModal", () => {
const submitButton = screen.getByRole("button", { name: "Create draft PR" });
expect(submitButton).toHaveClass("btn-primary");
await waitFor(() => expect(submitButton).toBeEnabled());
fireEvent.click(submitButton);
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));