Files
fusion/packages/dashboard/app/hooks/useModalManager.ts
gsxdsm eb425d17d9 FN-5825: add dedicated grouped-task modal popup
Introduce a dedicated modal flow for grouped shared-branch tasks in the dashboard.

- Add GroupTaskModal component and styles for grouped task details and actions.
- Wire grouped-task modal state through App, modal manager hooks, and modal container components.
- Update board/column/task-card interactions to open grouped tasks in the new modal.
- Adjust subtask breakdown behavior and add focused tests for grouped task modal/task-card flows.
- Document the grouped-task modal behavior and add a changeset for @runfusion/fusion.

Files changed:
 .changeset/fn-5825-group-task-modal.md             |   5 +
 docs/dashboard-guide.md                            |   5 +-
 packages/dashboard/app/App.tsx                     |   6 +
 packages/dashboard/app/components/AppModals.tsx    |  24 ++++
 packages/dashboard/app/components/Board.tsx        |   4 +-
 packages/dashboard/app/components/Column.tsx       |   4 +-
 .../dashboard/app/components/GroupTaskModal.css    |  79 ++++++++++
 .../dashboard/app/components/GroupTaskModal.tsx    | 159 +++++++++++++++++++++
 .../app/components/SubtaskBreakdownModal.tsx       |  16 ++-
 packages/dashboard/app/components/TaskCard.tsx     |   8 ++
 .../components/__tests__/GroupTaskModal.test.tsx   | 105 ++++++++++++++
 .../app/components/__tests__/TaskCard.test.tsx     |  18 +++
 packages/dashboard/app/hooks/useModalManager.ts    |  16 +++
 packages/dashboard/vitest.config.ts                |   2 +-
 14 files changed, 445 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-5825

Fusion-Task-Lineage: d9317a58-05ce-4437-8311-b7e2a186537b
2026-06-01 05:15:14 -07:00

467 lines
15 KiB
TypeScript

import { useCallback, useState } from "react";
import type { Task, TaskDetail } from "@fusion/core";
import type { SectionId } from "../components/SettingsModal";
import type { ToastType } from "./useToast";
export type DetailTaskTab =
| "definition"
| "logs"
| "changes"
| "comments"
| "model"
| "workflow"
| "pr"
| "retries";
export type DetailTaskOrigin = "list-mobile";
interface UseModalManagerOptions {
projectId?: string;
planningSessions: Array<{ id: string }>;
}
/**
* State and handler contract for App-level modal/overlay orchestration.
*/
export interface ModalManager {
// State
newTaskModalOpen: boolean;
isPlanningOpen: boolean;
planningInitialPlan: string | null;
planningResumeSessionId: string | undefined;
isSubtaskOpen: boolean;
subtaskInitialDescription: string | null;
subtaskResumeSessionId: string | undefined;
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
detailTask: (Task | TaskDetail) | null;
detailTaskInitialTab: DetailTaskTab;
detailTaskOrigin: DetailTaskOrigin | null;
groupModalGroupId: string | null;
settingsOpen: boolean;
settingsInitialSection: SectionId | undefined;
schedulesOpen: boolean;
githubImportOpen: boolean;
usageOpen: boolean;
usageAnchorRect: DOMRect | null;
systemStatsOpen: boolean;
terminalOpen: boolean;
terminalInitialCommand: string | undefined;
filesOpen: boolean;
todosOpen: boolean;
fileBrowserWorkspace: string;
fileBrowserInitialFile: string | null;
activityLogOpen: boolean;
gitManagerOpen: boolean;
workflowStepsOpen: boolean;
agentsOpen: boolean;
scriptsOpen: boolean;
setupWizardOpen: boolean;
modelOnboardingOpen: boolean;
anyModalOpen: boolean;
// Handlers
openNewTask: () => void;
closeNewTask: () => void;
openPlanning: () => void;
openPlanningWithInitialPlan: (initialPlan: string) => void;
resumePlanning: () => void;
openPlanningWithSession: (sessionId: string) => void;
closePlanning: () => void;
openSubtaskBreakdown: (description: string) => void;
openSubtaskWithSession: (sessionId: string) => void;
closeSubtask: () => void;
openDetailTask: (
task: Task | TaskDetail,
initialTab?: DetailTaskTab,
options?: { origin?: DetailTaskOrigin },
) => void;
openDetailWithChangesTab: (task: Task | TaskDetail) => void;
updateDetailTask: (updated: Partial<TaskDetail>) => void;
closeDetailTask: () => void;
openGroupModal: (groupId: string) => void;
closeGroupModal: () => void;
openSettings: (section?: SectionId) => void;
closeSettings: () => void;
openSchedules: () => void;
closeSchedules: () => void;
openGitHubImport: () => void;
closeGitHubImport: () => void;
openUsage: (anchorRect?: DOMRect | null) => void;
closeUsage: () => void;
openSystemStats: () => void;
closeSystemStats: () => void;
toggleTerminal: () => void;
closeTerminal: () => void;
openFiles: (workspace?: string, initialFile?: string | null) => void;
closeFiles: () => void;
openTodos: () => void;
closeTodos: () => void;
setFileWorkspace: (workspace: string) => void;
openActivityLog: () => void;
closeActivityLog: () => void;
openGitManager: () => void;
closeGitManager: () => void;
openWorkflowSteps: () => void;
closeWorkflowSteps: () => void;
openAgents: () => void;
closeAgents: () => void;
openScripts: () => void;
closeScripts: () => void;
runScript: (name: string, command: string) => Promise<void>;
openSetupWizard: () => void;
closeSetupWizard: () => void;
openModelOnboarding: () => void;
closeModelOnboarding: () => void;
onPlanningTaskCreated: (task: Task, addToast: (message: string, type?: ToastType) => void) => void;
onPlanningTasksCreated: (tasks: Task[], addToast: (message: string, type?: ToastType) => void) => void;
onSubtaskTasksCreated: (tasks: Task[], addToast: (message: string, type?: ToastType) => void) => void;
}
/**
* Centralized modal manager for dashboard App-level UI state.
*
* Encapsulates all modal open/close booleans, related resume/initial payloads,
* and cross-modal transitions (for example, script runner -> terminal handoff).
*/
export function useModalManager(options: UseModalManagerOptions): ModalManager {
const { planningSessions } = options;
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
const [detailTask, setDetailTask] = useState<(Task | TaskDetail) | null>(null);
const [detailTaskInitialTab, setDetailTaskInitialTab] = useState<DetailTaskTab>("definition");
const [detailTaskOrigin, setDetailTaskOrigin] = useState<DetailTaskOrigin | null>(null);
const [groupModalGroupId, setGroupModalGroupId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [schedulesOpen, setSchedulesOpen] = useState(false);
const [githubImportOpen, setGitHubImportOpen] = useState(false);
const [usageOpen, setUsageOpen] = useState(false);
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
const [systemStatsOpen, setSystemStatsOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [filesOpen, setFilesOpen] = useState(false);
const [todosOpen, setTodosOpen] = useState(false);
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
const [fileBrowserInitialFile, setFileBrowserInitialFile] = useState<string | null>(null);
const [activityLogOpen, setActivityLogOpen] = useState(false);
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
const [modelOnboardingOpen, setModelOnboardingOpen] = useState(false);
const anyModalOpen = Boolean(
detailTask ||
groupModalGroupId ||
settingsOpen ||
newTaskModalOpen ||
isPlanningOpen ||
isSubtaskOpen ||
terminalOpen ||
filesOpen ||
todosOpen ||
activityLogOpen ||
gitManagerOpen ||
workflowStepsOpen ||
scriptsOpen ||
agentsOpen ||
usageOpen ||
systemStatsOpen ||
schedulesOpen ||
githubImportOpen ||
setupWizardOpen ||
modelOnboardingOpen,
);
const openNewTask = useCallback(() => setNewTaskModalOpen(true), []);
const closeNewTask = useCallback(() => setNewTaskModalOpen(false), []);
const openPlanning = useCallback(() => setIsPlanningOpen(true), []);
const openPlanningWithInitialPlan = useCallback((initialPlan: string) => {
setPlanningInitialPlan(initialPlan);
setIsPlanningOpen(true);
}, []);
const resumePlanning = useCallback(() => {
const session = planningSessions[0];
if (!session) return;
setPlanningResumeSessionId(session.id);
setIsPlanningOpen(true);
}, [planningSessions]);
const openPlanningWithSession = useCallback((sessionId: string) => {
setPlanningResumeSessionId(sessionId);
setIsPlanningOpen(true);
}, []);
const closePlanning = useCallback(() => {
setIsPlanningOpen(false);
setPlanningInitialPlan(null);
setPlanningResumeSessionId(undefined);
}, []);
const openSubtaskBreakdown = useCallback((description: string) => {
setSubtaskInitialDescription(description);
setIsSubtaskOpen(true);
}, []);
const openSubtaskWithSession = useCallback((sessionId: string) => {
setSubtaskResumeSessionId(sessionId);
setIsSubtaskOpen(true);
}, []);
const closeSubtask = useCallback(() => {
setIsSubtaskOpen(false);
setSubtaskInitialDescription(null);
setSubtaskResumeSessionId(undefined);
}, []);
const openDetailTask = useCallback((
task: Task | TaskDetail,
initialTab: DetailTaskTab = "definition",
options?: { origin?: DetailTaskOrigin },
) => {
setDetailTask(task);
setDetailTaskInitialTab(initialTab);
setDetailTaskOrigin(options?.origin ?? null);
}, []);
const openDetailWithChangesTab = useCallback((task: Task | TaskDetail) => {
setDetailTask(task);
setDetailTaskInitialTab("changes");
setDetailTaskOrigin(null);
}, []);
const updateDetailTask = useCallback((updated: Partial<TaskDetail>) => {
setDetailTask((prev) => {
if (!prev) return prev;
if (updated.id !== undefined && updated.id !== prev.id) {
return prev;
}
return { ...prev, ...updated };
});
}, []);
const closeDetailTask = useCallback(() => {
setDetailTask(null);
setDetailTaskOrigin(null);
}, []);
const openGroupModal = useCallback((groupId: string) => {
setGroupModalGroupId(groupId);
}, []);
const closeGroupModal = useCallback(() => {
setGroupModalGroupId(null);
}, []);
const openSettings = useCallback((section?: SectionId) => {
setSettingsInitialSection(section);
setSettingsOpen(true);
}, []);
const closeSettings = useCallback(() => {
setSettingsOpen(false);
setSettingsInitialSection(undefined);
}, []);
const openSchedules = useCallback(() => setSchedulesOpen(true), []);
const closeSchedules = useCallback(() => setSchedulesOpen(false), []);
const openGitHubImport = useCallback(() => setGitHubImportOpen(true), []);
const closeGitHubImport = useCallback(() => setGitHubImportOpen(false), []);
const openUsage = useCallback((anchorRect?: DOMRect | null) => {
setUsageAnchorRect(anchorRect ?? null);
setUsageOpen(true);
}, []);
const closeUsage = useCallback(() => {
setUsageOpen(false);
setUsageAnchorRect(null);
}, []);
const openSystemStats = useCallback(() => setSystemStatsOpen(true), []);
const closeSystemStats = useCallback(() => setSystemStatsOpen(false), []);
const toggleTerminal = useCallback(() => {
setTerminalOpen((prev) => !prev);
}, []);
const closeTerminal = useCallback(() => {
setTerminalOpen(false);
setTerminalInitialCommand(undefined);
}, []);
const openFiles = useCallback((workspace?: string, initialFile?: string | null) => {
if (typeof workspace === "string" && workspace) {
setFileBrowserWorkspace(workspace);
}
if (typeof initialFile === "string" || initialFile === null) {
setFileBrowserInitialFile(initialFile);
} else {
setFileBrowserInitialFile(null);
}
setFilesOpen(true);
}, []);
const closeFiles = useCallback(() => {
setFilesOpen(false);
setFileBrowserInitialFile(null);
}, []);
const openTodos = useCallback(() => setTodosOpen(true), []);
const closeTodos = useCallback(() => setTodosOpen(false), []);
const setFileWorkspace = useCallback((workspace: string) => {
if (typeof workspace === "string" && workspace) {
setFileBrowserWorkspace(workspace);
}
}, []);
const openActivityLog = useCallback(() => setActivityLogOpen(true), []);
const closeActivityLog = useCallback(() => setActivityLogOpen(false), []);
const openGitManager = useCallback(() => setGitManagerOpen(true), []);
const closeGitManager = useCallback(() => setGitManagerOpen(false), []);
const openWorkflowSteps = useCallback(() => setWorkflowStepsOpen(true), []);
const closeWorkflowSteps = useCallback(() => setWorkflowStepsOpen(false), []);
const openAgents = useCallback(() => setAgentsOpen(true), []);
const closeAgents = useCallback(() => setAgentsOpen(false), []);
const openScripts = useCallback(() => setScriptsOpen(true), []);
const closeScripts = useCallback(() => setScriptsOpen(false), []);
const runScript = useCallback(async (_name: string, command: string) => {
setScriptsOpen(false);
setTerminalInitialCommand(command);
setTerminalOpen(true);
}, []);
const openSetupWizard = useCallback(() => setSetupWizardOpen(true), []);
const closeSetupWizard = useCallback(() => setSetupWizardOpen(false), []);
const openModelOnboarding = useCallback(() => setModelOnboardingOpen(true), []);
const closeModelOnboarding = useCallback(() => setModelOnboardingOpen(false), []);
const onPlanningTaskCreated = useCallback((task: Task, addToast: (message: string, type?: ToastType) => void) => {
addToast(`Created ${task.id} from planning mode`, "success");
setIsPlanningOpen(false);
setPlanningInitialPlan(null);
}, []);
const onPlanningTasksCreated = useCallback((tasks: Task[], addToast: (message: string, type?: ToastType) => void) => {
const ids = tasks.map((task) => task.id).join(", ");
addToast(`Created ${ids} from planning mode`, "success");
setIsPlanningOpen(false);
setPlanningInitialPlan(null);
}, []);
const onSubtaskTasksCreated = useCallback((tasks: Task[], addToast: (message: string, type?: ToastType) => void) => {
const ids = tasks.map((task) => task.id).join(", ");
addToast(`Created ${ids} from subtask breakdown`, "success");
setIsSubtaskOpen(false);
setSubtaskInitialDescription(null);
}, []);
return {
newTaskModalOpen,
isPlanningOpen,
planningInitialPlan,
planningResumeSessionId,
isSubtaskOpen,
subtaskInitialDescription,
subtaskResumeSessionId,
detailTask,
detailTaskInitialTab,
detailTaskOrigin,
groupModalGroupId,
settingsOpen,
settingsInitialSection,
schedulesOpen,
githubImportOpen,
usageOpen,
usageAnchorRect,
systemStatsOpen,
terminalOpen,
terminalInitialCommand,
filesOpen,
todosOpen,
fileBrowserWorkspace,
fileBrowserInitialFile,
activityLogOpen,
gitManagerOpen,
workflowStepsOpen,
agentsOpen,
scriptsOpen,
setupWizardOpen,
modelOnboardingOpen,
anyModalOpen,
openNewTask,
closeNewTask,
openPlanning,
openPlanningWithInitialPlan,
resumePlanning,
openPlanningWithSession,
closePlanning,
openSubtaskBreakdown,
openSubtaskWithSession,
closeSubtask,
openDetailTask,
openDetailWithChangesTab,
updateDetailTask,
closeDetailTask,
openGroupModal,
closeGroupModal,
openSettings,
closeSettings,
openSchedules,
closeSchedules,
openGitHubImport,
closeGitHubImport,
openUsage,
closeUsage,
openSystemStats,
closeSystemStats,
toggleTerminal,
closeTerminal,
openFiles,
closeFiles,
openTodos,
closeTodos,
setFileWorkspace,
openActivityLog,
closeActivityLog,
openGitManager,
closeGitManager,
openWorkflowSteps,
closeWorkflowSteps,
openAgents,
closeAgents,
openScripts,
closeScripts,
runScript,
openSetupWizard,
closeSetupWizard,
openModelOnboarding,
closeModelOnboarding,
onPlanningTaskCreated,
onPlanningTasksCreated,
onSubtaskTasksCreated,
};
}