diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 15d6c2ec4e..82f80c2897 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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/` 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/` 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). diff --git a/packages/dashboard/app/components/PrCreateModal.css b/packages/dashboard/app/components/PrCreateModal.css index 30151e1e77..55434ac7db 100644 --- a/packages/dashboard/app/components/PrCreateModal.css +++ b/packages/dashboard/app/components/PrCreateModal.css @@ -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%; + } } diff --git a/packages/dashboard/app/components/PrCreateModal.tsx b/packages/dashboard/app/components/PrCreateModal.tsx index ef71e62731..70535c09db 100644 --- a/packages/dashboard/app/components/PrCreateModal.tsx +++ b/packages/dashboard/app/components/PrCreateModal.tsx @@ -136,10 +136,18 @@ export function PrCreateModal({ const headingId = useId(); const modalRef = useRef(null); const restoreFocusRef = useRef(null); - const requestSeqRef = useRef(0); - const [loading, setLoading] = useState(false); + 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 [error, setError] = useState(null); + 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); @@ -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({
- {loading ?
{t("pr.loadingMetadata", "Loading PR metadata…")}
: ( - <> -
+ <> +

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

-
- {checks.map((check) => ( -
-
- - {!preflight?.branchOnRemote ? ( -
-
-

Push branch to remote

-

Fusion will push this task's branch to origin so the PR can be created.

-
- -
- ) : null} - {preflight?.conflictsWithBase ? ( -
-
-

Resolve conflicts with AI

-

Fusion will use AI to resolve conflicts on this branch and push it.

-
- -
+ ) : null} + {preflight?.conflictsWithBase ? ( +
+
+

Resolve conflicts with AI

+

Fusion will use AI to resolve conflicts on this branch and push it.

+
+ +
+ ) : null} + ) : null}
@@ -443,10 +548,12 @@ export function PrCreateModal({
- + {userEditedTitle && }
+ {metadataLoading ?
: null} + {metadataError ?

{metadataError}

: null} { setTitle(event.target.value); setUserEditedTitle(true); }} />
@@ -454,10 +561,11 @@ export function PrCreateModal({
- + {userEditedBody && }
+ {metadataLoading ?
: null}