feat(FN-5585): add planning branch controls to planning mode modal
Adds planning branch controls to the PlanningModeModal, extending the planning API and routes with supporting tests and documentation. The changeset bumps `@runfusion/fusion` as a minor release. Fusion-Task-Id: FN-5585 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> Fusion-Task-Id: FN-5585
This commit is contained in:
5
.changeset/fn-5585-planning-branch-ui.md
Normal file
5
.changeset/fn-5585-planning-branch-ui.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Planning Mode now lets you pick a branch strategy (project default, auto-named, existing, or custom new) and an optional base/merge-target branch when creating a task from a completed planning session.
|
||||
@@ -97,6 +97,20 @@ Behavior:
|
||||
- Nodes support manual drag repositioning with a 4px movement threshold to separate click from drag, using pointer capture and zoom-aware delta scaling for reliable tracking
|
||||
- Custom node positions persist per project in browser localStorage (`kb:${projectId}:fusion-plugin-dependency-graph:positions`) across refresh/project switches, and **Fit to graph** clears saved positions and restores auto-layout
|
||||
|
||||
## Planning Mode
|
||||
|
||||
Planning Mode now includes branch controls on the summary screen before you create a task.
|
||||
|
||||
- **Branch strategy** options mirror Subtask Breakdown semantics:
|
||||
- `Use project/default branch`
|
||||
- `Create auto-named branch per task`
|
||||
- `Use existing branch`
|
||||
- `Create custom new branch`
|
||||
- **Branch name** is required when using `existing` or `custom new` strategies.
|
||||
- **Merge target / base branch (optional)** lets you set the PR base branch (for example `main` or `develop`).
|
||||
|
||||
These values are sent with the Planning Mode create-task request as `branchSelection`, so created tasks persist branch/base-branch settings consistently with other branch-aware task creation flows.
|
||||
|
||||
## Chat View
|
||||
|
||||
Chat view provides project-scoped conversations with agents.
|
||||
|
||||
@@ -1058,6 +1058,43 @@ describe("Planning Mode API", () => {
|
||||
|
||||
await expect(createTaskFromPlanning("plan-123")).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("includes branchSelection options in create-task request body", async () => {
|
||||
const createdTask: Task = {
|
||||
id: "FN-043",
|
||||
title: "Branch aware plan",
|
||||
description: "Create task with branch config",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, createdTask, 201));
|
||||
|
||||
await createTaskFromPlanning("plan-123", undefined, undefined, {
|
||||
branchSelection: {
|
||||
mode: "custom-new",
|
||||
branchName: "feature/planning-ui",
|
||||
baseBranch: "develop",
|
||||
},
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/planning/create-task", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId: "plan-123",
|
||||
branchSelection: {
|
||||
mode: "custom-new",
|
||||
branchName: "feature/planning-ui",
|
||||
baseBranch: "develop",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3361,10 +3361,24 @@ export function createTaskFromPlanning(
|
||||
sessionId: string,
|
||||
summary?: PlanningSummary,
|
||||
projectId?: string,
|
||||
options?: {
|
||||
branch?: string;
|
||||
baseBranch?: string;
|
||||
branchSelection?: {
|
||||
mode: "project-default" | "auto-new" | "existing" | "custom-new";
|
||||
branchName?: string;
|
||||
baseBranch?: string;
|
||||
};
|
||||
},
|
||||
): Promise<Task> {
|
||||
return api<Task>(withProjectId("/planning/create-task", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(summary ? { sessionId, summary } : { sessionId }),
|
||||
body: JSON.stringify({
|
||||
...(summary ? { sessionId, summary } : { sessionId }),
|
||||
...(options?.branch !== undefined ? { branch: options.branch } : {}),
|
||||
...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}),
|
||||
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
|
||||
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
|
||||
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null);
|
||||
const [branchMode, setBranchMode] = useState<"project-default" | "auto-new" | "existing" | "custom-new">("project-default");
|
||||
const [branchName, setBranchName] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
// Use ref instead of state for hasAutoStarted to handle React StrictMode double-render.
|
||||
// In StrictMode, components render twice but state persists across renders,
|
||||
// which would skip auto-start on the second (committed) render. Refs are
|
||||
@@ -377,6 +380,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setResponseHistory([]);
|
||||
setConversationHistory([]);
|
||||
setEditedSummary(null);
|
||||
setBranchMode("project-default");
|
||||
setBranchName("");
|
||||
setBaseBranch("");
|
||||
setStreamingOutput("");
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
@@ -1557,13 +1563,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
const handleCreateTask = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
if ((branchMode === "existing" || branchMode === "custom-new") && !branchName.trim()) return;
|
||||
|
||||
setError(null);
|
||||
setIsCreatingTask(true);
|
||||
|
||||
try {
|
||||
const completedSessionId = view.session.sessionId;
|
||||
const task = await createTaskFromPlanning(completedSessionId, editedSummary ?? undefined, projectId);
|
||||
const task = await createTaskFromPlanning(completedSessionId, editedSummary ?? undefined, projectId, {
|
||||
branchSelection: {
|
||||
mode: branchMode,
|
||||
...(branchMode === "existing" || branchMode === "custom-new" ? { branchName: branchName.trim() } : {}),
|
||||
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
|
||||
},
|
||||
});
|
||||
onTaskCreated(task);
|
||||
// The server cleans up the planning session after task creation. Drop
|
||||
// the local selection so a future reopen doesn't try to fetch a deleted
|
||||
@@ -1582,7 +1595,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
} finally {
|
||||
setIsCreatingTask(false);
|
||||
}
|
||||
}, [broadcastCompleted, editedSummary, view, projectId, onTaskCreated, handleClose]);
|
||||
}, [baseBranch, branchMode, branchName, broadcastCompleted, editedSummary, view, projectId, onTaskCreated, handleClose]);
|
||||
|
||||
const handleStartBreakdown = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
@@ -2070,6 +2083,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
historyEntries={conversationHistory}
|
||||
onSummaryChange={setEditedSummary}
|
||||
tasks={tasks}
|
||||
branchMode={branchMode}
|
||||
branchName={branchName}
|
||||
baseBranch={baseBranch}
|
||||
onBranchModeChange={setBranchMode}
|
||||
onBranchNameChange={setBranchName}
|
||||
onBaseBranchChange={setBaseBranch}
|
||||
onCreateTask={handleCreateTask}
|
||||
onBreakIntoTasks={handleStartBreakdown}
|
||||
onRefine={() => {
|
||||
@@ -2360,6 +2379,12 @@ interface SummaryViewProps {
|
||||
historyEntries: ConversationHistoryEntry[];
|
||||
onSummaryChange: (summary: PlanningSummary) => void;
|
||||
tasks: Task[];
|
||||
branchMode: "project-default" | "auto-new" | "existing" | "custom-new";
|
||||
branchName: string;
|
||||
baseBranch: string;
|
||||
onBranchModeChange: (mode: "project-default" | "auto-new" | "existing" | "custom-new") => void;
|
||||
onBranchNameChange: (name: string) => void;
|
||||
onBaseBranchChange: (branch: string) => void;
|
||||
onCreateTask: () => void;
|
||||
onBreakIntoTasks: () => void;
|
||||
onRefine: () => void;
|
||||
@@ -2371,6 +2396,12 @@ function SummaryView({
|
||||
historyEntries,
|
||||
onSummaryChange,
|
||||
tasks,
|
||||
branchMode,
|
||||
branchName,
|
||||
baseBranch,
|
||||
onBranchModeChange,
|
||||
onBranchNameChange,
|
||||
onBaseBranchChange,
|
||||
onCreateTask,
|
||||
onBreakIntoTasks,
|
||||
onRefine,
|
||||
@@ -2387,6 +2418,8 @@ function SummaryView({
|
||||
deps: [isExpanded],
|
||||
});
|
||||
const selectedPriority = normalizeTaskPriority(summary.priority);
|
||||
const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new";
|
||||
const hasInvalidBranchSelection = isBranchNameRequired && !branchName.trim();
|
||||
|
||||
const handleDependencyToggle = (taskId: string) => {
|
||||
const newDeps = selectedDependencies.includes(taskId)
|
||||
@@ -2432,6 +2465,44 @@ function SummaryView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="task-detail-section">
|
||||
<div className="form-group">
|
||||
<label>Branch strategy</label>
|
||||
<select
|
||||
value={branchMode}
|
||||
onChange={(event) => onBranchModeChange(event.target.value as "project-default" | "auto-new" | "existing" | "custom-new")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="project-default">Use project/default branch</option>
|
||||
<option value="auto-new">Create auto-named branch per task</option>
|
||||
<option value="existing">Use existing branch</option>
|
||||
<option value="custom-new">Create custom new branch</option>
|
||||
</select>
|
||||
</div>
|
||||
{isBranchNameRequired && (
|
||||
<div className="form-group">
|
||||
<label>Branch name</label>
|
||||
<input
|
||||
value={branchName}
|
||||
onChange={(event) => onBranchNameChange(event.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Merge target / base branch (optional)</label>
|
||||
<input
|
||||
value={baseBranch}
|
||||
onChange={(event) => onBaseBranchChange(event.target.value)}
|
||||
disabled={isLoading}
|
||||
placeholder="main"
|
||||
/>
|
||||
</div>
|
||||
{hasInvalidBranchSelection && (
|
||||
<div className="form-error planning-error">Branch name is required for this branch strategy.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="planning-summary-meta-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="planning-summary-size">Suggested Size</label>
|
||||
@@ -2517,7 +2588,7 @@ function SummaryView({
|
||||
Refine Further
|
||||
</button>
|
||||
<div className="planning-summary-actions-right">
|
||||
<button className="btn" onClick={onCreateTask} disabled={isLoading}>
|
||||
<button className="btn" onClick={onCreateTask} disabled={isLoading || hasInvalidBranchSelection}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spin icon-mr-8" />
|
||||
|
||||
@@ -1031,6 +1031,7 @@ describe("PlanningModeModal", () => {
|
||||
"session-complete-2",
|
||||
expect.objectContaining({ ...resumedSummary, priority: "normal" }),
|
||||
undefined,
|
||||
expect.objectContaining({ branchSelection: { mode: "project-default" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1085,10 +1086,104 @@ describe("PlanningModeModal", () => {
|
||||
"session-complete-priority",
|
||||
expect.objectContaining({ priority: "high" }),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
branchSelection: { mode: "project-default" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces planning branch controls and sends branchSelection in create request", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-branch-controls",
|
||||
description: "Recovered summary for branch controls",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implement", "Verify"],
|
||||
};
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-branch-controls",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Resume-branch-controls",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover and create with branch controls" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-branch-controls"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Create Single Task" })).toBeDefined();
|
||||
});
|
||||
|
||||
const branchStrategy = screen.getByRole("combobox", { name: "Branch strategy" }) as HTMLSelectElement;
|
||||
expect(branchStrategy.value).toBe("project-default");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Single Task" }));
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith(
|
||||
"session-branch-controls",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
branchSelection: { mode: "project-default" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.change(branchStrategy, { target: { value: "existing" } });
|
||||
expect(screen.getByRole("textbox", { name: "Branch name" })).toBeDefined();
|
||||
|
||||
const createSingleTaskButton = screen.getByRole("button", { name: "Create Single Task" });
|
||||
expect(createSingleTaskButton).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Branch name" }), {
|
||||
target: { value: "feat/planning-branch" },
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Merge target / base branch (optional)" }), {
|
||||
target: { value: "develop" },
|
||||
});
|
||||
fireEvent.click(createSingleTaskButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTaskFromPlanning).toHaveBeenLastCalledWith(
|
||||
"session-branch-controls",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
branchSelection: {
|
||||
mode: "existing",
|
||||
branchName: "feat/planning-branch",
|
||||
baseBranch: "develop",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.change(branchStrategy, { target: { value: "auto-new" } });
|
||||
expect(screen.queryByRole("textbox", { name: "Branch name" })).toBeNull();
|
||||
|
||||
fireEvent.change(branchStrategy, { target: { value: "custom-new" } });
|
||||
expect(screen.getByRole("textbox", { name: "Branch name" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves per-subtask priority selections when creating tasks from breakdown", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-to-breakdown-priority",
|
||||
|
||||
@@ -1070,7 +1070,12 @@ describe("PlanningModeModal", () => {
|
||||
fireEvent.click(screen.getByText("Create Single Task"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-123", mockSummary, undefined);
|
||||
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith(
|
||||
"session-123",
|
||||
mockSummary,
|
||||
undefined,
|
||||
expect.objectContaining({ branchSelection: { mode: "project-default" } }),
|
||||
);
|
||||
expect(mockOnTaskCreated).toHaveBeenCalledWith(createdTask);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1931,6 +1931,51 @@ describe("Planning Mode Routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps per-task branch unset for project-default branchSelection", async () => {
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "FN-200b",
|
||||
description: "A task created from planning",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const startRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
const sessionId = startRes.body.sessionId;
|
||||
|
||||
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
|
||||
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
|
||||
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/create-task",
|
||||
JSON.stringify({
|
||||
sessionId,
|
||||
branchSelection: {
|
||||
mode: "project-default",
|
||||
},
|
||||
}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: undefined,
|
||||
baseBranch: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies shared branchSelection to all planning subtasks", async () => {
|
||||
(store.createTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
Reference in New Issue
Block a user