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:
gsxdsm
2026-05-25 16:32:17 -07:00
parent 5eacd79b43
commit 1fb905afb7
8 changed files with 291 additions and 5 deletions

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

View File

@@ -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 - 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 - 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
Chat view provides project-scoped conversations with agents. Chat view provides project-scoped conversations with agents.

View File

@@ -1058,6 +1058,43 @@ describe("Planning Mode API", () => {
await expect(createTaskFromPlanning("plan-123")).rejects.toThrow("not found"); 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",
},
}),
});
});
}); });
}); });

View File

@@ -3361,10 +3361,24 @@ export function createTaskFromPlanning(
sessionId: string, sessionId: string,
summary?: PlanningSummary, summary?: PlanningSummary,
projectId?: string, projectId?: string,
options?: {
branch?: string;
baseBranch?: string;
branchSelection?: {
mode: "project-default" | "auto-new" | "existing" | "custom-new";
branchName?: string;
baseBranch?: string;
};
},
): Promise<Task> { ): Promise<Task> {
return api<Task>(withProjectId("/planning/create-task", projectId), { return api<Task>(withProjectId("/planning/create-task", projectId), {
method: "POST", 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 } : {}),
}),
}); });
} }

View File

@@ -158,6 +158,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]); const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]); const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null); 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. // Use ref instead of state for hasAutoStarted to handle React StrictMode double-render.
// In StrictMode, components render twice but state persists across renders, // In StrictMode, components render twice but state persists across renders,
// which would skip auto-start on the second (committed) render. Refs are // which would skip auto-start on the second (committed) render. Refs are
@@ -377,6 +380,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setResponseHistory([]); setResponseHistory([]);
setConversationHistory([]); setConversationHistory([]);
setEditedSummary(null); setEditedSummary(null);
setBranchMode("project-default");
setBranchName("");
setBaseBranch("");
setStreamingOutput(""); setStreamingOutput("");
setIsReconnecting(false); setIsReconnecting(false);
setIsRetrying(false); setIsRetrying(false);
@@ -1557,13 +1563,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const handleCreateTask = useCallback(async () => { const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return; if (view.type !== "summary") return;
if ((branchMode === "existing" || branchMode === "custom-new") && !branchName.trim()) return;
setError(null); setError(null);
setIsCreatingTask(true); setIsCreatingTask(true);
try { try {
const completedSessionId = view.session.sessionId; 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); onTaskCreated(task);
// The server cleans up the planning session after task creation. Drop // 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 // 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 { } finally {
setIsCreatingTask(false); setIsCreatingTask(false);
} }
}, [broadcastCompleted, editedSummary, view, projectId, onTaskCreated, handleClose]); }, [baseBranch, branchMode, branchName, broadcastCompleted, editedSummary, view, projectId, onTaskCreated, handleClose]);
const handleStartBreakdown = useCallback(async () => { const handleStartBreakdown = useCallback(async () => {
if (view.type !== "summary") return; if (view.type !== "summary") return;
@@ -2070,6 +2083,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
historyEntries={conversationHistory} historyEntries={conversationHistory}
onSummaryChange={setEditedSummary} onSummaryChange={setEditedSummary}
tasks={tasks} tasks={tasks}
branchMode={branchMode}
branchName={branchName}
baseBranch={baseBranch}
onBranchModeChange={setBranchMode}
onBranchNameChange={setBranchName}
onBaseBranchChange={setBaseBranch}
onCreateTask={handleCreateTask} onCreateTask={handleCreateTask}
onBreakIntoTasks={handleStartBreakdown} onBreakIntoTasks={handleStartBreakdown}
onRefine={() => { onRefine={() => {
@@ -2360,6 +2379,12 @@ interface SummaryViewProps {
historyEntries: ConversationHistoryEntry[]; historyEntries: ConversationHistoryEntry[];
onSummaryChange: (summary: PlanningSummary) => void; onSummaryChange: (summary: PlanningSummary) => void;
tasks: Task[]; 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; onCreateTask: () => void;
onBreakIntoTasks: () => void; onBreakIntoTasks: () => void;
onRefine: () => void; onRefine: () => void;
@@ -2371,6 +2396,12 @@ function SummaryView({
historyEntries, historyEntries,
onSummaryChange, onSummaryChange,
tasks, tasks,
branchMode,
branchName,
baseBranch,
onBranchModeChange,
onBranchNameChange,
onBaseBranchChange,
onCreateTask, onCreateTask,
onBreakIntoTasks, onBreakIntoTasks,
onRefine, onRefine,
@@ -2387,6 +2418,8 @@ function SummaryView({
deps: [isExpanded], deps: [isExpanded],
}); });
const selectedPriority = normalizeTaskPriority(summary.priority); const selectedPriority = normalizeTaskPriority(summary.priority);
const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new";
const hasInvalidBranchSelection = isBranchNameRequired && !branchName.trim();
const handleDependencyToggle = (taskId: string) => { const handleDependencyToggle = (taskId: string) => {
const newDeps = selectedDependencies.includes(taskId) const newDeps = selectedDependencies.includes(taskId)
@@ -2432,6 +2465,44 @@ function SummaryView({
/> />
</div> </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="planning-summary-meta-row">
<div className="form-group"> <div className="form-group">
<label htmlFor="planning-summary-size">Suggested Size</label> <label htmlFor="planning-summary-size">Suggested Size</label>
@@ -2517,7 +2588,7 @@ function SummaryView({
Refine Further Refine Further
</button> </button>
<div className="planning-summary-actions-right"> <div className="planning-summary-actions-right">
<button className="btn" onClick={onCreateTask} disabled={isLoading}> <button className="btn" onClick={onCreateTask} disabled={isLoading || hasInvalidBranchSelection}>
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 size={16} className="spin icon-mr-8" /> <Loader2 size={16} className="spin icon-mr-8" />

View File

@@ -1031,6 +1031,7 @@ describe("PlanningModeModal", () => {
"session-complete-2", "session-complete-2",
expect.objectContaining({ ...resumedSummary, priority: "normal" }), expect.objectContaining({ ...resumedSummary, priority: "normal" }),
undefined, undefined,
expect.objectContaining({ branchSelection: { mode: "project-default" } }),
); );
}); });
}); });
@@ -1085,10 +1086,104 @@ describe("PlanningModeModal", () => {
"session-complete-priority", "session-complete-priority",
expect.objectContaining({ priority: "high" }), expect.objectContaining({ priority: "high" }),
undefined, 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 () => { it("preserves per-subtask priority selections when creating tasks from breakdown", async () => {
const resumedSummary: PlanningSummary = { const resumedSummary: PlanningSummary = {
title: "Resume-to-breakdown-priority", title: "Resume-to-breakdown-priority",

View File

@@ -1070,7 +1070,12 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByText("Create Single Task")); fireEvent.click(screen.getByText("Create Single Task"));
await waitFor(() => { 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); expect(mockOnTaskCreated).toHaveBeenCalledWith(createdTask);
}); });
}); });

View File

@@ -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 () => { it("applies shared branchSelection to all planning subtasks", async () => {
(store.createTask as ReturnType<typeof vi.fn>) (store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ .mockResolvedValueOnce({