FN-5716: replace base-branch input with branch dropdown

Switch task base-branch selection to prefer existing branches while preserving custom entry when needed.

- fetch local git branches for TaskForm and sort common integration branches (main/master/trunk/develop) first
- render base branch as a dropdown with default and Custom… options, with fallback custom input mode and toggle back to dropdown
- update TaskForm/NewTaskModal tests for branch loading, dropdown ordering, unknown-branch fallback, and fetch failure behavior
- document the new branch dropdown + custom fallback behavior in dashboard guide branch planning rules

Files changed:
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/components/TaskForm.tsx     | 100 +++++++++++++++++++--
 .../app/components/__tests__/NewTaskModal.test.tsx |   1 +
 .../app/components/__tests__/TaskForm.test.tsx     |  66 +++++++++++++-
 4 files changed, 156 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-5716

Fusion-Task-Lineage: 0ed72020-d8a0-4675-92d3-9476f53a173a
This commit is contained in:
gsxdsm
2026-05-30 09:40:44 -07:00
parent 805eae4237
commit 98e6a7364e
4 changed files with 156 additions and 15 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowStep } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { NodeHealthDot } from "./NodeHealthDot";
@@ -16,6 +16,26 @@ function getNodeStatusLabel(status: NodeInfo["status"]): string {
}
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const COMMON_INTEGRATION_BRANCHES = ["main", "master", "trunk", "develop"];
const CUSTOM_BRANCH_OPTION = "__fusion-custom__";
const DEFAULT_BRANCH_OPTION = "";
function sortBranchNames(branches: string[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
for (const name of COMMON_INTEGRATION_BRANCHES) {
if (branches.includes(name) && !seen.has(name)) {
ordered.push(name);
seen.add(name);
}
}
for (const name of [...branches].sort((a, b) => a.localeCompare(b))) {
if (seen.has(name)) continue;
seen.add(name);
ordered.push(name);
}
return ordered;
}
/** Renders a phase badge using shared .phase-badge classes for consistency */
function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string): ReactNode {
@@ -214,6 +234,8 @@ export function TaskForm({
const [globalSettings, setGlobalSettings] = useState<GlobalSettings | null>(null);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
const [baseBranchOptions, setBaseBranchOptions] = useState<string[]>([]);
const [baseBranchCustomMode, setBaseBranchCustomMode] = useState(false);
// AI Refinement state
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
@@ -325,6 +347,18 @@ export function TaskForm({
githubTrackingDefaultAppliedRef.current = true;
}, [mode, isActive, settings, onGithubTrackingEnabledChange]);
useEffect(() => {
if (!isActive || !onBaseBranchChange) return;
fetchGitBranches(projectId)
.then((branches) => {
const names = branches
.map((branchInfo) => branchInfo.name)
.filter((name): name is string => typeof name === "string" && name.length > 0);
setBaseBranchOptions(sortBranchNames(names));
})
.catch(() => setBaseBranchOptions([]));
}, [isActive, onBaseBranchChange, projectId]);
useEffect(() => {
if (!isActive) {
githubTrackingDefaultAppliedRef.current = false;
@@ -1010,14 +1044,62 @@ export function TaskForm({
{onBaseBranchChange && (
<>
<label htmlFor="task-base-branch" className="model-select-label">Merge target / base branch</label>
<input
id="task-base-branch"
className="input"
value={baseBranch || ""}
onChange={(e) => onBaseBranchChange(e.target.value)}
placeholder="e.g. main"
disabled={disabled}
/>
{(() => {
const currentValue = baseBranch || "";
const valueIsKnown = currentValue.length > 0 && baseBranchOptions.includes(currentValue);
const isCustomMode = baseBranchCustomMode || (currentValue.length > 0 && !valueIsKnown) || baseBranchOptions.length === 0;
if (isCustomMode) {
return (
<div className="form-inline-group">
<input
id="task-base-branch"
className="input"
value={currentValue}
onChange={(e) => onBaseBranchChange(e.target.value)}
placeholder="e.g. main"
disabled={disabled}
data-testid="task-base-branch-custom-input"
/>
<button
type="button"
className="btn-link"
onClick={() => {
setBaseBranchCustomMode(false);
onBaseBranchChange("");
}}
disabled={disabled}
data-testid="task-base-branch-use-dropdown"
>
Use dropdown
</button>
</div>
);
}
return (
<select
id="task-base-branch"
className="select"
value={currentValue}
onChange={(e) => {
const next = e.target.value;
if (next === CUSTOM_BRANCH_OPTION) {
setBaseBranchCustomMode(true);
return;
}
onBaseBranchChange(next === DEFAULT_BRANCH_OPTION ? "" : next);
}}
disabled={disabled}
data-testid="task-base-branch-select"
>
<option value={DEFAULT_BRANCH_OPTION}>(default / project branch)</option>
{baseBranchOptions.map((name) => (
<option key={name} value={name}>{name}</option>
))}
<option value={CUSTOM_BRANCH_OPTION}>Custom</option>
</select>
);
})()}
</>
)}
</div>

View File

@@ -29,6 +29,7 @@ vi.mock("../../api", () => ({
}),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchGitBranches: vi.fn().mockResolvedValue([]),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchAuthStatus: vi.fn().mockResolvedValue({ providers: [] }),
refineText: vi.fn(),

View File

@@ -30,6 +30,7 @@ vi.mock("../../api", () => ({
refineText: vi.fn().mockResolvedValue("Refined text"),
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
fetchGitBranches: vi.fn().mockResolvedValue([]),
}));
function makeTask(id: string): Task {
@@ -127,8 +128,10 @@ globalThis.URL.createObjectURL = vi.fn(() => "blob:mock-url");
globalThis.URL.revokeObjectURL = vi.fn();
describe("TaskForm", () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
const { fetchGitBranches } = await import("../../api");
vi.mocked(fetchGitBranches).mockResolvedValue([]);
});
it("renders description field with AI refine button when text is present", () => {
@@ -222,7 +225,7 @@ describe("TaskForm", () => {
expect(onPriorityChange).toHaveBeenCalledWith("urgent");
});
it("renders working and base branch inputs when branch callbacks are provided", () => {
it("renders working branch input and base branch custom input when no branch options are available", () => {
renderTaskForm({
branch: "feature/fn-3422",
baseBranch: "main",
@@ -233,7 +236,7 @@ describe("TaskForm", () => {
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(screen.getByLabelText("Working branch")).toHaveValue("feature/fn-3422");
expect(screen.getByLabelText("Merge target / base branch")).toHaveValue("main");
expect(screen.getByTestId("task-base-branch-custom-input")).toHaveValue("main");
});
it("calls branch change handlers and supports explicit clearing", () => {
@@ -250,7 +253,7 @@ describe("TaskForm", () => {
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Working branch"), { target: { value: "feature/new" } });
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: "" } });
fireEvent.change(screen.getByTestId("task-base-branch-custom-input"), { target: { value: "" } });
expect(onBranchChange).toHaveBeenCalledWith("feature/new");
expect(onBaseBranchChange).toHaveBeenCalledWith("");
@@ -273,6 +276,61 @@ describe("TaskForm", () => {
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "true");
});
it("renders base branch dropdown options sorted with common integration branches first", async () => {
const { fetchGitBranches } = await import("../../api");
vi.mocked(fetchGitBranches).mockResolvedValueOnce([
{ name: "release" },
{ name: "develop" },
{ name: "feature/foo" },
{ name: "main" },
{ name: "main" },
{ name: "trunk" },
] as any);
const onBaseBranchChange = vi.fn();
renderTaskForm({ onBaseBranchChange });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
const select = await screen.findByTestId("task-base-branch-select");
const optionValues = Array.from((select as HTMLSelectElement).options).map((option) => option.value);
expect(optionValues).toEqual(["", "main", "trunk", "develop", "feature/foo", "release", "__fusion-custom__"]);
fireEvent.change(select, { target: { value: "develop" } });
expect(onBaseBranchChange).toHaveBeenCalledWith("develop");
fireEvent.change(select, { target: { value: "__fusion-custom__" } });
expect(screen.getByTestId("task-base-branch-custom-input")).toBeTruthy();
});
it("defaults to custom mode for unknown base branch and supports switching back to dropdown", async () => {
const { fetchGitBranches } = await import("../../api");
vi.mocked(fetchGitBranches).mockResolvedValueOnce([{ name: "main" }, { name: "develop" }] as any);
const onBaseBranchChange = vi.fn();
renderTaskForm({
baseBranch: "release/candidate",
onBaseBranchChange,
});
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(await screen.findByTestId("task-base-branch-custom-input")).toHaveValue("release/candidate");
fireEvent.click(screen.getByTestId("task-base-branch-use-dropdown"));
expect(onBaseBranchChange).toHaveBeenCalledWith("");
});
it("keeps base branch entry available when branch loading fails", async () => {
const { fetchGitBranches } = await import("../../api");
vi.mocked(fetchGitBranches).mockRejectedValueOnce(new Error("boom"));
renderTaskForm({ onBaseBranchChange: vi.fn() });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByTestId("task-base-branch-custom-input")).toBeTruthy();
});
});
it("fetches and stores favoriteModels from fetchModels response", async () => {
const { fetchModels } = await import("../../api");
vi.mocked(fetchModels).mockResolvedValueOnce({