FN-8834: clarify mission merge behavior and branch details

Clarify mission merge options and safely surface shared-branch status in Mission Manager.

- Reuse merge behavior guidance across mission create and edit forms.
- Add a manual mission-create link while preserving AI planning as the primary CTA.
- Guard branch-group details against stale or foreign mission data and cover the behavior with tests.
- Update localized copy, mission documentation, and the published-package changeset.

Files changed:
 .changeset/fn-8834-mission-merge-guidance.md       |   7 +
 docs/missions.md                                   |   2 +-
 packages/dashboard/app/components/MissionManager.css    |  26 ++
 packages/dashboard/app/components/MissionManager.tsx    | 169 +++++++++----
 packages/dashboard/app/components/__tests__/MissionManager.auto-merge.test.tsx   | 271 +++++++++++++++++++--
 packages/i18n/locales/en/app.json                  |   1 +
 packages/i18n/locales/es/app.json                  |   1 +
 packages/i18n/locales/fr/app.json                  |   1 +
 packages/i18n/locales/ko/app.json                  |   1 +
 packages/i18n/locales/zh-CN/app.json               |   1 +
 packages/i18n/locales/zh-TW/app.json               |   1 +
 packages/i18n/src/resources.d.ts                   |   1 +
 12 files changed, 411 insertions(+), 71 deletions(-)

Fusion-Task-Id: FN-8834

Fusion-Task-Lineage: 43b28c43-83d5-41cf-a9ec-0e91117ec091

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-08 10:36:36 -07:00
parent d450dbe971
commit 168f7fb9cc
12 changed files with 412 additions and 72 deletions

View File

@@ -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.

View File

@@ -128,7 +128,7 @@ The Mission Manager create/edit form exposes this as **Branch strategy** plus a
### Mission auto-merge override ### 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 ### Shared branch-group invariant across entry points

View File

@@ -243,6 +243,28 @@ Title metric matches the shared ViewHeader (1.125rem) so the Missions header rea
font-size: inherit; 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. */ /* Hide duplicate sidebar title; the shared header already labels this view. */
.mission-manager__sidebar-title { .mission-manager__sidebar-title {
display: none; display: none;
@@ -582,6 +604,10 @@ Narrow/mobile Missions puts Plan New Mission at the bottom of the list, using th
display: none; display: none;
} }
.mission-list__footer-actions .mission-list__manual-create-link {
align-self: center;
}
/* ── Inline Form Card ── */ /* ── Inline Form Card ── */
.mission-form-card { .mission-form-card {
display: flex; display: flex;

View File

@@ -1,5 +1,5 @@
import "./MissionManager.css"; 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 { useTranslation } from "react-i18next";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@@ -359,6 +359,42 @@ const EMPTY_MISSION_FORM: MissionFormData = {
taskPrefix: "", 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 (
<label>
{t("missions.autoMergeOverride", "Merge behavior")}
<select
value={value}
onChange={(event) => onChange(event.target.value as MissionAutoMergeOverride)}
aria-label={t("missions.autoMergeOverrideAriaLabel", "Mission auto-merge override")}
>
<option value="inherit">{t("missions.autoMergeInherited", "Use project default")}</option>
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
</select>
<small className="mission-detail__autopilot-description">
{t(
"missions.autoMergeOverrideDescription",
"Inherited follows the project setting. Auto-merge lands each feature as it passes. Single pull request keeps every feature on one shared branch for joint review and merge.",
)}
</small>
</label>
);
}
const EMPTY_MILESTONE_FORM: MilestoneFormData = { const EMPTY_MILESTONE_FORM: MilestoneFormData = {
title: "", title: "",
description: "", description: "",
@@ -781,7 +817,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
persistSidebarWidth(nextWidth); persistSidebarWidth(nextWidth);
}, [isMobile, persistSidebarWidth, sidebarWidth]); }, [isMobile, persistSidebarWidth, sidebarWidth]);
// Form states
const [isCreatingMission, setIsCreatingMission] = useState(false); const [isCreatingMission, setIsCreatingMission] = useState(false);
const [editingMissionId, setEditingMissionId] = useState<string | null>(null); const [editingMissionId, setEditingMissionId] = useState<string | null>(null);
const [missionForm, setMissionForm] = useState<MissionFormData>(EMPTY_MISSION_FORM); const [missionForm, setMissionForm] = useState<MissionFormData>(EMPTY_MISSION_FORM);
@@ -1005,6 +1040,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const missionsRef = useRef<MissionWithSummary[]>([]); const missionsRef = useRef<MissionWithSummary[]>([]);
const selectedMissionRef = useRef<MissionWithHierarchy | null>(null); const selectedMissionRef = useRef<MissionWithHierarchy | null>(null);
const selectedMilestoneIdRef = useRef<string | null>(null); const selectedMilestoneIdRef = useRef<string | null>(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. // 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 validationRequestGenerationRef = useRef(new MilestoneValidationFreshnessCoordinator());
const activeTabRef = useRef<"structure" | "activity">("structure"); const activeTabRef = useRef<"structure" | "activity">("structure");
@@ -1147,9 +1199,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}, [addToast, projectId, t]); }, [addToast, projectId, t]);
const loadMissionDetail = useCallback(async (missionId: string) => { const loadMissionDetail = useCallback(async (missionId: string) => {
const requestGeneration = ++missionDetailRequestGenerationRef.current;
try { try {
setDetailLoading(true); setDetailLoading(true);
const payload = await fetchMission(missionId, projectId); const payload = await fetchMission(missionId, projectId);
if (requestGeneration !== missionDetailRequestGenerationRef.current) return;
if (!payload || typeof payload !== "object") { if (!payload || typeof payload !== "object") {
throw new Error("Malformed mission detail response"); throw new Error("Malformed mission detail response");
} }
@@ -1200,13 +1254,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
setValidationTelemetry(null); setValidationTelemetry(null);
} }
} catch (err) { } catch (err) {
if (requestGeneration !== missionDetailRequestGenerationRef.current) return;
console.error("[MissionManager] loadMissionDetail:", err); console.error("[MissionManager] loadMissionDetail:", err);
addToast(getErrorMessage(err) || t("missions.loadDetailFailed", "Failed to load mission details"), "error"); addToast(getErrorMessage(err) || t("missions.loadDetailFailed", "Failed to load mission details"), "error");
} finally { } finally {
setDetailLoading(false); if (requestGeneration === missionDetailRequestGenerationRef.current) {
setDetailLoading(false);
}
} }
}, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]); }, [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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setSelectedMissionBranchGroup(null); setSelectedMissionBranchGroup(null);
@@ -1511,6 +1575,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
try { try {
const deletedMissionId = JSON.parse(messageEvent.data) as string; const deletedMissionId = JSON.parse(messageEvent.data) as string;
if (deletedMissionId && selectedMissionRef.current?.id === deletedMissionId) { if (deletedMissionId && selectedMissionRef.current?.id === deletedMissionId) {
invalidateMissionDetailRequests();
setSelectedMission(null); setSelectedMission(null);
} }
} catch { } catch {
@@ -1744,6 +1809,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}, [ }, [
isActive, isActive,
isActivityScrolledNearBottom, isActivityScrolledNearBottom,
invalidateMissionDetailRequests,
loadMissionDetail, loadMissionDetail,
loadMissionHealth, loadMissionHealth,
loadMissions, loadMissions,
@@ -1858,13 +1924,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
await deleteMission(missionId, projectId); await deleteMission(missionId, projectId);
addToast(t("missions.deleted", "Mission deleted"), "success"); addToast(t("missions.deleted", "Mission deleted"), "success");
if (selectedMission?.id === missionId) { if (selectedMission?.id === missionId) {
invalidateMissionDetailRequests();
setSelectedMission(null); setSelectedMission(null);
} }
await loadMissions(); await loadMissions();
} catch (err) { } catch (err) {
addToast(getErrorMessage(err) || t("missions.deleteFailed", "Failed to delete mission"), "error"); 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) => { const requestDeleteMission = useCallback(async (missionId: string) => {
/* /*
@@ -2603,6 +2670,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}, [loadMissionDetail]); }, [loadMissionDetail]);
const handleBackToList = useCallback(() => { const handleBackToList = useCallback(() => {
invalidateMissionDetailRequests();
setSelectedMission(null); setSelectedMission(null);
setSelectedMilestoneId(null); setSelectedMilestoneId(null);
setValidationTelemetry(null); setValidationTelemetry(null);
@@ -2612,7 +2680,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
setEventsFilter("all"); setEventsFilter("all");
setExpandedEventMetadata(new Set()); setExpandedEventMetadata(new Set());
loadMissions(); loadMissions();
}, [loadMissions]); }, [invalidateMissionDetailRequests, loadMissions]);
useEffect(() => () => {
invalidateMissionDetailRequests();
}, [invalidateMissionDetailRequests]);
const hasMoreEvents = missionEvents.length < eventsTotal; const hasMoreEvents = missionEvents.length < eventsTotal;
const autopilotState = (selectedMission?.autopilotState ?? "inactive") as AutopilotState; const autopilotState = (selectedMission?.autopilotState ?? "inactive") as AutopilotState;
@@ -3084,25 +3156,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option> <option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
</select> </select>
</label> </label>
<label> <MissionMergeBehaviorField
{t("missions.autoMergeOverride", "Merge behavior")} value={missionForm.autoMergeOverride}
<select onChange={(autoMergeOverride) => setMissionForm({ ...missionForm, autoMergeOverride })}
value={missionForm.autoMergeOverride} t={t}
onChange={(e) => setMissionForm({ ...missionForm, autoMergeOverride: e.target.value as MissionAutoMergeOverride })} />
aria-label={t("missions.autoMergeOverrideAriaLabel", "Mission auto-merge override")}
>
<option value="inherit">{t("missions.autoMergeInherited", "Use project default")}</option>
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
</select>
{/*
FNXC:MissionAutoMerge 2026-07-19-00:00:
Operators need in-context guidance for the per-mission merge choice: auto-merge lands each feature independently, while a single pull request keeps all features on one shared branch for joint review.
*/}
<span className="mission-detail__autopilot-description">
{t("missions.autoMergeOverrideDescription", "Auto-merge merges each feature as it passes. Single pull request keeps every feature on one shared branch to review and merge together.")}
</span>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && ( {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label> <label>
{t("missions.branchName", "Branch name")} {t("missions.branchName", "Branch name")}
@@ -4479,6 +4537,20 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
); );
}; };
/*
FNXC:MissionAutoMerge 2026-08-08-17:21:
Keep AI planning as the primary Plan New Mission CTA, but retain a visible,
production manual-create path for operators who need to choose merge behavior
before a mission exists. This link opens the existing form without changing
the frozen planning button set.
*/
const openDirectMissionCreate = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
setMissionForm(EMPTY_MISSION_FORM);
setEditingMissionId(null);
setIsCreatingMission(true);
};
const openNewMissionInterview = () => { const openNewMissionInterview = () => {
if (resumeSessionId) { if (resumeSessionId) {
dismissedResumeSessionIdRef.current = resumeSessionId; dismissedResumeSessionIdRef.current = resumeSessionId;
@@ -4838,21 +4910,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option> <option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
</select> </select>
</label> </label>
<label> <MissionMergeBehaviorField
{t("missions.autoMergeOverride", "Merge behavior")} value={missionForm.autoMergeOverride}
<select onChange={(autoMergeOverride) => setMissionForm({ ...missionForm, autoMergeOverride })}
value={missionForm.autoMergeOverride} t={t}
onChange={(e) => setMissionForm({ ...missionForm, autoMergeOverride: e.target.value as MissionAutoMergeOverride })} />
aria-label={t("missions.autoMergeOverrideAriaLabel", "Mission auto-merge override")}
>
<option value="inherit">{t("missions.autoMergeInherited", "Use project default")}</option>
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
</select>
<span className="mission-detail__autopilot-description">
{t("missions.autoMergeOverrideDescription", "Auto-merge merges each feature as it passes. Single pull request keeps every feature on one shared branch to review and merge together.")}
</span>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && ( {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label> <label>
{t("missions.branchName", "Branch name")} {t("missions.branchName", "Branch name")}
@@ -4957,21 +5019,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option> <option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
</select> </select>
</label> </label>
<label> <MissionMergeBehaviorField
{t("missions.autoMergeOverride", "Merge behavior")} value={missionForm.autoMergeOverride}
<select onChange={(autoMergeOverride) => setMissionForm({ ...missionForm, autoMergeOverride })}
value={missionForm.autoMergeOverride} t={t}
onChange={(e) => setMissionForm({ ...missionForm, autoMergeOverride: e.target.value as MissionAutoMergeOverride })} />
aria-label={t("missions.autoMergeOverrideAriaLabel", "Mission auto-merge override")}
>
<option value="inherit">{t("missions.autoMergeInherited", "Use project default")}</option>
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
</select>
<span className="mission-detail__autopilot-description">
{t("missions.autoMergeOverrideDescription", "Auto-merge merges each feature as it passes. Single pull request keeps every feature on one shared branch to review and merge together.")}
</span>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && ( {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label> <label>
{t("missions.branchName", "Branch name")} {t("missions.branchName", "Branch name")}
@@ -5038,6 +5090,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<Sparkles size={14} /> <Sparkles size={14} />
{t("missions.planNewMission", "Plan New Mission")} {t("missions.planNewMission", "Plan New Mission")}
</button> </button>
<a className="mission-list__manual-create-link" href="#mission-create" onClick={openDirectMissionCreate}>
{t("missions.createButton", "Create")}
</a>
</div> </div>
)} )}
@@ -5049,6 +5104,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<Sparkles size={14} /> <Sparkles size={14} />
{t("missions.planNewMission", "Plan New Mission")} {t("missions.planNewMission", "Plan New Mission")}
</button> </button>
<a className="mission-list__manual-create-link" href="#mission-create" onClick={openDirectMissionCreate}>
{t("missions.createButton", "Create")}
</a>
</div> </div>
)} )}
</div> </div>
@@ -5222,6 +5280,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<Sparkles size={14} /> <Sparkles size={14} />
{t("missions.planNewMission", "Plan New Mission")} {t("missions.planNewMission", "Plan New Mission")}
</button> </button>
<a className="mission-list__manual-create-link" href="#mission-create" onClick={openDirectMissionCreate}>
{t("missions.createButton", "Create")}
</a>
</div> </div>
</aside> </aside>

View File

@@ -5,7 +5,7 @@ undefined so JSON serialization clears an existing mission auto-merge override.
*/ */
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MissionManager } from "../MissionManager"; import { MissionManager } from "../MissionManager";
const mockFetchMissions = vi.fn(); const mockFetchMissions = vi.fn();
@@ -55,12 +55,12 @@ function mission(autoMerge?: boolean) {
}; };
} }
function setDesktopViewport() { function setViewport({ width, mobile = false }: { width: number; mobile?: boolean }) {
Object.defineProperty(window, "innerWidth", { value: 1440, configurable: true }); Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
configurable: true, configurable: true,
value: vi.fn().mockImplementation((query: string) => ({ value: vi.fn().mockImplementation((query: string) => ({
matches: false, matches: mobile && query.includes("max-width"),
media: query, media: query,
onchange: null, onchange: null,
addListener: vi.fn(), addListener: vi.fn(),
@@ -72,7 +72,15 @@ function setDesktopViewport() {
}); });
} }
async function openEditForm(autoMerge?: boolean) { function setDesktopViewport() {
setViewport({ width: 1440 });
}
function setMobileViewport() {
setViewport({ width: 390, mobile: true });
}
async function openDetailEditForm(autoMerge?: boolean) {
const detail = mission(autoMerge); const detail = mission(autoMerge);
mockFetchMissions.mockResolvedValue([detail]); mockFetchMissions.mockResolvedValue([detail]);
mockFetchMission.mockResolvedValue(detail); mockFetchMission.mockResolvedValue(detail);
@@ -83,6 +91,26 @@ async function openEditForm(autoMerge?: boolean) {
return screen.getByLabelText("Mission auto-merge override") as HTMLSelectElement; return screen.getByLabelText("Mission auto-merge override") as HTMLSelectElement;
} }
function expectMergeGuidance(form: HTMLElement) {
expect(within(form).getByText(/Inherited follows the project setting/i)).toBeInTheDocument();
expect(within(form).getByText(/Auto-merge lands each feature as it passes/i)).toBeInTheDocument();
expect(within(form).getByText(/Single pull request keeps every feature on one shared branch/i)).toBeInTheDocument();
}
function getMissionForm(control: HTMLElement) {
const form = control.closest(".mission-form-card");
if (!form) throw new Error("Mission merge behavior control must be rendered in its production form card");
return form;
}
async function findManualMissionCreateLink() {
return waitFor(() => {
const link = document.querySelector<HTMLAnchorElement>(".mission-list__manual-create-link");
if (!link) throw new Error("Production mission list must expose manual creation beside planning");
return link;
});
}
describe("MissionManager auto-merge override", () => { describe("MissionManager auto-merge override", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -96,27 +124,109 @@ describe("MissionManager auto-merge override", () => {
mockGetBranchGroup.mockResolvedValue({ group: null }); mockGetBranchGroup.mockResolvedValue({ group: null });
}); });
it("renders merge behavior guidance in both mission edit forms", async () => { it("renders complete merge guidance in the selected mission detail edit form", async () => {
await openEditForm(); const detailControl = await openDetailEditForm();
expect(screen.getByText(/Single pull request keeps every feature/i)).toBeInTheDocument(); expect(detailControl.value).toBe("inherit");
expectMergeGuidance(getMissionForm(detailControl));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
const editButtons = screen.getAllByRole("button", { name: "Edit mission" });
fireEvent.click(editButtons.at(-1)!);
expect(screen.getByText(/Single pull request keeps every feature/i)).toBeInTheDocument();
}); });
it.each([ it.each([
[undefined, "inherit"], [undefined, "inherit"],
[true, "on"], [true, "on"],
[false, "off"], [false, "off"],
] as const)("reflects a %s mission override as %s", async (autoMerge, expected) => { ] as const)("renders complete merge guidance in the list edit form for %s", async (autoMerge, expected) => {
const control = await openEditForm(autoMerge); const selected = mission(true);
const listEdited = { ...mission(autoMerge), id: "M-002", title: "List Edit Mission" };
mockFetchMissions.mockResolvedValue([selected, listEdited]);
mockFetchMission.mockImplementation((id: string) => Promise.resolve(id === selected.id ? selected : listEdited));
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await screen.findByText("Single PR Mission"));
const listItem = screen.getByText("List Edit Mission").closest(".mission-list__item");
if (!listItem) throw new Error("List edit mission row must be rendered");
fireEvent.click(within(listItem).getByRole("button", { name: "Edit mission" }));
const listControl = await screen.findByLabelText("Mission auto-merge override") as HTMLSelectElement;
expect(listControl.value).toBe(expected);
expectMergeGuidance(getMissionForm(listControl));
});
it("renders complete merge guidance in the production list create form", async () => {
mockFetchMissions.mockResolvedValue([mission()]);
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await findManualMissionCreateLink());
const control = await screen.findByLabelText("Mission auto-merge override") as HTMLSelectElement;
expect(control.value).toBe("inherit");
expectMergeGuidance(getMissionForm(control));
fireEvent.change(control, { target: { value: "on" } });
expect(control.value).toBe("on");
expectMergeGuidance(getMissionForm(control));
fireEvent.change(control, { target: { value: "off" } });
expect(control.value).toBe("off");
expectMergeGuidance(getMissionForm(control));
});
it("keeps create guidance and the owned shared branch summary visible in the mobile presentation", async () => {
setMobileViewport();
const detail = {
...mission(false),
milestones: [{ id: "MS-001", title: "Milestone", status: "planning", createdAt: now, updatedAt: now, slices: [{ id: "SL-001", title: "Slice", status: "pending", createdAt: now, updatedAt: now, features: [{ id: "F-001", title: "Feature", taskId: "FN-001", status: "triaged", createdAt: now, updatedAt: now }] }] }],
};
mockFetchMissions.mockResolvedValue([detail]);
mockFetchMission.mockResolvedValue(detail);
mockFetchTaskDetail.mockResolvedValue({ id: "FN-001", branchContext: { source: "mission", groupId: "BG-001", assignmentMode: "shared" } });
mockGetBranchGroup.mockResolvedValue({ group: {
id: "BG-001", sourceType: "mission", sourceId: "M-001", branchName: "mission/mobile", autoMerge: false,
prState: "open", status: "open", createdAt: 0, updatedAt: 0, members: [], completion: { landed: 0, total: 2, complete: false },
} });
const { unmount } = render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
expect(screen.getByTestId("mission-manager-dialog").querySelector(".mission-manager__body--stacked")).not.toBeNull();
await screen.findByText("Single PR Mission");
fireEvent.click(await findManualMissionCreateLink());
const createControl = await screen.findByLabelText("Mission auto-merge override") as HTMLSelectElement;
expectMergeGuidance(getMissionForm(createControl));
unmount();
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await screen.findByText("Single PR Mission"));
const summary = await screen.findByTestId("mission-shared-branch-summary");
expect(summary).toHaveTextContent("mission/mobile");
expect(summary).toHaveTextContent("2 member");
expect(summary).toHaveTextContent("open");
expect(summary.querySelectorAll("button, a, input, select")).toHaveLength(0);
});
it("keeps Plan New Mission CTAs on the AI planning path", async () => {
mockFetchMissions.mockResolvedValue([mission()]);
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
const sidebarCreate = await waitFor(() => {
const button = document.querySelector<HTMLButtonElement>(".mission-manager__sidebar-cta");
if (!button) throw new Error("Mission sidebar planning CTA must be rendered");
return button;
});
fireEvent.click(sidebarCreate);
await waitFor(() => expect(screen.queryByLabelText("Mission auto-merge override")).toBeNull());
});
it.each([
[undefined, "inherit"],
[true, "on"],
[false, "off"],
] as const)("reflects a %s mission override as %s with complete detail-edit guidance", async (autoMerge, expected) => {
const control = await openDetailEditForm(autoMerge);
expect(control.value).toBe(expected); expect(control.value).toBe(expected);
expectMergeGuidance(getMissionForm(control));
}); });
it("sends null when an existing override is returned to inherited", async () => { it("sends null when an existing override is returned to inherited", async () => {
const control = await openEditForm(false); const control = await openDetailEditForm(false);
fireEvent.change(control, { target: { value: "inherit" } }); fireEvent.change(control, { target: { value: "inherit" } });
fireEvent.click(screen.getByRole("button", { name: "Update" })); fireEvent.click(screen.getByRole("button", { name: "Update" }));
@@ -177,11 +287,140 @@ describe("MissionManager auto-merge override", () => {
expect(summary.querySelector("button")).toBeNull(); expect(summary.querySelector("button")).toBeNull();
}); });
it.each([
[
"zero members without a pull request",
{ branchName: "mission/empty", memberCount: 0, prState: "none" },
["mission/empty", "0 member", "No PR"],
],
[
"one member with a merged pull request",
{ branchName: "mission/landed", memberCount: 1, prState: "merged" },
["mission/landed", "1 member", "merged"],
],
] as const)("shows %s in the read-only shared branch summary", async (_label, groupDetails, expectedText) => {
const detail = {
...mission(false),
milestones: [{ id: "MS-001", title: "Milestone", status: "planning", createdAt: now, updatedAt: now, slices: [{ id: "SL-001", title: "Slice", status: "pending", createdAt: now, updatedAt: now, features: [{ id: "F-001", title: "Feature", taskId: "FN-001", status: "triaged", createdAt: now, updatedAt: now }] }] }],
};
mockFetchMissions.mockResolvedValue([detail]);
mockFetchMission.mockResolvedValue(detail);
mockFetchTaskDetail.mockResolvedValue({ id: "FN-001", branchContext: { source: "mission", groupId: "BG-001", assignmentMode: "shared" } });
mockGetBranchGroup.mockResolvedValue({ group: {
id: "BG-001", sourceType: "mission", sourceId: "M-001", autoMerge: false,
status: "open", createdAt: 0, updatedAt: 0, members: [], completion: { landed: 0, total: groupDetails.memberCount, complete: false },
...groupDetails,
} });
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await screen.findByText("Single PR Mission"));
const summary = await screen.findByTestId("mission-shared-branch-summary");
expectedText.forEach((text) => expect(summary).toHaveTextContent(text));
expect(summary.querySelectorAll("button, a, input, select")).toHaveLength(0);
});
it("skips unusable linked tasks and keeps scanning for an owned group", async () => {
const detail = {
...mission(false),
milestones: [{ id: "MS-001", title: "Milestone", status: "planning", createdAt: now, updatedAt: now, slices: [{ id: "SL-001", title: "Slice", status: "pending", createdAt: now, updatedAt: now, features: [
{ id: "F-001", title: "Missing task", taskId: "FN-missing", status: "triaged", createdAt: now, updatedAt: now },
{ id: "F-002", title: "Live task", taskId: "FN-live", status: "triaged", createdAt: now, updatedAt: now },
] }] }],
};
mockFetchMissions.mockResolvedValue([detail]);
mockFetchMission.mockResolvedValue(detail);
mockFetchTaskDetail.mockImplementation((id: string) => id === "FN-missing"
? Promise.reject(new Error("task deleted"))
: Promise.resolve({ id, branchContext: { source: "mission", groupId: "BG-live", assignmentMode: "shared" } }));
mockGetBranchGroup.mockResolvedValue({ group: {
id: "BG-live", sourceType: "mission", sourceId: "M-001", branchName: "mission/live", autoMerge: false,
prState: "open", status: "open", createdAt: 0, updatedAt: 0, members: [], completion: { landed: 0, total: 2, complete: false },
} });
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await screen.findByText("Single PR Mission"));
expect(await screen.findByTestId("mission-shared-branch-summary")).toHaveTextContent("mission/live");
expect(mockGetBranchGroup).toHaveBeenCalledWith("BG-live", "project-1");
});
it("does not allow a delayed prior mission detail to overwrite the current detail", async () => {
const first = mission(false);
const second = { ...mission(false), id: "M-002", title: "Current Mission" };
let resolveFirst!: (value: typeof first) => void;
const delayedFirst = new Promise<typeof first>((resolve) => { resolveFirst = resolve; });
mockFetchMissions.mockResolvedValue([first, second]);
mockFetchMission.mockImplementation((id: string) => id === first.id ? delayedFirst : Promise.resolve(second));
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await screen.findByText("Single PR Mission"));
fireEvent.click(await screen.findByText("Current Mission"));
expect(await screen.findByRole("heading", { name: "Current Mission" })).toBeInTheDocument();
resolveFirst(first);
await waitFor(() => expect(screen.getByRole("heading", { name: "Current Mission" })).toBeInTheDocument());
expect(screen.queryByRole("heading", { name: "Single PR Mission" })).toBeNull();
});
it("does not allow a delayed detail request to reopen a mission after returning to the list", async () => {
const detail = mission(false);
let resolveRefresh!: (value: typeof detail) => void;
const delayedRefresh = new Promise<typeof detail>((resolve) => { resolveRefresh = resolve; });
mockFetchMissions.mockResolvedValue([detail]);
mockFetchMission.mockResolvedValueOnce(detail).mockReturnValueOnce(delayedRefresh);
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await screen.findByText("Single PR Mission"));
await screen.findByRole("heading", { name: "Single PR Mission" });
fireEvent.click(screen.getByRole("button", { name: "Open mission Single PR Mission" }));
fireEvent.click(screen.getByTestId("mission-back-btn"));
resolveRefresh(detail);
await waitFor(() => expect(screen.queryByRole("heading", { name: "Single PR Mission" })).toBeNull());
});
it("does not allow a delayed prior mission group to overwrite the current detail", async () => {
const first = {
...mission(false),
milestones: [{ id: "MS-001", title: "Milestone", status: "planning", createdAt: now, updatedAt: now, slices: [{ id: "SL-001", title: "Slice", status: "pending", createdAt: now, updatedAt: now, features: [{ id: "F-001", title: "First feature", taskId: "FN-first", status: "triaged", createdAt: now, updatedAt: now }] }] }],
};
const second = {
...mission(false), id: "M-002", title: "Current Mission",
milestones: [{ id: "MS-002", title: "Milestone", status: "planning", createdAt: now, updatedAt: now, slices: [{ id: "SL-002", title: "Slice", status: "pending", createdAt: now, updatedAt: now, features: [{ id: "F-002", title: "Second feature", taskId: "FN-second", status: "triaged", createdAt: now, updatedAt: now }] }] }],
};
let resolveOldGroup!: (value: unknown) => void;
const oldGroup = new Promise((resolve) => { resolveOldGroup = resolve; });
mockFetchMissions.mockResolvedValue([first, second]);
mockFetchMission.mockImplementation((id: string) => Promise.resolve(id === "M-001" ? first : second));
mockFetchTaskDetail.mockImplementation((id: string) => Promise.resolve({
id,
branchContext: { source: "mission", groupId: id === "FN-first" ? "BG-first" : "BG-second", assignmentMode: "shared" },
}));
mockGetBranchGroup.mockImplementation((groupId: string) => groupId === "BG-first"
? oldGroup
: Promise.resolve({ group: {
id: "BG-second", sourceType: "mission", sourceId: "M-002", branchName: "mission/current", autoMerge: false,
prState: "open", status: "open", createdAt: 0, updatedAt: 0, members: [], completion: { landed: 0, total: 1, complete: false },
} }));
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await screen.findByText("Single PR Mission"));
await waitFor(() => expect(mockGetBranchGroup).toHaveBeenCalledWith("BG-first", "project-1"));
fireEvent.click(screen.getByText("Current Mission"));
expect(await screen.findByTestId("mission-shared-branch-summary")).toHaveTextContent("mission/current");
resolveOldGroup({ group: {
id: "BG-first", sourceType: "mission", sourceId: "M-001", branchName: "mission/stale", autoMerge: false,
prState: "closed", status: "closed", createdAt: 0, updatedAt: 0, members: [], completion: { landed: 0, total: 4, complete: false },
} });
await waitFor(() => expect(screen.getByTestId("mission-shared-branch-summary")).toHaveTextContent("mission/current"));
expect(screen.getByTestId("mission-shared-branch-summary")).not.toHaveTextContent("mission/stale");
});
it.each([ it.each([
["on", true], ["on", true],
["off", false], ["off", false],
] as const)("sends %s as an explicit %s override", async (selection, expected) => { ] as const)("sends %s as an explicit %s override", async (selection, expected) => {
const control = await openEditForm(); const control = await openDetailEditForm();
fireEvent.change(control, { target: { value: selection } }); fireEvent.change(control, { target: { value: selection } });
fireEvent.click(screen.getByRole("button", { name: "Update" })); fireEvent.click(screen.getByRole("button", { name: "Update" }));

View File

@@ -3656,6 +3656,7 @@
"assertionUpdateFailed": "Failed to update assertion", "assertionUpdateFailed": "Failed to update assertion",
"attemptRetries_one": "Attempt {{attempt}} · {{count}} {{label}} left", "attemptRetries_one": "Attempt {{attempt}} · {{count}} {{label}} left",
"attemptRetries_other": "Attempt {{attempt}} · {{count}} {{label}} left", "attemptRetries_other": "Attempt {{attempt}} · {{count}} {{label}} left",
"autoMergeOverrideDescription": "Inherited follows the project setting. Auto-merge lands each feature as it passes. Single pull request keeps every feature on one shared branch for joint review and merge.",
"autopilotActivatingSlice": "Activating slice", "autopilotActivatingSlice": "Activating slice",
"autopilotCompleting": "Completing", "autopilotCompleting": "Completing",
"autopilotDescription": "When on, Fusion automatically activates the next slice and plans its features as work completes.", "autopilotDescription": "When on, Fusion automatically activates the next slice and plans its features as work completes.",

View File

@@ -3646,6 +3646,7 @@
"assertionUpdateFailed": "Error al actualizar la aserción", "assertionUpdateFailed": "Error al actualizar la aserción",
"attemptRetries_one": "", "attemptRetries_one": "",
"attemptRetries_other": "", "attemptRetries_other": "",
"autoMergeOverrideDescription": "Heredado sigue la configuración del proyecto. La fusión automática integra cada funcionalidad al pasar. Una sola solicitud de extracción mantiene todas las funcionalidades en una rama compartida para revisión y fusión conjuntas.",
"autopilotActivatingSlice": "Activando segmento", "autopilotActivatingSlice": "Activando segmento",
"autopilotCompleting": "Completando", "autopilotCompleting": "Completando",
"autopilotDescription": "Cuando está activado, Fusion activa automáticamente el siguiente segmento y planifica sus funcionalidades a medida que avanza el trabajo.", "autopilotDescription": "Cuando está activado, Fusion activa automáticamente el siguiente segmento y planifica sus funcionalidades a medida que avanza el trabajo.",

View File

@@ -3646,6 +3646,7 @@
"assertionUpdateFailed": "Échec de la mise à jour de l'assertion", "assertionUpdateFailed": "Échec de la mise à jour de l'assertion",
"attemptRetries_one": "", "attemptRetries_one": "",
"attemptRetries_other": "", "attemptRetries_other": "",
"autoMergeOverrideDescription": "Hérité suit le paramètre du projet. La fusion automatique intègre chaque fonctionnalité lorsqu’elle passe. Une seule demande de tirage conserve toutes les fonctionnalités sur une branche partagée pour une révision et une fusion conjointes.",
"autopilotActivatingSlice": "Activation du découpage", "autopilotActivatingSlice": "Activation du découpage",
"autopilotCompleting": "En finalisation", "autopilotCompleting": "En finalisation",
"autopilotDescription": "Lorsqu'il est activé, Fusion active automatiquement le découpage suivant et planifie ses fonctionnalités à mesure que le travail avance.", "autopilotDescription": "Lorsqu'il est activé, Fusion active automatiquement le découpage suivant et planifie ses fonctionnalités à mesure que le travail avance.",

View File

@@ -3646,6 +3646,7 @@
"assertionUpdateFailed": "어설션 업데이트에 실패했습니다", "assertionUpdateFailed": "어설션 업데이트에 실패했습니다",
"attemptRetries_one": "", "attemptRetries_one": "",
"attemptRetries_other": "", "attemptRetries_other": "",
"autoMergeOverrideDescription": "상속은 프로젝트 설정을 따릅니다. 자동 병합은 각 기능이 통과할 때 병합합니다. 단일 풀 리퀘스트는 공동 검토와 병합을 위해 모든 기능을 하나의 공유 브랜치에 유지합니다.",
"autopilotActivatingSlice": "슬라이스 활성화 중", "autopilotActivatingSlice": "슬라이스 활성화 중",
"autopilotCompleting": "완료 중", "autopilotCompleting": "완료 중",
"autopilotDescription": "켜져 있으면 Fusion이 작업이 완료될 때 자동으로 다음 슬라이스를 활성화하고 기능을 계획합니다.", "autopilotDescription": "켜져 있으면 Fusion이 작업이 완료될 때 자동으로 다음 슬라이스를 활성화하고 기능을 계획합니다.",

View File

@@ -3646,6 +3646,7 @@
"assertionUpdateFailed": "更新断言失败", "assertionUpdateFailed": "更新断言失败",
"attemptRetries_one": "", "attemptRetries_one": "",
"attemptRetries_other": "", "attemptRetries_other": "",
"autoMergeOverrideDescription": "继承遵循项目设置。自动合并会在每个功能通过时合并。单个拉取请求会将所有功能保留在一个共享分支上,以便联合审查和合并。",
"autopilotActivatingSlice": "正在激活切片", "autopilotActivatingSlice": "正在激活切片",
"autopilotCompleting": "完成中", "autopilotCompleting": "完成中",
"autopilotDescription": "开启后,Fusion 会在工作完成时自动激活下一个切片并规划其功能。", "autopilotDescription": "开启后,Fusion 会在工作完成时自动激活下一个切片并规划其功能。",

View File

@@ -3646,6 +3646,7 @@
"assertionUpdateFailed": "更新斷言失敗", "assertionUpdateFailed": "更新斷言失敗",
"attemptRetries_one": "", "attemptRetries_one": "",
"attemptRetries_other": "", "attemptRetries_other": "",
"autoMergeOverrideDescription": "繼承遵循專案設定。自動合併會在每個功能通過時合併。單一提取請求會將所有功能保留在一個共用分支上,以便共同審查與合併。",
"autopilotActivatingSlice": "正在啟動切片", "autopilotActivatingSlice": "正在啟動切片",
"autopilotCompleting": "完成中", "autopilotCompleting": "完成中",
"autopilotDescription": "開啟後,Fusion 會在工作完成時自動啟動下一個切片並規劃其功能。", "autopilotDescription": "開啟後,Fusion 會在工作完成時自動啟動下一個切片並規劃其功能。",

View File

@@ -3658,6 +3658,7 @@ export default interface Resources {
"assertionUpdated": "Assertion updated", "assertionUpdated": "Assertion updated",
"attemptRetries_one": "Attempt {{attempt}} · {{count}} {{label}} left", "attemptRetries_one": "Attempt {{attempt}} · {{count}} {{label}} left",
"attemptRetries_other": "Attempt {{attempt}} · {{count}} {{label}} left", "attemptRetries_other": "Attempt {{attempt}} · {{count}} {{label}} left",
"autoMergeOverrideDescription": "Inherited follows the project setting. Auto-merge lands each feature as it passes. Single pull request keeps every feature on one shared branch for joint review and merge.",
"autopilotActivatingSlice": "Activating slice", "autopilotActivatingSlice": "Activating slice",
"autopilotCompleting": "Completing", "autopilotCompleting": "Completing",
"autopilotDescription": "When on, Fusion automatically activates the next slice and plans its features as work completes.", "autopilotDescription": "When on, Fusion automatically activates the next slice and plans its features as work completes.",