Files
fusion/packages/dashboard/app/components/NewTaskModal.tsx
gsxdsm 9c29e2e776 FN-5829: add shared feature branch option for new tasks
Allow new tasks to target a shared branch-group feature branch during creation.

- Add branch-group aware branch selection handling in task workflow routes.
- Update store/types/db wiring to support and persist shared feature-branch targeting.
- Extend dashboard task creation UI/tests and route tests to cover the new option.
- Add a changeset for @runfusion/fusion documenting the new CLI/task behavior.

Files changed:
 .changeset/fn-5829-shared-feature-branch-option.md |   7 ++
 .../src/commands/__tests__/task-lifecycle.test.ts  |   2 +-
 .../core/src/__tests__/branch-group-store.test.ts  |  26 ++++++
 packages/core/src/db.ts                            |   4 +-
 packages/core/src/store.ts                         |   9 +-
 packages/core/src/types.ts                         |   2 +-
 packages/dashboard/app/components/NewTaskModal.tsx |   2 +-
 packages/dashboard/app/components/TaskForm.tsx     |   9 +-
 .../app/components/__tests__/NewTaskModal.test.tsx |  37 ++++++++
 .../src/__tests__/branch-selection.test.ts         |  12 +++
 .../dashboard/src/__tests__/mission-e2e.test.ts    |   8 +-
 .../src/__tests__/routes-planning.test.ts          |   6 +-
 .../src/__tests__/routes-tasks.test.ts             | 102 +++++++++++++++++++++
 packages/dashboard/src/routes/branch-selection.ts  |  19 +++-
 .../src/routes/register-task-workflow-routes.ts    |  23 +++--
 15 files changed, 242 insertions(+), 26 deletions(-)

Fusion-Task-Id: FN-5829

Fusion-Task-Lineage: 7d69acc8-19a3-4112-9bf2-5ac7b5e5a929
2026-06-01 03:23:10 -07:00

557 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "./NewTaskModal.css";
import { useState, useCallback, useEffect, useRef } from "react";
import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { uploadAttachment } from "../api";
import { Bot } from "lucide-react";
import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
import { TaskForm, type BranchSelectionMode, type PendingImage } from "./TaskForm";
import { REPO_OVERRIDE_RE } from "./githubTracking";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { useViewportMode } from "../hooks/useViewportMode";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
interface NewTaskModalProps {
isOpen: boolean;
onClose: () => void;
projectId?: string;
tasks: Task[]; // for dependency selection
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
addToast: (message: string, type?: ToastType) => void;
}
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast }: NewTaskModalProps) {
const { confirm } = useConfirm();
const viewportMode = useViewportMode();
useMobileScrollLock(isOpen);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: viewportMode === "mobile",
});
const keyboardStyle: React.CSSProperties = keyboardOpen
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as React.CSSProperties)
: {};
const [description, setDescription] = useState("");
const [dependencies, setDependencies] = useState<string[]>([]);
const [branchMode, setBranchMode] = useState<BranchSelectionMode>("project-default");
const [branch, setBranch] = useState("");
const [baseBranch, setBaseBranch] = useState("");
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [executorModel, setExecutorModel] = useState("");
const [validatorModel, setValidatorModel] = useState("");
const [planningModel, setPlanningModel] = useState("");
const [thinkingLevel, setThinkingLevel] = useState<string>("");
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
const [hasDirtyState, setHasDirtyState] = useState(false);
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
const [githubTrackingEnabled, setGithubTrackingEnabled] = useState(false);
const [githubRepoOverride, setGithubRepoOverride] = useState("");
// Agent assignment state
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { agents, loading: agentsLoading } = useAgentsMapCache(projectId);
const [showAgentPicker, setShowAgentPicker] = useState(false);
const agentPickerRef = useRef<HTMLDivElement>(null);
// Quick-fields dependency picker state
const [showDeps, setShowDeps] = useState(false);
const [depSearch, setDepSearch] = useState("");
const quickFieldsDepRef = useRef<HTMLDivElement>(null);
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
const { nodes } = useNodes();
// Handler for workflow step changes that detects explicit user interaction
const handleWorkflowStepsChange = useCallback((steps: string[]) => {
setWorkflowStepsExplicitlySet(true);
setSelectedWorkflowSteps(steps);
}, []);
// Callback when defaultOn steps are auto-applied by TaskForm
const handleDefaultOnApplied = useCallback(() => {
// defaultOn auto-selection is not "explicit" user interaction
setWorkflowStepsExplicitlySet(false);
}, []);
// Load agents for agent picker
const loadAgents = useCallback(() => {
setShowAgentPicker(true);
}, []);
// Close agent picker when clicking outside
useEffect(() => {
if (!showAgentPicker) return;
const handleClickOutside = (e: MouseEvent) => {
if (agentPickerRef.current && !agentPickerRef.current.contains(e.target as Node)) {
setShowAgentPicker(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAgentPicker]);
// Close quick-fields dep dropdown when clicking outside
useEffect(() => {
if (!showDeps) return;
const handleClickOutside = (e: MouseEvent) => {
if (quickFieldsDepRef.current && !quickFieldsDepRef.current.contains(e.target as Node)) {
setShowDeps(false);
setDepSearch("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showDeps]);
// Compute available deps for quick-fields picker (same logic as TaskForm)
const availableDeps = tasks
.filter((t) => !dependencies.includes(t.id))
.sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
});
const filteredDeps = depSearch
? availableDeps.filter((t) =>
t.id.toLowerCase().includes(depSearch.toLowerCase()) ||
(t.title && t.title.toLowerCase().includes(depSearch.toLowerCase())) ||
(t.description && t.description.toLowerCase().includes(depSearch.toLowerCase()))
)
: availableDeps;
const truncate = (s: string, len: number) =>
s.length > len ? s.slice(0, len) + "…" : s;
const githubRepoOverrideTrimmed = githubRepoOverride.trim();
const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed);
const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new" || branchMode === "shared-group";
const hasInvalidBranchSelection = isBranchNameRequired && !branch.trim();
// Track dirty state
useEffect(() => {
const isDirty =
description.trim() !== "" ||
dependencies.length > 0 ||
pendingImages.length > 0 ||
executorModel !== "" ||
validatorModel !== "" ||
planningModel !== "" ||
thinkingLevel !== "" ||
selectedWorkflowSteps.length > 0 ||
selectedAgentId !== null ||
reviewLevel !== undefined ||
autoMerge !== undefined ||
priority !== DEFAULT_TASK_PRIORITY ||
nodeId !== undefined ||
branchMode !== "project-default" ||
branch !== "" ||
baseBranch !== "" ||
githubTrackingEnabled ||
githubRepoOverrideTrimmed !== "";
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
const handleClose = useCallback(async () => {
if (hasDirtyState) {
const shouldDiscard = await confirm({
title: "Discard Changes",
message: "You have unsaved changes. Discard them?",
danger: true,
});
if (!shouldDiscard) return;
}
// Clean up object URLs
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
// Reset form
setPendingImages([]);
setDescription("");
setDependencies([]);
setExecutorModel("");
setValidatorModel("");
setPlanningModel("");
setThinkingLevel("");
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowSteps([]);
setWorkflowStepsExplicitlySet(false);
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
setAutoMerge(undefined);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
setBranchMode("project-default");
setBranch("");
setBaseBranch("");
setHasDirtyState(false);
setGithubTrackingEnabled(false);
setGithubRepoOverride("");
onClose();
}, [hasDirtyState, onClose, pendingImages, confirm]);
const handleSubmit = useCallback(async () => {
const trimmedDesc = description.trim();
if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection) return;
setIsSubmitting(true);
try {
const executorSlashIdx = executorModel.indexOf("/");
const validatorSlashIdx = validatorModel.indexOf("/");
const planningSlashIdx = planningModel.indexOf("/");
const createInput: TaskCreateInput & {
branchSelection?: {
mode: BranchSelectionMode;
branchName?: string;
baseBranch?: string;
};
} = {
title: undefined,
description: trimmedDesc,
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
// When user explicitly cleared all workflow steps, send empty array to prevent backend re-applying defaults.
// When user hasn't interacted with workflow steps (or left auto-selected defaults), send undefined to let backend apply defaults.
enabledWorkflowSteps: workflowStepsExplicitlySet ? (selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : []) : undefined,
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined,
validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined,
planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined,
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" : undefined,
reviewLevel,
...(autoMerge !== undefined ? { autoMerge } : {}),
priority,
nodeId,
branchSelection: {
mode: branchMode,
...(isBranchNameRequired && branch.trim() ? { branchName: branch.trim() } : {}),
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
},
...(githubTrackingEnabled || githubRepoOverrideTrimmed !== ""
? {
githubTracking: {
enabled: githubTrackingEnabled,
...(githubRepoOverrideTrimmed !== "" ? { repoOverride: githubRepoOverrideTrimmed } : {}),
},
}
: {}),
};
const task = await onCreateTask(createInput);
// Upload pending images as attachments
if (pendingImages.length > 0) {
const failures: string[] = [];
for (const img of pendingImages) {
try {
await uploadAttachment(task.id, img.file, projectId);
} catch {
failures.push(img.file.name);
}
}
if (failures.length > 0) {
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
}
}
// Clean up
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setPendingImages([]);
setDescription("");
setDependencies([]);
setExecutorModel("");
setValidatorModel("");
setPlanningModel("");
setThinkingLevel("");
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowSteps([]);
setWorkflowStepsExplicitlySet(false);
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
setAutoMerge(undefined);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
setBranchMode("project-default");
setBranch("");
setBaseBranch("");
addToast(`Created ${task.id}`, "success");
onClose();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to create task", "error");
} finally {
setIsSubmitting(false);
}
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
}, [handleClose]);
// Compute selected agent label for display
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
// Quick fields: promoted dependencies and agent assignment
const quickFields = (
<div className="new-task-quick-fields">
{/* Dependencies field */}
<div className="form-group">
<label>Dependencies</label>
<div className="dep-trigger-wrap" ref={quickFieldsDepRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={() => setShowDeps((v) => !v)}
disabled={isSubmitting}
data-testid="dep-trigger"
>
{dependencies.length > 0 ? `${dependencies.length} selected` : "Add dependencies"}
</button>
{showDeps && (
<div className="dep-dropdown">
<input
className="dep-dropdown-search"
placeholder="Search tasks…"
autoFocus
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{filteredDeps.length === 0 ? (
<div className="dep-dropdown-empty">No available tasks</div>
) : (
filteredDeps.map((t) => (
<div
key={t.id}
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
onClick={() => {
setDependencies(
dependencies.includes(t.id) ? dependencies.filter((d) => d !== t.id) : [...dependencies, t.id],
);
setShowDeps(false);
setDepSearch("");
}}
onMouseDown={(e) => e.preventDefault()}
>
<span className="dep-dropdown-id">{t.id}</span>
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
</div>
))
)}
</div>
)}
</div>
{dependencies.length > 0 && (
<div className="selected-deps">
{dependencies.map((depId) => (
<span key={depId} className="dep-chip">
{depId}
<button
type="button"
className="dep-chip-remove"
onClick={() => setDependencies(dependencies.filter((d) => d !== depId))}
disabled={isSubmitting}
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Agent Assignment */}
<div className="form-group">
<label>Assign Agent</label>
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={() => {
if (showAgentPicker) {
setShowAgentPicker(false);
} else {
void loadAgents();
}
}}
disabled={isSubmitting}
data-testid="new-task-agent-button"
>
<Bot size={12} style={{ verticalAlign: "middle" }} />
{selectedAgentLabel ? ` ${selectedAgentLabel}` : " Assign agent"}
</button>
{showAgentPicker && (
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">Select agent</div>
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
{!agentsLoading && agents.map((a) => (
<div
key={a.id}
className={`dep-dropdown-item${selectedAgentId === a.id ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(a.id === selectedAgentId ? null : a.id);
setShowAgentPicker(false);
}}
data-testid={`agent-option-${a.id}`}
>
<Bot size={12} style={{ marginRight: 6 }} />
<span className="dep-dropdown-id">{a.role}</span>
<span className="dep-dropdown-title">{a.name}</span>
</div>
))}
{!agentsLoading && agents.length === 0 && (
<div className="dep-dropdown-empty">No agents available</div>
)}
{selectedAgentId && (
<div
className="dep-dropdown-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(null);
setShowAgentPicker(false);
}}
>
<span className="dep-dropdown-title">Clear selection</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={handleClose} onKeyDown={handleKeyDown} role="dialog" aria-modal="true">
<div
className="modal modal-lg new-task-modal"
onClick={(e) => e.stopPropagation()}
style={keyboardStyle}
>
<div className="modal-header">
<h3>New Task</h3>
<button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label="Close">
&times;
</button>
</div>
<div className="modal-body">
{!setupReadinessLoading && (
<SetupWarningBanner
hasAiProvider={hasAiProvider}
hasGithub={hasGithub}
/>
)}
<TaskForm
mode="create"
description={description}
onDescriptionChange={setDescription}
dependencies={dependencies}
onDependenciesChange={setDependencies}
executorModel={executorModel}
onExecutorModelChange={setExecutorModel}
validatorModel={validatorModel}
onValidatorModelChange={setValidatorModel}
presetMode={presetMode}
onPresetModeChange={setPresetMode}
selectedPresetId={selectedPresetId}
onSelectedPresetIdChange={setSelectedPresetId}
selectedWorkflowSteps={selectedWorkflowSteps}
onWorkflowStepsChange={handleWorkflowStepsChange}
onDefaultOnApplied={handleDefaultOnApplied}
pendingImages={pendingImages}
onImagesChange={setPendingImages}
tasks={tasks}
projectId={projectId}
disabled={isSubmitting}
addToast={addToast}
isActive={isOpen}
onClose={handleClose}
planningModel={planningModel}
onPlanningModelChange={setPlanningModel}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
reviewLevel={reviewLevel}
onReviewLevelChange={setReviewLevel}
autoMerge={autoMerge}
onAutoMergeChange={setAutoMerge}
priority={priority}
onPriorityChange={setPriority}
branch={branch}
onBranchChange={setBranch}
branchMode={branchMode}
onBranchModeChange={setBranchMode}
baseBranch={baseBranch}
onBaseBranchChange={setBaseBranch}
nodeId={nodeId}
onNodeIdChange={setNodeId}
nodeOptions={nodes}
githubTrackingEnabled={githubTrackingEnabled}
onGithubTrackingEnabledChange={setGithubTrackingEnabled}
githubRepoOverride={githubRepoOverride}
onGithubRepoOverrideChange={setGithubRepoOverride}
renderBelowPrimary={quickFields}
hideDependencies={true}
autoExpandMoreOptionsOnSelection={false}
/>
</div>
{hasInvalidBranchSelection && (
<div className="form-error new-task-branch-error">Branch name is required for this branch strategy.</div>
)}
<div className="modal-actions">
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
Cancel
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleSubmit}
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
>
{isSubmitting ? "Creating..." : "Create Task"}
</button>
</div>
</div>
</div>
);
}