diff --git a/.changeset/fn-8834-mission-merge-guidance.md b/.changeset/fn-8834-mission-merge-guidance.md new file mode 100644 index 0000000000..05d500f199 --- /dev/null +++ b/.changeset/fn-8834-mission-merge-guidance.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Clarify mission merge behavior and show read-only shared branch status. +category: fix +dev: Mission detail validates BranchGroup ownership before displaying branch, member, and PR data. diff --git a/docs/missions.md b/docs/missions.md index 34b12a9293..f30bf3ee63 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -128,7 +128,7 @@ The Mission Manager create/edit form exposes this as **Branch strategy** plus a ### Mission auto-merge override -The **Merge behavior** control can inherit the project default, explicitly enable auto-merge, or select **Single pull request**. In-context help explains that auto-merge lands each feature individually while a single pull request retains all features on a shared branch for joint review. The latter persists `autoMerge: false` on the mission and stamps newly triaged feature tasks with the same false override, while preserving the mission's shared branch group. Returning the control to inherited clears the mission override. Once a shared branch has members, Mission detail displays its branch name, member count, and PR state. +The **Merge behavior** control appears in each Mission Manager create and edit form. Use the secondary **Create** link beside **Plan New Mission** when a manual create is needed; the primary planning CTA continues to start the AI interview. Its in-context help explains that **Inherited** follows the project setting, **Auto-merge** lands each feature as it passes, and **Single pull request** retains all features on a shared branch for joint review. The latter persists `autoMerge: false` on the mission and stamps newly triaged feature tasks with the same false override, while preserving the mission's shared branch group. Returning the control to inherited clears the mission override. Mission detail shows the branch name, canonical member count, and PR state only after it resolves a mission-owned group through a linked task's branch context; unavailable, stale, or foreign groups remain hidden. ### Shared branch-group invariant across entry points diff --git a/packages/dashboard/app/components/MissionManager.css b/packages/dashboard/app/components/MissionManager.css index c9c9b17ff7..53a36bbe93 100644 --- a/packages/dashboard/app/components/MissionManager.css +++ b/packages/dashboard/app/components/MissionManager.css @@ -243,6 +243,28 @@ Title metric matches the shared ViewHeader (1.125rem) so the Missions header rea font-size: inherit; } +/* +FNXC:MissionAutoMerge 2026-08-08-17:21: +Manual creation is a secondary text link so Plan New Mission remains the sole +primary CTA while every production presentation can open the merge-aware form. +*/ +.mission-list__manual-create-link { + align-self: center; + color: var(--text-muted); + font-size: var(--font-size-sm); + text-decoration: underline; + text-underline-offset: var(--space-xxs); +} + +.mission-list__manual-create-link:hover { + color: var(--text); +} + +.mission-list__manual-create-link:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + /* Hide duplicate sidebar title; the shared header already labels this view. */ .mission-manager__sidebar-title { display: none; @@ -582,6 +604,10 @@ Narrow/mobile Missions puts Plan New Mission at the bottom of the list, using th display: none; } +.mission-list__footer-actions .mission-list__manual-create-link { + align-self: center; +} + /* ── Inline Form Card ── */ .mission-form-card { display: flex; diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 9d92359182..94ac74633e 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -1,5 +1,5 @@ import "./MissionManager.css"; -import { useState, useEffect, useCallback, useRef, useMemo, type ReactNode } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo, type MouseEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -359,6 +359,42 @@ const EMPTY_MISSION_FORM: MissionFormData = { taskPrefix: "", }; +interface MissionMergeBehaviorFieldProps { + value: MissionAutoMergeOverride; + onChange: (value: MissionAutoMergeOverride) => void; + t: (key: string, fallback: string) => string; +} + +/* +FNXC:MissionAutoMerge 2026-08-08-16:11: +Every Mission Manager create and edit surface must explain the same three merge +choices beside its selector: inherited project behavior, feature-by-feature +auto-merge, and the shared-branch single-pull-request review path. A shared +field prevents a duplicated form path from silently omitting that contract. +*/ +export function MissionMergeBehaviorField({ value, onChange, t }: MissionMergeBehaviorFieldProps) { + return ( + + ); +} + const EMPTY_MILESTONE_FORM: MilestoneFormData = { title: "", description: "", @@ -781,7 +817,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr persistSidebarWidth(nextWidth); }, [isMobile, persistSidebarWidth, sidebarWidth]); - // Form states const [isCreatingMission, setIsCreatingMission] = useState(false); const [editingMissionId, setEditingMissionId] = useState(null); const [missionForm, setMissionForm] = useState(EMPTY_MISSION_FORM); @@ -1005,6 +1040,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const missionsRef = useRef([]); const selectedMissionRef = useRef(null); const selectedMilestoneIdRef = useRef(null); + /* + FNXC:MissionBranchGroupDetail 2026-08-08-16:58: + Mission selection can issue overlapping detail requests. Keep only the latest + response authoritative so a delayed prior mission cannot restore its hierarchy + or trigger a stale shared-branch scan after the operator has selected another. + */ + const missionDetailRequestGenerationRef = useRef(0); + /* + FNXC:MissionBranchGroupDetail 2026-08-08-17:07: + Returning to the mission list, deleting the selected mission, hiding this + inline view, or unmounting also changes selection. Invalidate in-flight + detail reads at each of those boundaries so they cannot resurrect a detail + after the operator has left it. + */ + const invalidateMissionDetailRequests = useCallback(() => { + missionDetailRequestGenerationRef.current += 1; + }, []); // FNXC:MilestoneValidationFreshness 2026-08-01-20:42: Rollup and telemetry responses share one per-milestone generation so an older request cannot restore a repaired failed badge, while a newer failure remains valid. const validationRequestGenerationRef = useRef(new MilestoneValidationFreshnessCoordinator()); const activeTabRef = useRef<"structure" | "activity">("structure"); @@ -1147,9 +1199,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr }, [addToast, projectId, t]); const loadMissionDetail = useCallback(async (missionId: string) => { + const requestGeneration = ++missionDetailRequestGenerationRef.current; try { setDetailLoading(true); const payload = await fetchMission(missionId, projectId); + if (requestGeneration !== missionDetailRequestGenerationRef.current) return; if (!payload || typeof payload !== "object") { throw new Error("Malformed mission detail response"); } @@ -1200,13 +1254,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr setValidationTelemetry(null); } } catch (err) { + if (requestGeneration !== missionDetailRequestGenerationRef.current) return; console.error("[MissionManager] loadMissionDetail:", err); addToast(getErrorMessage(err) || t("missions.loadDetailFailed", "Failed to load mission details"), "error"); } finally { - setDetailLoading(false); + if (requestGeneration === missionDetailRequestGenerationRef.current) { + setDetailLoading(false); + } } }, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]); + /* + FNXC:MissionBranchGroupDetail 2026-08-08-16:11: + Mission detail may resolve multiple linked tasks asynchronously. Reset first and + cancel the prior scan when mission, project, or component ownership changes so + an unavailable candidate can be skipped but an old response never leaks its + branch, member count, or PR state into the current mission. + */ useEffect(() => { let cancelled = false; setSelectedMissionBranchGroup(null); @@ -1511,6 +1575,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr try { const deletedMissionId = JSON.parse(messageEvent.data) as string; if (deletedMissionId && selectedMissionRef.current?.id === deletedMissionId) { + invalidateMissionDetailRequests(); setSelectedMission(null); } } catch { @@ -1744,6 +1809,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr }, [ isActive, isActivityScrolledNearBottom, + invalidateMissionDetailRequests, loadMissionDetail, loadMissionHealth, loadMissions, @@ -1858,13 +1924,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr await deleteMission(missionId, projectId); addToast(t("missions.deleted", "Mission deleted"), "success"); if (selectedMission?.id === missionId) { + invalidateMissionDetailRequests(); setSelectedMission(null); } await loadMissions(); } catch (err) { addToast(getErrorMessage(err) || t("missions.deleteFailed", "Failed to delete mission"), "error"); } - }, [addToast, loadMissions, selectedMission, projectId, t]); + }, [addToast, invalidateMissionDetailRequests, loadMissions, selectedMission, projectId, t]); const requestDeleteMission = useCallback(async (missionId: string) => { /* @@ -2603,6 +2670,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr }, [loadMissionDetail]); const handleBackToList = useCallback(() => { + invalidateMissionDetailRequests(); setSelectedMission(null); setSelectedMilestoneId(null); setValidationTelemetry(null); @@ -2612,7 +2680,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr setEventsFilter("all"); setExpandedEventMetadata(new Set()); loadMissions(); - }, [loadMissions]); + }, [invalidateMissionDetailRequests, loadMissions]); + + useEffect(() => () => { + invalidateMissionDetailRequests(); + }, [invalidateMissionDetailRequests]); const hasMoreEvents = missionEvents.length < eventsTotal; const autopilotState = (selectedMission?.autopilotState ?? "inactive") as AutopilotState; @@ -3084,25 +3156,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr - + setMissionForm({ ...missionForm, autoMergeOverride })} + t={t} + /> {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && ( - + setMissionForm({ ...missionForm, autoMergeOverride })} + t={t} + /> {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && ( - + setMissionForm({ ...missionForm, autoMergeOverride })} + t={t} + /> {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (