refactor(FN-1289): extract AppInner logic into hooks and modal component

- Extract view state, project actions, and task handler concerns from AppInner into dedicated hooks
- Add AppModals component to centralize modal rendering and related props wiring
- Inline thin aliases and complete AppInner cleanup after moving responsibilities out
- Add unit tests for useViewState, useProjectActions, and useTaskHandlers hooks
This commit is contained in:
gsxdsm
2026-04-08 11:39:59 -07:00
parent 4c198e3473
commit 13728878fa
8 changed files with 1164 additions and 574 deletions

View File

@@ -1,33 +1,12 @@
import { useState, useCallback, useEffect } from "react";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
import { updateProject, unregisterProject } from "./api";
import type { ProjectInfo } from "./api";
import { useState, useCallback } from "react";
import type { TaskDetail } from "@fusion/core";
import { Header, useViewportMode } from "./components/Header";
import { Board } from "./components/Board";
import { ListView } from "./components/ListView";
import { ProjectOverview } from "./components/ProjectOverview";
import { SetupWizardModal } from "./components/SetupWizardModal";
import { TaskDetailModal } from "./components/TaskDetailModal";
import { TerminalModal } from "./components/TerminalModal";
import { FileBrowserModal } from "./components/FileBrowserModal";
import { SettingsModal } from "./components/SettingsModal";
import { ModelOnboardingModal } from "./components/ModelOnboardingModal";
import { PlanningModeModal } from "./components/PlanningModeModal";
import { SubtaskBreakdownModal } from "./components/SubtaskBreakdownModal";
import { ToastContainer } from "./components/ToastContainer";
import { GitHubImportModal } from "./components/GitHubImportModal";
import { GitManagerModal } from "./components/GitManagerModal";
import { UsageIndicator } from "./components/UsageIndicator";
import { NewTaskModal } from "./components/NewTaskModal";
import { ScheduledTasksModal } from "./components/ScheduledTasksModal";
import { ActivityLogModal } from "./components/ActivityLogModal";
import { WorkflowStepManager } from "./components/WorkflowStepManager";
import { MissionManager } from "./components/MissionManager";
import { AgentListModal } from "./components/AgentListModal";
import { AgentsView } from "./components/AgentsView";
import { NodesView } from "./components/NodesView";
import { MailboxModal } from "./components/MailboxModal";
import { ScriptsModal } from "./components/ScriptsModal";
import { AppModals } from "./components/AppModals";
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { MobileNavBar } from "./components/MobileNavBar";
import { QuickChatFAB } from "./components/QuickChatFAB";
@@ -43,10 +22,9 @@ import { useAppSettings } from "./hooks/useAppSettings";
import { useDeepLink } from "./hooks/useDeepLink";
import { useFavorites } from "./hooks/useFavorites";
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
type ViewMode = "overview" | "project";
type TaskView = "board" | "list" | "agents";
import { useViewState } from "./hooks/useViewState";
import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers";
function AppInner() {
const { toasts, addToast, removeToast } = useToast();
@@ -68,109 +46,11 @@ function AppInner() {
// Background AI sessions
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
// View state
const [viewMode, setViewMode] = useState<ViewMode>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-view-mode");
if (saved === "overview" || saved === "project") return saved;
}
return "overview";
});
const [taskView, setTaskView] = useState<TaskView>(() => {
const saved = getScopedItem("kb-dashboard-task-view");
if (saved === "board" || saved === "list" || saved === "agents") return saved;
return "board";
});
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
// Modal state/handlers extracted to a dedicated manager hook.
const {
newTaskModalOpen,
isPlanningOpen,
planningInitialPlan,
planningResumeSessionId,
isSubtaskOpen,
subtaskInitialDescription,
subtaskResumeSessionId,
detailTask,
detailTaskInitialTab,
settingsOpen,
settingsInitialSection,
schedulesOpen,
githubImportOpen,
usageOpen,
terminalOpen,
terminalInitialCommand,
filesOpen,
fileBrowserWorkspace,
activityLogOpen,
mailboxOpen,
mailboxUnreadCount,
mailboxAgents,
gitManagerOpen,
workflowStepsOpen,
missionsOpen,
missionResumeSessionId,
missionTargetId,
agentsOpen,
scriptsOpen,
setupWizardOpen,
modelOnboardingOpen,
anyModalOpen,
openNewTask,
closeNewTask,
openPlanning,
openPlanningWithInitialPlan,
resumePlanning,
openPlanningWithSession,
closePlanning,
openSubtaskBreakdown,
openSubtaskWithSession,
closeSubtask,
openDetailTask,
openDetailWithChangesTab,
updateDetailTask,
closeDetailTask,
openSettings,
closeSettings,
openSchedules,
closeSchedules,
openGitHubImport,
closeGitHubImport,
openUsage,
closeUsage,
toggleTerminal,
closeTerminal,
openFiles,
closeFiles,
setFileWorkspace,
openActivityLog,
closeActivityLog,
openMailbox,
closeMailbox,
openGitManager,
closeGitManager,
openWorkflowSteps,
closeWorkflowSteps,
openMissions,
openMissionById,
openMissionWithSession,
closeMissions,
closeAgents,
openScripts,
closeScripts,
runScript,
openSetupWizard,
closeSetupWizard,
openModelOnboarding,
closeModelOnboarding,
onPlanningTaskCreated,
onPlanningTasksCreated,
onSubtaskTasksCreated,
} = useModalManager({
const modalManager = useModalManager({
projectId: currentProject?.id,
planningSessions: bgPlanningSessions,
});
@@ -200,85 +80,50 @@ function AppInner() {
toggleFavoriteModel,
} = useFavorites();
// Persist view mode
useEffect(() => {
localStorage.setItem("kb-dashboard-view-mode", viewMode);
}, [viewMode]);
// Load project-scoped task view when project context changes
useEffect(() => {
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
if (saved === "board" || saved === "list" || saved === "agents") {
setTaskView(saved);
return;
}
setTaskView("board");
}, [currentProject?.id]);
// Persist task view
useEffect(() => {
setScopedItem("kb-dashboard-task-view", taskView, currentProject?.id);
}, [currentProject?.id, taskView]);
// Sync view mode when current project is restored from localStorage
useEffect(() => {
// Wait for both loading states to complete before syncing
if (projectsLoading || currentProjectLoading) return;
// If we have a restored current project but viewMode is overview, sync to project view
if (currentProject && viewMode === "overview") {
setViewMode("project");
}
}, [projectsLoading, currentProjectLoading, currentProject, viewMode]);
// Auto-open setup wizard on first run (no projects)
useEffect(() => {
// Wait for both loading states to complete before making decision
if (projectsLoading || currentProjectLoading) return;
// Don't open if wizard is already open
if (setupWizardOpen) return;
// Don't open if we have projects OR a saved current project
if (projects.length > 0 || currentProject) return;
// Only open when truly no projects exist and no project is being restored
const timer = setTimeout(() => {
openSetupWizard();
}, 500);
return () => clearTimeout(timer);
}, [projectsLoading, projects.length, currentProjectLoading, currentProject, setupWizardOpen, openSetupWizard]);
// Theme toggle handler: cycles Dark → Light → System → Dark
const handleToggleTheme = useCallback(() => {
const cycle: ThemeMode[] = ["dark", "light", "system"];
const currentIndex = cycle.indexOf(themeMode);
const nextMode = cycle[(currentIndex + 1) % cycle.length];
setThemeMode(nextMode);
}, [themeMode, setThemeMode]);
const { viewMode, setViewMode, taskView, handleChangeTaskView, handleToggleTheme } = useViewState({
projectsLoading,
currentProjectLoading,
currentProject,
projectsLength: projects.length,
setupWizardOpen: modalManager.setupWizardOpen,
openSetupWizard: modalManager.openSetupWizard,
themeMode,
setThemeMode,
});
// Auth and onboarding bootstrap logic extracted to a dedicated hook.
useAuthOnboarding({
projectId: currentProject?.id,
openModelOnboarding,
openSettings,
openModelOnboarding: modalManager.openModelOnboarding,
openSettings: modalManager.openSettings,
});
const handleToggleFavorite = useCallback(async (provider: string) => {
try {
await toggleFavoriteProvider(provider);
} catch {
addToast("Failed to update favorites", "error");
}
}, [toggleFavoriteProvider, addToast]);
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
try {
await toggleFavoriteModel(modelId);
} catch {
addToast("Failed to update model favorites", "error");
}
}, [toggleFavoriteModel, addToast]);
const {
handleSelectProject,
handleViewAllProjects,
handleOpenSettings,
handleAddProject,
handleSetupComplete,
handleModelOnboardingComplete,
handlePauseProject,
handleResumeProject,
handleRemoveProject,
handleToggleFavorite,
handleToggleModelFavorite,
} = useProjectActions({
setCurrentProject,
clearCurrentProject,
setViewMode,
currentProject,
refreshProjects,
toggleFavoriteProvider,
toggleFavoriteModel,
addToast,
openSettings: modalManager.openSettings,
openSetupWizard: modalManager.openSetupWizard,
closeSetupWizard: modalManager.closeSetupWizard,
closeModelOnboarding: modalManager.closeModelOnboarding,
});
const { handleDetailClose } = useDeepLink({
projectId: currentProject?.id,
@@ -287,186 +132,37 @@ function AppInner() {
currentProject,
setCurrentProject,
addToast,
openTaskDetail: openDetailTask,
closeTaskDetail: closeDetailTask,
openTaskDetail: modalManager.openDetailTask,
closeTaskDetail: modalManager.closeDetailTask,
});
// View change handlers
const handleChangeTaskView = useCallback((newView: TaskView) => {
setTaskView(newView);
}, []);
// Project selection handlers
const handleSelectProject = useCallback((project: ProjectInfo) => {
setCurrentProject(project);
setViewMode("project");
}, [setCurrentProject]);
const handleViewAllProjects = useCallback(() => {
clearCurrentProject();
setViewMode("overview");
}, [clearCurrentProject]);
const handleOpenSettings = useCallback(() => {
openSettings();
}, [openSettings]);
const handleAddProject = useCallback(() => {
openSetupWizard();
}, [openSetupWizard]);
const handleSetupComplete = useCallback((project: ProjectInfo) => {
closeSetupWizard();
setCurrentProject(project);
setViewMode("project");
addToast(`Project ${project.name} registered successfully`, "success");
refreshProjects();
}, [closeSetupWizard, setCurrentProject, addToast, refreshProjects]);
const handleModelOnboardingComplete = useCallback(() => {
closeModelOnboarding();
}, [closeModelOnboarding]);
const handlePauseProject = useCallback(async (project: ProjectInfo) => {
try {
await updateProject(project.id, { status: "paused" });
addToast(`Project ${project.name} paused`, "success");
refreshProjects();
} catch {
addToast(`Failed to pause project ${project.name}`, "error");
}
}, [addToast, refreshProjects]);
const handleResumeProject = useCallback(async (project: ProjectInfo) => {
try {
await updateProject(project.id, { status: "active" });
addToast(`Project ${project.name} resumed`, "success");
refreshProjects();
} catch {
addToast(`Failed to resume project ${project.name}`, "error");
}
}, [addToast, refreshProjects]);
const handleRemoveProject = useCallback(async (project: ProjectInfo) => {
try {
await unregisterProject(project.id);
addToast(`Project ${project.name} removed`, "success");
// If we removed the current project, go back to overview
if (currentProject?.id === project.id) {
clearCurrentProject();
setViewMode("overview");
}
refreshProjects();
} catch {
addToast(`Failed to remove project ${project.name}`, "error");
}
}, [unregisterProject, currentProject, clearCurrentProject, addToast, refreshProjects]);
// Task handlers
const handleNewTaskOpen = openNewTask;
const handleNewTaskClose = closeNewTask;
const handleBoardQuickCreate = useCallback(
async (input: TaskCreateInput): Promise<void> => {
await createTask({ ...input, column: "triage" });
},
[createTask],
);
const handleModalCreate = useCallback(
async (input: TaskCreateInput): Promise<Task> => {
const task = await createTask({ ...input, column: "triage" });
return task;
},
[createTask],
);
// Planning mode handlers
const handlePlanningOpen = openPlanning;
const handleResumePlanning = resumePlanning;
const handlePlanningClose = closePlanning;
const handlePlanningTaskCreated = useCallback((task: Task) => {
onPlanningTaskCreated(task, addToast);
}, [onPlanningTaskCreated, addToast]);
const handlePlanningTasksCreated = useCallback((createdTasks: Task[]) => {
onPlanningTasksCreated(createdTasks, addToast);
}, [onPlanningTasksCreated, addToast]);
// Handle planning mode from new task dialog
const handleNewTaskPlanningMode = openPlanningWithInitialPlan;
// Handle subtask breakdown from inline/quick create
const handleSubtaskBreakdown = openSubtaskBreakdown;
const handleSubtaskClose = closeSubtask;
const handleSubtaskTasksCreated = useCallback((createdTasks: Task[]) => {
onSubtaskTasksCreated(createdTasks, addToast);
}, [onSubtaskTasksCreated, addToast]);
// Usage indicator handlers
const handleOpenUsage = openUsage;
const handleCloseUsage = closeUsage;
// Schedules modal handlers
const handleOpenSchedules = openSchedules;
const handleCloseSchedules = closeSchedules;
const handleToggleAutoMerge = toggleAutoMerge;
const handleToggleGlobalPause = toggleGlobalPause;
const handleToggleEnginePause = toggleEnginePause;
const handleDetailOpen = openDetailTask;
const {
handleBoardQuickCreate,
handleModalCreate,
handlePlanningTaskCreated,
handlePlanningTasksCreated,
handleSubtaskTasksCreated,
handleGitHubImport,
} = useTaskHandlers({
createTask,
onPlanningTaskCreated: modalManager.onPlanningTaskCreated,
onPlanningTasksCreated: modalManager.onPlanningTasksCreated,
onSubtaskTasksCreated: modalManager.onSubtaskTasksCreated,
addToast,
});
const handleOpenDetailWithTab = useCallback((task: TaskDetail, initialTab: "changes") => {
if (initialTab === "changes") {
openDetailWithChangesTab(task);
modalManager.openDetailWithChangesTab(task);
return;
}
openDetailTask(task, initialTab);
}, [openDetailTask, openDetailWithChangesTab]);
modalManager.openDetailTask(task, initialTab);
}, [modalManager]);
const handleGitHubImport = useCallback((task: Task) => {
addToast(`Imported ${task.id} from GitHub`, "success");
}, [addToast]);
const handleToggleTerminal = toggleTerminal;
const handleOpenFiles = openFiles;
const handleWorkspaceChange = setFileWorkspace;
// Activity log handlers
const handleOpenActivityLog = openActivityLog;
const handleCloseActivityLog = closeActivityLog;
const handleOpenMailbox = openMailbox;
const handleCloseMailbox = closeMailbox;
// Mission link handler from TaskCard
const handleOpenMission = openMissionById;
// Git Manager handlers
const handleOpenGitManager = openGitManager;
const handleCloseGitManager = closeGitManager;
// Agent handlers
const handleCloseAgents = closeAgents;
// Node management view handlers
const handleOpenNodes = useCallback(() => {
setNodesOpen((prev) => !prev);
}, []);
const handleCloseNodes = useCallback(() => {
setNodesOpen(false);
}, []);
// Scripts handlers
const handleOpenScripts = openScripts;
const handleCloseScripts = closeScripts;
const handleRunScript = runScript;
// Terminal close handler
const handleTerminalClose = closeTerminal;
// Render main content based on view mode
const renderMainContent = () => {
@@ -474,7 +170,7 @@ function AppInner() {
return (
<div className="nodes-management-overlay">
<div className="nodes-management-overlay__header">
<button className="btn btn-sm" onClick={handleCloseNodes}>Close Nodes</button>
<button className="btn btn-sm" onClick={() => setNodesOpen(false)}>Close Nodes</button>
</div>
<NodesView addToast={addToast} />
</div>
@@ -508,14 +204,14 @@ function AppInner() {
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
onOpenDetail={modalManager.openDetailTask}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}
onNewTask={handleNewTaskOpen}
onPlanningMode={handleNewTaskPlanningMode}
onSubtaskBreakdown={handleSubtaskBreakdown}
onNewTask={modalManager.openNewTask}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
autoMerge={autoMerge}
onToggleAutoMerge={handleToggleAutoMerge}
onToggleAutoMerge={toggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onArchiveTask={archiveTask}
@@ -529,7 +225,7 @@ function AppInner() {
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={handleOpenMission}
onOpenMission={modalManager.openMissionById}
/>
);
}
@@ -540,13 +236,13 @@ function AppInner() {
tasks={tasks}
projectId={currentProject?.id}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
onOpenDetail={modalManager.openDetailTask}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={handleNewTaskOpen}
onNewTask={modalManager.openNewTask}
onQuickCreate={handleBoardQuickCreate}
onPlanningMode={handleNewTaskPlanningMode}
onSubtaskBreakdown={handleSubtaskBreakdown}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
availableModels={availableModels}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
@@ -562,28 +258,28 @@ function AppInner() {
<Header
isElectron={isElectron}
onOpenSettings={handleOpenSettings}
onOpenGitHubImport={openGitHubImport}
onOpenPlanning={handlePlanningOpen}
onResumePlanning={handleResumePlanning}
onOpenGitHubImport={modalManager.openGitHubImport}
onOpenPlanning={modalManager.openPlanning}
onResumePlanning={modalManager.resumePlanning}
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={handleOpenUsage}
onOpenActivityLog={handleOpenActivityLog}
onOpenMailbox={handleOpenMailbox}
mailboxUnreadCount={mailboxUnreadCount}
onOpenSchedules={handleOpenSchedules}
onOpenGitManager={handleOpenGitManager}
onOpenUsage={modalManager.openUsage}
onOpenActivityLog={modalManager.openActivityLog}
onOpenMailbox={modalManager.openMailbox}
mailboxUnreadCount={modalManager.mailboxUnreadCount}
onOpenSchedules={modalManager.openSchedules}
onOpenGitManager={modalManager.openGitManager}
onOpenNodes={handleOpenNodes}
onOpenWorkflowSteps={openWorkflowSteps}
onOpenMissions={viewMode === "project" && currentProject ? openMissions : undefined}
onOpenScripts={handleOpenScripts}
onRunScript={handleRunScript}
onToggleTerminal={handleToggleTerminal}
onOpenFiles={handleOpenFiles}
filesOpen={filesOpen}
onOpenWorkflowSteps={modalManager.openWorkflowSteps}
onOpenMissions={viewMode === "project" && currentProject ? modalManager.openMissions : undefined}
onOpenScripts={modalManager.openScripts}
onRunScript={modalManager.runScript}
onToggleTerminal={modalManager.toggleTerminal}
onOpenFiles={modalManager.openFiles}
filesOpen={modalManager.filesOpen}
globalPaused={globalPaused}
enginePaused={enginePaused}
onToggleGlobalPause={handleToggleGlobalPause}
onToggleEnginePause={handleToggleEnginePause}
onToggleGlobalPause={toggleGlobalPause}
onToggleEnginePause={toggleEnginePause}
view={taskView}
onChangeView={viewMode === "project" && currentProject ? handleChangeTaskView : undefined}
searchQuery={searchQuery}
@@ -610,11 +306,11 @@ function AppInner() {
backgroundNeedsInput={bgNeedsInput}
onOpenBackgroundSession={(session) => {
if (session.type === "planning") {
openPlanningWithSession(session.id);
modalManager.openPlanningWithSession(session.id);
} else if (session.type === "subtask") {
openSubtaskWithSession(session.id);
modalManager.openSubtaskWithSession(session.id);
} else if (session.type === "mission_interview") {
openMissionWithSession(session.id);
modalManager.openMissionWithSession(session.id);
}
}}
onDismissBackgroundSession={bgDismiss}
@@ -624,192 +320,50 @@ function AppInner() {
view={taskView}
onChangeView={viewMode === "project" && currentProject ? handleChangeTaskView : () => {}}
footerVisible={viewMode === "project" && !!currentProject}
modalOpen={anyModalOpen}
modalOpen={modalManager.anyModalOpen}
onOpenSettings={handleOpenSettings}
onOpenActivityLog={handleOpenActivityLog}
onOpenMailbox={handleOpenMailbox}
mailboxUnreadCount={mailboxUnreadCount}
onOpenGitManager={handleOpenGitManager}
onOpenWorkflowSteps={openWorkflowSteps}
onOpenMissions={viewMode === "project" && currentProject ? openMissions : undefined}
onOpenSchedules={handleOpenSchedules}
onOpenScripts={handleOpenScripts}
onToggleTerminal={handleToggleTerminal}
onOpenFiles={handleOpenFiles}
onOpenGitHubImport={openGitHubImport}
onOpenPlanning={handlePlanningOpen}
onResumePlanning={handleResumePlanning}
onOpenActivityLog={modalManager.openActivityLog}
onOpenMailbox={modalManager.openMailbox}
mailboxUnreadCount={modalManager.mailboxUnreadCount}
onOpenGitManager={modalManager.openGitManager}
onOpenWorkflowSteps={modalManager.openWorkflowSteps}
onOpenMissions={viewMode === "project" && currentProject ? modalManager.openMissions : undefined}
onOpenSchedules={modalManager.openSchedules}
onOpenScripts={modalManager.openScripts}
onToggleTerminal={modalManager.toggleTerminal}
onOpenFiles={modalManager.openFiles}
onOpenGitHubImport={modalManager.openGitHubImport}
onOpenPlanning={modalManager.openPlanning}
onResumePlanning={modalManager.resumePlanning}
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={handleOpenUsage}
onRunScript={handleRunScript}
onOpenUsage={modalManager.openUsage}
onRunScript={modalManager.runScript}
projectId={currentProject?.id}
/>
{viewMode === "project" && currentProject && (
<QuickChatFAB projectId={currentProject.id} addToast={addToast} />
)}
{detailTask && (
<TaskDetailModal
task={detailTask}
projectId={currentProject?.id}
tasks={tasks}
onClose={handleDetailClose}
onOpenDetail={handleDetailOpen}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
onMergeTask={mergeTask}
onRetryTask={retryTask}
onDuplicateTask={duplicateTask}
onTaskUpdated={updateDetailTask}
addToast={addToast}
githubTokenConfigured={githubTokenConfigured}
initialTab={detailTaskInitialTab}
/>
)}
{settingsOpen && (
<SettingsModal
onClose={closeSettings}
addToast={addToast}
initialSection={settingsInitialSection}
projectId={currentProject?.id}
themeMode={themeMode}
colorTheme={colorTheme}
onThemeModeChange={setThemeMode}
onColorThemeChange={setColorTheme}
/>
)}
<GitHubImportModal
isOpen={githubImportOpen}
onClose={closeGitHubImport}
onImport={handleGitHubImport}
<AppModals
projectId={currentProject?.id}
tasks={tasks}
/>
<PlanningModeModal
isOpen={isPlanningOpen}
onClose={handlePlanningClose}
onTaskCreated={handlePlanningTaskCreated}
onTasksCreated={handlePlanningTasksCreated}
tasks={tasks}
initialPlan={planningInitialPlan ?? undefined}
projectId={currentProject?.id}
resumeSessionId={planningResumeSessionId}
/>
<SubtaskBreakdownModal
isOpen={isSubtaskOpen}
onClose={handleSubtaskClose}
initialDescription={subtaskInitialDescription ?? ""}
onTasksCreated={handleSubtaskTasksCreated}
projectId={currentProject?.id}
resumeSessionId={subtaskResumeSessionId}
/>
<TerminalModal
isOpen={terminalOpen}
onClose={handleTerminalClose}
initialCommand={terminalInitialCommand}
projectId={currentProject?.id}
/>
<ScriptsModal
isOpen={scriptsOpen}
onClose={handleCloseScripts}
addToast={addToast}
onRunScript={handleRunScript}
projectId={currentProject?.id}
/>
{filesOpen && (
<FileBrowserModal
initialWorkspace={fileBrowserWorkspace}
isOpen={true}
onClose={closeFiles}
onWorkspaceChange={handleWorkspaceChange}
/>
)}
<UsageIndicator
isOpen={usageOpen}
onClose={handleCloseUsage}
projectId={currentProject?.id}
/>
{schedulesOpen && (
<ScheduledTasksModal
onClose={handleCloseSchedules}
addToast={addToast}
/>
)}
<NewTaskModal
isOpen={newTaskModalOpen}
onClose={handleNewTaskClose}
tasks={tasks}
onCreateTask={handleModalCreate}
addToast={addToast}
projectId={currentProject?.id}
onPlanningMode={handleNewTaskPlanningMode}
onSubtaskBreakdown={handleSubtaskBreakdown}
/>
<ActivityLogModal
isOpen={activityLogOpen}
onClose={handleCloseActivityLog}
tasks={tasks}
projectId={currentProject?.id}
projects={projects}
currentProject={currentProject}
onOpenTaskDetail={(taskId) => {
const task = tasks.find((t) => t.id === taskId);
if (task) {
handleDetailOpen(task as TaskDetail);
}
addToast={addToast}
toasts={toasts}
removeToast={removeToast}
modalManager={modalManager}
projectActions={{ handleSetupComplete, handleModelOnboardingComplete }}
taskHandlers={{
handleModalCreate,
handlePlanningTaskCreated,
handlePlanningTasksCreated,
handleSubtaskTasksCreated,
handleGitHubImport,
}}
taskOperations={{ moveTask, deleteTask, mergeTask, retryTask, duplicateTask }}
deepLink={{ handleDetailClose }}
settings={{ githubTokenConfigured, themeMode, colorTheme, setThemeMode, setColorTheme }}
/>
<GitManagerModal
isOpen={gitManagerOpen}
onClose={handleCloseGitManager}
tasks={tasks}
addToast={addToast}
/>
<WorkflowStepManager
isOpen={workflowStepsOpen}
onClose={closeWorkflowSteps}
addToast={addToast}
projectId={currentProject?.id}
/>
<MissionManager
isOpen={missionsOpen}
onClose={closeMissions}
addToast={addToast}
projectId={currentProject?.id}
resumeSessionId={missionResumeSessionId}
targetMissionId={missionTargetId}
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
onSelectTask={(taskId) => {
const task = tasks.find((t) => t.id === taskId);
if (task) {
openDetailTask(task as TaskDetail);
}
}}
/>
<AgentListModal
isOpen={agentsOpen}
onClose={handleCloseAgents}
addToast={addToast}
projectId={currentProject?.id}
/>
<MailboxModal
isOpen={mailboxOpen}
onClose={handleCloseMailbox}
projectId={currentProject?.id}
addToast={addToast}
agents={mailboxAgents}
/>
{setupWizardOpen && (
<SetupWizardModal
onProjectRegistered={handleSetupComplete}
onClose={closeSetupWizard}
/>
)}
{modelOnboardingOpen && (
<ModelOnboardingModal
onComplete={handleModelOnboardingComplete}
addToast={addToast}
/>
)}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);
}

View File

@@ -0,0 +1,259 @@
import type { ProjectInfo } from "../api";
import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, TaskDetail, ThemeMode } from "@fusion/core";
import type { UseProjectActionsResult } from "../hooks/useProjectActions";
import type { ModalManager } from "../hooks/useModalManager";
import type { UseTaskHandlersResult } from "../hooks/useTaskHandlers";
import type { Toast, ToastType } from "../hooks/useToast";
import { TaskDetailModal } from "./TaskDetailModal";
import { SettingsModal } from "./SettingsModal";
import { GitHubImportModal } from "./GitHubImportModal";
import { PlanningModeModal } from "./PlanningModeModal";
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
import { TerminalModal } from "./TerminalModal";
import { ScriptsModal } from "./ScriptsModal";
import { FileBrowserModal } from "./FileBrowserModal";
import { UsageIndicator } from "./UsageIndicator";
import { ScheduledTasksModal } from "./ScheduledTasksModal";
import { NewTaskModal } from "./NewTaskModal";
import { ActivityLogModal } from "./ActivityLogModal";
import { GitManagerModal } from "./GitManagerModal";
import { WorkflowStepManager } from "./WorkflowStepManager";
import { MissionManager } from "./MissionManager";
import { AgentListModal } from "./AgentListModal";
import { MailboxModal } from "./MailboxModal";
import { SetupWizardModal } from "./SetupWizardModal";
import { ModelOnboardingModal } from "./ModelOnboardingModal";
import { ToastContainer } from "./ToastContainer";
interface AppModalsProps {
projectId?: string;
tasks: Task[];
projects: ProjectInfo[];
currentProject: ProjectInfo | null;
addToast: (message: string, type?: ToastType) => void;
toasts: Toast[];
removeToast: (id: number) => void;
modalManager: ModalManager;
projectActions: Pick<UseProjectActionsResult, "handleSetupComplete" | "handleModelOnboardingComplete">;
taskHandlers: Pick<UseTaskHandlersResult, "handleModalCreate" | "handlePlanningTaskCreated" | "handlePlanningTasksCreated" | "handleSubtaskTasksCreated" | "handleGitHubImport">;
taskOperations: {
moveTask: (taskId: string, column: Column, position?: number) => Promise<Task>;
deleteTask: (taskId: string) => Promise<Task>;
mergeTask: (taskId: string) => Promise<MergeResult>;
retryTask: (taskId: string) => Promise<Task>;
duplicateTask: (taskId: string) => Promise<Task>;
};
deepLink: {
handleDetailClose: () => void;
};
settings: {
githubTokenConfigured: boolean;
themeMode: ThemeMode;
colorTheme: ColorTheme;
setThemeMode: (mode: ThemeMode) => void;
setColorTheme: (theme: ColorTheme) => void;
};
}
export function AppModals({
projectId,
tasks,
projects,
currentProject,
addToast,
toasts,
removeToast,
modalManager,
projectActions,
taskHandlers,
taskOperations,
deepLink,
settings,
}: AppModalsProps) {
return (
<>
{modalManager.detailTask && (
<TaskDetailModal
task={modalManager.detailTask}
projectId={projectId}
tasks={tasks}
onClose={deepLink.handleDetailClose}
onOpenDetail={modalManager.openDetailTask}
onMoveTask={taskOperations.moveTask}
onDeleteTask={taskOperations.deleteTask}
onMergeTask={taskOperations.mergeTask}
onRetryTask={taskOperations.retryTask}
onDuplicateTask={taskOperations.duplicateTask}
onTaskUpdated={modalManager.updateDetailTask}
addToast={addToast}
githubTokenConfigured={settings.githubTokenConfigured}
initialTab={modalManager.detailTaskInitialTab}
/>
)}
{modalManager.settingsOpen && (
<SettingsModal
onClose={modalManager.closeSettings}
addToast={addToast}
initialSection={modalManager.settingsInitialSection}
projectId={projectId}
themeMode={settings.themeMode}
colorTheme={settings.colorTheme}
onThemeModeChange={settings.setThemeMode}
onColorThemeChange={settings.setColorTheme}
/>
)}
<GitHubImportModal
isOpen={modalManager.githubImportOpen}
onClose={modalManager.closeGitHubImport}
onImport={taskHandlers.handleGitHubImport}
tasks={tasks}
/>
<PlanningModeModal
isOpen={modalManager.isPlanningOpen}
onClose={modalManager.closePlanning}
onTaskCreated={taskHandlers.handlePlanningTaskCreated}
onTasksCreated={taskHandlers.handlePlanningTasksCreated}
tasks={tasks}
initialPlan={modalManager.planningInitialPlan ?? undefined}
projectId={projectId}
resumeSessionId={modalManager.planningResumeSessionId}
/>
<SubtaskBreakdownModal
isOpen={modalManager.isSubtaskOpen}
onClose={modalManager.closeSubtask}
initialDescription={modalManager.subtaskInitialDescription ?? ""}
onTasksCreated={taskHandlers.handleSubtaskTasksCreated}
projectId={projectId}
resumeSessionId={modalManager.subtaskResumeSessionId}
/>
<TerminalModal
isOpen={modalManager.terminalOpen}
onClose={modalManager.closeTerminal}
initialCommand={modalManager.terminalInitialCommand}
projectId={projectId}
/>
<ScriptsModal
isOpen={modalManager.scriptsOpen}
onClose={modalManager.closeScripts}
addToast={addToast}
onRunScript={modalManager.runScript}
projectId={projectId}
/>
{modalManager.filesOpen && (
<FileBrowserModal
initialWorkspace={modalManager.fileBrowserWorkspace}
isOpen={true}
onClose={modalManager.closeFiles}
onWorkspaceChange={modalManager.setFileWorkspace}
/>
)}
<UsageIndicator
isOpen={modalManager.usageOpen}
onClose={modalManager.closeUsage}
projectId={projectId}
/>
{modalManager.schedulesOpen && (
<ScheduledTasksModal
onClose={modalManager.closeSchedules}
addToast={addToast}
/>
)}
<NewTaskModal
isOpen={modalManager.newTaskModalOpen}
onClose={modalManager.closeNewTask}
tasks={tasks}
onCreateTask={taskHandlers.handleModalCreate}
addToast={addToast}
projectId={projectId}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
/>
<ActivityLogModal
isOpen={modalManager.activityLogOpen}
onClose={modalManager.closeActivityLog}
tasks={tasks}
projectId={projectId}
projects={projects}
currentProject={currentProject}
onOpenTaskDetail={(taskId) => {
const task = tasks.find((candidate) => candidate.id === taskId);
if (task) {
modalManager.openDetailTask(task as TaskDetail);
}
}}
/>
<GitManagerModal
isOpen={modalManager.gitManagerOpen}
onClose={modalManager.closeGitManager}
tasks={tasks}
addToast={addToast}
/>
<WorkflowStepManager
isOpen={modalManager.workflowStepsOpen}
onClose={modalManager.closeWorkflowSteps}
addToast={addToast}
projectId={projectId}
/>
<MissionManager
isOpen={modalManager.missionsOpen}
onClose={modalManager.closeMissions}
addToast={addToast}
projectId={projectId}
resumeSessionId={modalManager.missionResumeSessionId}
targetMissionId={modalManager.missionTargetId}
availableTasks={tasks.map((task) => ({ id: task.id, title: task.title }))}
onSelectTask={(taskId) => {
const task = tasks.find((candidate) => candidate.id === taskId);
if (task) {
modalManager.openDetailTask(task as TaskDetail);
}
}}
/>
<AgentListModal
isOpen={modalManager.agentsOpen}
onClose={modalManager.closeAgents}
addToast={addToast}
projectId={projectId}
/>
<MailboxModal
isOpen={modalManager.mailboxOpen}
onClose={modalManager.closeMailbox}
projectId={projectId}
addToast={addToast}
agents={modalManager.mailboxAgents}
/>
{modalManager.setupWizardOpen && (
<SetupWizardModal
onProjectRegistered={projectActions.handleSetupComplete}
onClose={modalManager.closeSetupWizard}
/>
)}
{modalManager.modelOnboardingOpen && (
<ModelOnboardingModal
onComplete={projectActions.handleModelOnboardingComplete}
addToast={addToast}
/>
)}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);
}

View File

@@ -0,0 +1,169 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProjectActions } from "../useProjectActions";
import * as api from "../../api";
import type { ProjectInfo } from "../../api";
vi.mock("../../api", () => ({
updateProject: vi.fn(),
unregisterProject: vi.fn(),
}));
const mockUpdateProject = vi.mocked(api.updateProject);
const mockUnregisterProject = vi.mocked(api.unregisterProject);
const PROJECT: ProjectInfo = {
id: "proj_123",
name: "Demo",
path: "/demo",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
function createOptions(overrides: Partial<Parameters<typeof useProjectActions>[0]> = {}): Parameters<typeof useProjectActions>[0] {
return {
setCurrentProject: vi.fn(),
clearCurrentProject: vi.fn(),
setViewMode: vi.fn(),
currentProject: PROJECT,
refreshProjects: vi.fn().mockResolvedValue(undefined),
toggleFavoriteProvider: vi.fn().mockResolvedValue(undefined),
toggleFavoriteModel: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
openSettings: vi.fn(),
openSetupWizard: vi.fn(),
closeSetupWizard: vi.fn(),
closeModelOnboarding: vi.fn(),
...overrides,
};
}
describe("useProjectActions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdateProject.mockResolvedValue(PROJECT);
mockUnregisterProject.mockResolvedValue(undefined);
});
it("handleSelectProject sets current project and view mode", () => {
const options = createOptions();
const { result } = renderHook(() => useProjectActions(options));
act(() => {
result.current.handleSelectProject(PROJECT);
});
expect(options.setCurrentProject).toHaveBeenCalledWith(PROJECT);
expect(options.setViewMode).toHaveBeenCalledWith("project");
});
it("handleViewAllProjects clears current project and sets overview", () => {
const options = createOptions();
const { result } = renderHook(() => useProjectActions(options));
act(() => {
result.current.handleViewAllProjects();
});
expect(options.clearCurrentProject).toHaveBeenCalledTimes(1);
expect(options.setViewMode).toHaveBeenCalledWith("overview");
});
it("handleSetupComplete closes wizard, sets project, toasts, and refreshes", async () => {
const options = createOptions();
const { result } = renderHook(() => useProjectActions(options));
act(() => {
result.current.handleSetupComplete(PROJECT);
});
expect(options.closeSetupWizard).toHaveBeenCalledTimes(1);
expect(options.setCurrentProject).toHaveBeenCalledWith(PROJECT);
expect(options.setViewMode).toHaveBeenCalledWith("project");
expect(options.addToast).toHaveBeenCalledWith("Project Demo registered successfully", "success");
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
});
it("handlePauseProject calls updateProject and shows success toast", async () => {
const options = createOptions();
const { result } = renderHook(() => useProjectActions(options));
await act(async () => {
await result.current.handlePauseProject(PROJECT);
});
expect(mockUpdateProject).toHaveBeenCalledWith(PROJECT.id, { status: "paused" });
expect(options.addToast).toHaveBeenCalledWith("Project Demo paused", "success");
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
});
it("handleResumeProject calls updateProject and shows success toast", async () => {
const options = createOptions();
const { result } = renderHook(() => useProjectActions(options));
await act(async () => {
await result.current.handleResumeProject(PROJECT);
});
expect(mockUpdateProject).toHaveBeenCalledWith(PROJECT.id, { status: "active" });
expect(options.addToast).toHaveBeenCalledWith("Project Demo resumed", "success");
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
});
it("handleRemoveProject unregisters, clears current selection, toasts, and refreshes", async () => {
const options = createOptions({ currentProject: PROJECT });
const { result } = renderHook(() => useProjectActions(options));
await act(async () => {
await result.current.handleRemoveProject(PROJECT);
});
expect(mockUnregisterProject).toHaveBeenCalledWith(PROJECT.id);
expect(options.addToast).toHaveBeenCalledWith("Project Demo removed", "success");
expect(options.clearCurrentProject).toHaveBeenCalledTimes(1);
expect(options.setViewMode).toHaveBeenCalledWith("overview");
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
});
it("handleRemoveProject shows error toast on API failure", async () => {
mockUnregisterProject.mockRejectedValueOnce(new Error("boom"));
const options = createOptions();
const { result } = renderHook(() => useProjectActions(options));
await act(async () => {
await result.current.handleRemoveProject(PROJECT);
});
expect(options.addToast).toHaveBeenCalledWith("Failed to remove project Demo", "error");
});
it("handleToggleFavorite delegates and shows error toast on failure", async () => {
const options = createOptions({
toggleFavoriteProvider: vi.fn().mockRejectedValue(new Error("boom")),
});
const { result } = renderHook(() => useProjectActions(options));
await act(async () => {
await result.current.handleToggleFavorite("anthropic");
});
expect(options.toggleFavoriteProvider).toHaveBeenCalledWith("anthropic");
expect(options.addToast).toHaveBeenCalledWith("Failed to update favorites", "error");
});
it("handleToggleModelFavorite delegates and shows error toast on failure", async () => {
const options = createOptions({
toggleFavoriteModel: vi.fn().mockRejectedValue(new Error("boom")),
});
const { result } = renderHook(() => useProjectActions(options));
await act(async () => {
await result.current.handleToggleModelFavorite("claude-sonnet-4-5");
});
expect(options.toggleFavoriteModel).toHaveBeenCalledWith("claude-sonnet-4-5");
expect(options.addToast).toHaveBeenCalledWith("Failed to update model favorites", "error");
});
});

View File

@@ -0,0 +1,106 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useTaskHandlers } from "../useTaskHandlers";
import type { Task, TaskCreateInput } from "@fusion/core";
const CREATED_TASK: Task = {
id: "FN-123",
title: "Test",
description: "Created task",
status: "pending",
column: "triage",
steps: [],
dependencies: [],
log: [],
attachments: [],
createdAt: "",
updatedAt: "",
size: "M",
reviewLevel: 0,
};
function createOptions(overrides: Partial<Parameters<typeof useTaskHandlers>[0]> = {}): Parameters<typeof useTaskHandlers>[0] {
return {
createTask: vi.fn().mockResolvedValue(CREATED_TASK),
onPlanningTaskCreated: vi.fn(),
onPlanningTasksCreated: vi.fn(),
onSubtaskTasksCreated: vi.fn(),
addToast: vi.fn(),
...overrides,
};
}
describe("useTaskHandlers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("handleBoardQuickCreate calls createTask with triage column", async () => {
const options = createOptions();
const { result } = renderHook(() => useTaskHandlers(options));
const input: TaskCreateInput = { description: "Do work" };
await act(async () => {
await result.current.handleBoardQuickCreate(input);
});
expect(options.createTask).toHaveBeenCalledWith({ description: "Do work", column: "triage" });
});
it("handleModalCreate calls createTask with triage column and returns task", async () => {
const options = createOptions();
const { result } = renderHook(() => useTaskHandlers(options));
let created: Task | null = null;
await act(async () => {
created = await result.current.handleModalCreate({ description: "From modal" });
});
expect(options.createTask).toHaveBeenCalledWith({ description: "From modal", column: "triage" });
expect(created).toEqual(CREATED_TASK);
});
it("handlePlanningTaskCreated delegates with addToast", () => {
const options = createOptions();
const { result } = renderHook(() => useTaskHandlers(options));
act(() => {
result.current.handlePlanningTaskCreated(CREATED_TASK);
});
expect(options.onPlanningTaskCreated).toHaveBeenCalledWith(CREATED_TASK, options.addToast);
});
it("handlePlanningTasksCreated delegates with addToast", () => {
const options = createOptions();
const { result } = renderHook(() => useTaskHandlers(options));
act(() => {
result.current.handlePlanningTasksCreated([CREATED_TASK]);
});
expect(options.onPlanningTasksCreated).toHaveBeenCalledWith([CREATED_TASK], options.addToast);
});
it("handleSubtaskTasksCreated delegates with addToast", () => {
const options = createOptions();
const { result } = renderHook(() => useTaskHandlers(options));
act(() => {
result.current.handleSubtaskTasksCreated([CREATED_TASK]);
});
expect(options.onSubtaskTasksCreated).toHaveBeenCalledWith([CREATED_TASK], options.addToast);
});
it("handleGitHubImport shows success toast with task ID", () => {
const options = createOptions();
const { result } = renderHook(() => useTaskHandlers(options));
act(() => {
result.current.handleGitHubImport(CREATED_TASK);
});
expect(options.addToast).toHaveBeenCalledWith("Imported FN-123 from GitHub", "success");
});
});

View File

@@ -0,0 +1,167 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useViewState } from "../useViewState";
import type { ProjectInfo } from "../../api";
import type { ThemeMode } from "@fusion/core";
const PROJECT: ProjectInfo = {
id: "proj_123",
name: "Demo Project",
path: "/demo",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
function createOptions(overrides: Partial<Parameters<typeof useViewState>[0]> = {}): Parameters<typeof useViewState>[0] {
return {
projectsLoading: false,
currentProjectLoading: false,
currentProject: null,
projectsLength: 1,
setupWizardOpen: false,
openSetupWizard: vi.fn(),
themeMode: "dark",
setThemeMode: vi.fn(),
...overrides,
};
}
describe("useViewState", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("returns default viewMode and taskView when no localStorage exists", async () => {
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.viewMode).toBe("overview");
expect(result.current.taskView).toBe("board");
});
});
it("reads saved viewMode from localStorage on init", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.viewMode).toBe("project");
});
});
it("reads saved taskView from localStorage on init", async () => {
localStorage.setItem("kb-dashboard-task-view", "list");
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.taskView).toBe("list");
});
});
it("persists viewMode changes to localStorage", async () => {
const { result } = renderHook(() => useViewState(createOptions()));
await act(async () => {
result.current.setViewMode("project");
});
expect(localStorage.getItem("kb-dashboard-view-mode")).toBe("project");
});
it("persists taskView changes to localStorage", async () => {
const { result } = renderHook(() => useViewState(createOptions()));
await act(async () => {
result.current.setTaskView("list");
});
expect(localStorage.getItem("kb-dashboard-task-view")).toBe("list");
});
it("handleChangeTaskView updates taskView state", async () => {
const { result } = renderHook(() => useViewState(createOptions()));
await act(async () => {
result.current.handleChangeTaskView("agents");
});
expect(result.current.taskView).toBe("agents");
});
it("handleToggleTheme cycles dark → light → system → dark", async () => {
let themeMode: ThemeMode = "dark";
const setThemeMode = vi.fn((mode: ThemeMode) => {
themeMode = mode;
});
const { result, rerender } = renderHook(() =>
useViewState(
createOptions({
themeMode,
setThemeMode,
}),
),
);
await act(async () => {
result.current.handleToggleTheme();
});
expect(setThemeMode).toHaveBeenLastCalledWith("light");
rerender();
await act(async () => {
result.current.handleToggleTheme();
});
expect(setThemeMode).toHaveBeenLastCalledWith("system");
rerender();
await act(async () => {
result.current.handleToggleTheme();
});
expect(setThemeMode).toHaveBeenLastCalledWith("dark");
});
it("syncs viewMode to project when currentProject is restored after loading", async () => {
localStorage.setItem("kb-dashboard-view-mode", "overview");
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
projectsLength: 1,
}),
),
);
await waitFor(() => {
expect(result.current.viewMode).toBe("project");
});
});
it("calls openSetupWizard when no projects and no current project after loading", async () => {
vi.useFakeTimers();
const openSetupWizard = vi.fn();
renderHook(() =>
useViewState(
createOptions({
projectsLength: 0,
currentProject: null,
openSetupWizard,
}),
),
);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(openSetupWizard).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});

View File

@@ -0,0 +1,147 @@
import { useCallback } from "react";
import { updateProject, unregisterProject } from "../api";
import type { ProjectInfo } from "../api";
import type { ViewMode } from "./useViewState";
import type { ToastType } from "./useToast";
interface UseProjectActionsOptions {
setCurrentProject: (project: ProjectInfo) => void;
clearCurrentProject: () => void;
setViewMode: (mode: ViewMode) => void;
currentProject: ProjectInfo | null;
refreshProjects: () => Promise<void>;
toggleFavoriteProvider: (provider: string) => Promise<void>;
toggleFavoriteModel: (modelId: string) => Promise<void>;
addToast: (message: string, type: ToastType) => void;
openSettings: () => void;
openSetupWizard: () => void;
closeSetupWizard: () => void;
closeModelOnboarding: () => void;
}
export interface UseProjectActionsResult {
handleSelectProject: (project: ProjectInfo) => void;
handleViewAllProjects: () => void;
handleOpenSettings: () => void;
handleAddProject: () => void;
handleSetupComplete: (project: ProjectInfo) => void;
handleModelOnboardingComplete: () => void;
handlePauseProject: (project: ProjectInfo) => Promise<void>;
handleResumeProject: (project: ProjectInfo) => Promise<void>;
handleRemoveProject: (project: ProjectInfo) => Promise<void>;
handleToggleFavorite: (provider: string) => Promise<void>;
handleToggleModelFavorite: (modelId: string) => Promise<void>;
}
export function useProjectActions(options: UseProjectActionsOptions): UseProjectActionsResult {
const {
setCurrentProject,
clearCurrentProject,
setViewMode,
currentProject,
refreshProjects,
toggleFavoriteProvider,
toggleFavoriteModel,
addToast,
openSettings,
openSetupWizard,
closeSetupWizard,
closeModelOnboarding,
} = options;
const handleSelectProject = useCallback((project: ProjectInfo) => {
setCurrentProject(project);
setViewMode("project");
}, [setCurrentProject, setViewMode]);
const handleViewAllProjects = useCallback(() => {
clearCurrentProject();
setViewMode("overview");
}, [clearCurrentProject, setViewMode]);
const handleOpenSettings = useCallback(() => {
openSettings();
}, [openSettings]);
const handleAddProject = useCallback(() => {
openSetupWizard();
}, [openSetupWizard]);
const handleSetupComplete = useCallback((project: ProjectInfo) => {
closeSetupWizard();
setCurrentProject(project);
setViewMode("project");
addToast(`Project ${project.name} registered successfully`, "success");
void refreshProjects();
}, [closeSetupWizard, setCurrentProject, setViewMode, addToast, refreshProjects]);
const handleModelOnboardingComplete = useCallback(() => {
closeModelOnboarding();
}, [closeModelOnboarding]);
const handlePauseProject = useCallback(async (project: ProjectInfo) => {
try {
await updateProject(project.id, { status: "paused" });
addToast(`Project ${project.name} paused`, "success");
await refreshProjects();
} catch {
addToast(`Failed to pause project ${project.name}`, "error");
}
}, [addToast, refreshProjects]);
const handleResumeProject = useCallback(async (project: ProjectInfo) => {
try {
await updateProject(project.id, { status: "active" });
addToast(`Project ${project.name} resumed`, "success");
await refreshProjects();
} catch {
addToast(`Failed to resume project ${project.name}`, "error");
}
}, [addToast, refreshProjects]);
const handleRemoveProject = useCallback(async (project: ProjectInfo) => {
try {
await unregisterProject(project.id);
addToast(`Project ${project.name} removed`, "success");
if (currentProject?.id === project.id) {
clearCurrentProject();
setViewMode("overview");
}
await refreshProjects();
} catch {
addToast(`Failed to remove project ${project.name}`, "error");
}
}, [currentProject, clearCurrentProject, setViewMode, addToast, refreshProjects]);
const handleToggleFavorite = useCallback(async (provider: string) => {
try {
await toggleFavoriteProvider(provider);
} catch {
addToast("Failed to update favorites", "error");
}
}, [toggleFavoriteProvider, addToast]);
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
try {
await toggleFavoriteModel(modelId);
} catch {
addToast("Failed to update model favorites", "error");
}
}, [toggleFavoriteModel, addToast]);
return {
handleSelectProject,
handleViewAllProjects,
handleOpenSettings,
handleAddProject,
handleSetupComplete,
handleModelOnboardingComplete,
handlePauseProject,
handleResumeProject,
handleRemoveProject,
handleToggleFavorite,
handleToggleModelFavorite,
};
}

View File

@@ -0,0 +1,70 @@
import { useCallback } from "react";
import type { Task, TaskCreateInput } from "@fusion/core";
import type { ToastType } from "./useToast";
interface UseTaskHandlersOptions {
createTask: (input: TaskCreateInput) => Promise<Task>;
onPlanningTaskCreated: (task: Task, addToast: (msg: string, type?: ToastType) => void) => void;
onPlanningTasksCreated: (tasks: Task[], addToast: (msg: string, type?: ToastType) => void) => void;
onSubtaskTasksCreated: (tasks: Task[], addToast: (msg: string, type?: ToastType) => void) => void;
addToast: (message: string, type?: ToastType) => void;
}
export interface UseTaskHandlersResult {
handleBoardQuickCreate: (input: TaskCreateInput) => Promise<void>;
handleModalCreate: (input: TaskCreateInput) => Promise<Task>;
handlePlanningTaskCreated: (task: Task) => void;
handlePlanningTasksCreated: (tasks: Task[]) => void;
handleSubtaskTasksCreated: (tasks: Task[]) => void;
handleGitHubImport: (task: Task) => void;
}
export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandlersResult {
const {
createTask,
onPlanningTaskCreated,
onPlanningTasksCreated,
onSubtaskTasksCreated,
addToast,
} = options;
const handleBoardQuickCreate = useCallback(
async (input: TaskCreateInput): Promise<void> => {
await createTask({ ...input, column: "triage" });
},
[createTask],
);
const handleModalCreate = useCallback(
async (input: TaskCreateInput): Promise<Task> => {
const task = await createTask({ ...input, column: "triage" });
return task;
},
[createTask],
);
const handlePlanningTaskCreated = useCallback((task: Task) => {
onPlanningTaskCreated(task, addToast);
}, [onPlanningTaskCreated, addToast]);
const handlePlanningTasksCreated = useCallback((tasks: Task[]) => {
onPlanningTasksCreated(tasks, addToast);
}, [onPlanningTasksCreated, addToast]);
const handleSubtaskTasksCreated = useCallback((tasks: Task[]) => {
onSubtaskTasksCreated(tasks, addToast);
}, [onSubtaskTasksCreated, addToast]);
const handleGitHubImport = useCallback((task: Task) => {
addToast(`Imported ${task.id} from GitHub`, "success");
}, [addToast]);
return {
handleBoardQuickCreate,
handleModalCreate,
handlePlanningTaskCreated,
handlePlanningTasksCreated,
handleSubtaskTasksCreated,
handleGitHubImport,
};
}

View File

@@ -0,0 +1,118 @@
import { useCallback, useEffect, useState } from "react";
import type { ThemeMode } from "@fusion/core";
import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
export type ViewMode = "overview" | "project";
export type TaskView = "board" | "list" | "agents";
interface UseViewStateOptions {
projectsLoading: boolean;
currentProjectLoading: boolean;
currentProject: ProjectInfo | null;
projectsLength: number;
setupWizardOpen: boolean;
openSetupWizard: () => void;
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
}
export interface UseViewStateResult {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
taskView: TaskView;
setTaskView: (view: TaskView) => void;
handleChangeTaskView: (newView: TaskView) => void;
handleToggleTheme: () => void;
}
export function useViewState(options: UseViewStateOptions): UseViewStateResult {
const {
projectsLoading,
currentProjectLoading,
currentProject,
projectsLength,
setupWizardOpen,
openSetupWizard,
themeMode,
setThemeMode,
} = options;
const [viewMode, setViewMode] = useState<ViewMode>(() => {
if (typeof window !== "undefined") {
const saved = window.localStorage.getItem("kb-dashboard-view-mode");
if (saved === "overview" || saved === "project") return saved;
}
return "overview";
});
const [taskView, setTaskView] = useState<TaskView>(() => {
const saved = getScopedItem("kb-dashboard-task-view");
if (saved === "board" || saved === "list" || saved === "agents") return saved;
return "board";
});
useEffect(() => {
window.localStorage.setItem("kb-dashboard-view-mode", viewMode);
}, [viewMode]);
useEffect(() => {
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
if (saved === "board" || saved === "list" || saved === "agents") {
setTaskView(saved);
return;
}
setTaskView("board");
}, [currentProject?.id]);
useEffect(() => {
setScopedItem("kb-dashboard-task-view", taskView, currentProject?.id);
}, [currentProject?.id, taskView]);
useEffect(() => {
if (projectsLoading || currentProjectLoading) return;
if (currentProject && viewMode === "overview") {
setViewMode("project");
}
}, [projectsLoading, currentProjectLoading, currentProject, viewMode]);
useEffect(() => {
if (projectsLoading || currentProjectLoading) return;
if (setupWizardOpen) return;
if (projectsLength > 0 || currentProject) return;
const timer = window.setTimeout(() => {
openSetupWizard();
}, 500);
return () => window.clearTimeout(timer);
}, [
projectsLoading,
projectsLength,
currentProjectLoading,
currentProject,
setupWizardOpen,
openSetupWizard,
]);
const handleChangeTaskView = useCallback((newView: TaskView) => {
setTaskView(newView);
}, []);
const handleToggleTheme = useCallback(() => {
const cycle: ThemeMode[] = ["dark", "light", "system"];
const currentIndex = cycle.indexOf(themeMode);
const nextMode = cycle[(currentIndex + 1) % cycle.length];
setThemeMode(nextMode);
}, [themeMode, setThemeMode]);
return {
viewMode,
setViewMode,
taskView,
setTaskView,
handleChangeTaskView,
handleToggleTheme,
};
}