import { useState, useCallback, useEffect, useRef } from "react"; import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core"; import { fetchConfig, fetchSettings, fetchAuthStatus, fetchGlobalSettings, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api"; import type { ModelInfo, ProjectInfo } from "./api"; import { Header } 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 { ChangedFilesModal } from "./components/ChangedFilesModal"; import { SettingsModal } from "./components/SettingsModal"; import { ModelOnboardingModal } from "./components/ModelOnboardingModal"; import { PlanningModeModal } from "./components/PlanningModeModal"; import { SubtaskBreakdownModal } from "./components/SubtaskBreakdownModal"; import type { SectionId } from "./components/SettingsModal"; 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 { ScriptsModal } from "./components/ScriptsModal"; import { ExecutorStatusBar } from "./components/ExecutorStatusBar"; import { useBackgroundSessions } from "./hooks/useBackgroundSessions"; import { useTasks } from "./hooks/useTasks"; import { useProjects } from "./hooks/useProjects"; import { useCurrentProject } from "./hooks/useCurrentProject"; import { ToastProvider, useToast } from "./hooks/useToast"; import { useTheme } from "./hooks/useTheme"; type ViewMode = "overview" | "project"; type TaskView = "board" | "list" | "agents"; function AppInner() { const { toasts, addToast, removeToast } = useToast(); // Project management hooks - MUST be called before any conditional logic const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects, register: registerProject, update: updateProjectHook, unregister: unregisterProjectHook } = useProjects(); const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects); // Tasks hook with project context const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks( currentProject ? { projectId: currentProject.id } : undefined ); // Theme management const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme(); // Background AI sessions const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id); // View state const [viewMode, setViewMode] = useState(() => { 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(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("kb-dashboard-task-view"); if (saved === "board" || saved === "list" || saved === "agents") return saved; } return "board"; }); // Modal states const [newTaskModalOpen, setNewTaskModalOpen] = useState(false); const [isPlanningOpen, setIsPlanningOpen] = useState(false); const [planningInitialPlan, setPlanningInitialPlan] = useState(null); const [isSubtaskOpen, setIsSubtaskOpen] = useState(false); const [subtaskInitialDescription, setSubtaskInitialDescription] = useState(null); const [detailTask, setDetailTask] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [schedulesOpen, setSchedulesOpen] = useState(false); const [githubImportOpen, setGitHubImportOpen] = useState(false); const [usageOpen, setUsageOpen] = useState(false); const [terminalOpen, setTerminalOpen] = useState(false); const [filesOpen, setFilesOpen] = useState(false); const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project"); const [changedFilesState, setChangedFilesState] = useState<{ taskId: string; worktree: string | undefined; column: string } | null>(null); const [activityLogOpen, setActivityLogOpen] = useState(false); const [gitManagerOpen, setGitManagerOpen] = useState(false); const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false); const [missionsOpen, setMissionsOpen] = useState(false); const [agentsOpen, setAgentsOpen] = useState(false); const [scriptsOpen, setScriptsOpen] = useState(false); const [planningResumeSessionId, setPlanningResumeSessionId] = useState(undefined); const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState(undefined); const [missionResumeSessionId, setMissionResumeSessionId] = useState(undefined); const [terminalInitialCommand, setTerminalInitialCommand] = useState(undefined); const [settingsInitialSection, setSettingsInitialSection] = useState(undefined); const [setupWizardOpen, setSetupWizardOpen] = useState(false); const [modelOnboardingOpen, setModelOnboardingOpen] = useState(false); // Settings state const [maxConcurrent, setMaxConcurrent] = useState(2); const [rootDir, setRootDir] = useState("."); const [autoMerge, setAutoMerge] = useState(true); const [globalPaused, setGlobalPaused] = useState(false); const [enginePaused, setEnginePaused] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState(undefined); const [githubTokenConfigured, setGithubTokenConfigured] = useState(false); const [availableModels, setAvailableModels] = useState([]); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); // Persist view mode useEffect(() => { localStorage.setItem("kb-dashboard-view-mode", viewMode); }, [viewMode]); // Persist task view useEffect(() => { localStorage.setItem("kb-dashboard-task-view", taskView); }, [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(() => { setSetupWizardOpen(true); }, 500); return () => clearTimeout(timer); }, [projectsLoading, projects.length, currentProjectLoading, currentProject, setupWizardOpen]); // 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]); // Initial data fetch useEffect(() => { fetchConfig(currentProject?.id) .then((cfg) => { setMaxConcurrent(cfg.maxConcurrent); setRootDir(cfg.rootDir); }) .catch(() => {/* keep default */}); fetchSettings(currentProject?.id) .then((s) => { setAutoMerge(!!s.autoMerge); setGlobalPaused(!!s.globalPause); setEnginePaused(!!s.enginePaused); setGithubTokenConfigured(!!s.githubTokenConfigured); setTaskStuckTimeoutMs(s.taskStuckTimeoutMs); }) .catch(() => {/* keep default */}); fetchAuthStatus() .then(({ providers }) => { const hasAuthenticatedProvider = providers.some((p) => p.authenticated); // Check if onboarding is needed: either no authenticated providers, // or providers are authenticated but no default model is configured const needsSetup = providers.length > 0 && !hasAuthenticatedProvider; if (needsSetup || (providers.length > 0 && hasAuthenticatedProvider)) { fetchGlobalSettings() .then((globalSettings) => { const hasDefaultModel = !!(globalSettings.defaultProvider && globalSettings.defaultModelId); const setupIncomplete = !hasAuthenticatedProvider || !hasDefaultModel; if (!globalSettings.modelOnboardingComplete && setupIncomplete) { // First-run: show onboarding modal setModelOnboardingOpen(true); } else if (!hasAuthenticatedProvider) { // Already onboarded but no auth: open settings to authentication setSettingsOpen(true); setSettingsInitialSection("authentication"); } }) .catch(() => { // If we can't fetch global settings, fall back to onboarding if (!hasAuthenticatedProvider) { setModelOnboardingOpen(true); } }); } }) .catch(() => {/* fail silently */}); }, [currentProject?.id]); // Fetch available models useEffect(() => { fetchModels() .then((response) => { setAvailableModels(response.models); setFavoriteProviders(response.favoriteProviders); setFavoriteModels(response.favoriteModels); }) .catch(() => {/* keep empty array on failure */}); }, []); // Favorite provider/model toggle handlers (shared by ListView bulk edit) const handleToggleFavorite = useCallback(async (provider: string) => { const currentFavorites = favoriteProviders; const isFavorite = currentFavorites.includes(provider); const newFavorites = isFavorite ? currentFavorites.filter((p) => p !== provider) : [provider, ...currentFavorites]; setFavoriteProviders(newFavorites); try { await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels }); } catch { setFavoriteProviders(currentFavorites); addToast("Failed to update favorites", "error"); } }, [favoriteProviders, favoriteModels, addToast]); const handleToggleModelFavorite = useCallback(async (modelId: string) => { const currentFavorites = favoriteModels; const isFavorite = currentFavorites.includes(modelId); const newFavorites = isFavorite ? currentFavorites.filter((m) => m !== modelId) : [modelId, ...currentFavorites]; setFavoriteModels(newFavorites); try { await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites }); } catch { setFavoriteModels(currentFavorites); addToast("Failed to update model favorites", "error"); } }, [favoriteModels, favoriteProviders, addToast]); // Handle deep link to task on mount (with optional project context) // Uses a ref to prevent duplicate fetches when setCurrentProject triggers // a re-run of this effect during project switching. const deepLinkFetchedRef = useRef(false); // Tracks the task ID currently open from a deep link so that dismissing // the modal can clean the URL (one-time open behaviour). const deepLinkTaskIdRef = useRef(null); useEffect(() => { const params = new URLSearchParams(window.location.search); const projectParam = params.get("project"); const taskId = params.get("task"); // If no task to load, nothing to do if (!taskId) return; // Wait for projects to finish loading before resolving deep links. // Without this guard, an empty projects list during loading would // produce a false "project not found" error toast. if (projectsLoading) return; // If project param is present, validate it and switch project if needed if (projectParam) { const matchingProject = projects.find((p) => p.id === projectParam); if (!matchingProject) { addToast(`Project '${projectParam}' not found`, "error"); return; } // Switch to the project if it's different from the current one if (currentProject?.id !== matchingProject.id) { setCurrentProject(matchingProject); } } // Skip if we've already fetched this task (prevents double-fetch when // setCurrentProject causes this effect to re-run). if (deepLinkFetchedRef.current) return; deepLinkFetchedRef.current = true; // Use project param as the authoritative project context for the fetch // when present; otherwise fall back to the current/default project. const taskProjectId = projectParam ?? currentProject?.id; fetchTaskDetail(taskId, taskProjectId) .then((detail) => { setDetailTask(detail); // Mark this as a deep-linked open so dismissal can clean the URL deepLinkTaskIdRef.current = taskId; }) .catch(() => { addToast(`Task ${taskId} not found`, "error"); }); }, [addToast, projects, projectsLoading, currentProject, setCurrentProject]); // 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 handleAddProject = useCallback(() => { setSetupWizardOpen(true); }, []); const handleSetupComplete = useCallback((project: ProjectInfo) => { setSetupWizardOpen(false); setCurrentProject(project); setViewMode("project"); addToast(`Project ${project.name} registered successfully`, "success"); refreshProjects(); }, [setCurrentProject, addToast, refreshProjects]); const handleModelOnboardingComplete = useCallback(() => { setModelOnboardingOpen(false); }, []); 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 = useCallback(() => setNewTaskModalOpen(true), []); const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []); const handleBoardQuickCreate = useCallback( async (input: TaskCreateInput): Promise => { await createTask({ ...input, column: "triage" }); }, [createTask], ); const handleModalCreate = useCallback( async (input: TaskCreateInput): Promise => { const task = await createTask({ ...input, column: "triage" }); return task; }, [createTask], ); // Planning mode handlers const handlePlanningOpen = useCallback(() => setIsPlanningOpen(true), []); const handleResumePlanning = useCallback(() => { const session = bgPlanningSessions[0]; if (session) { setPlanningResumeSessionId(session.id); setIsPlanningOpen(true); } }, [bgPlanningSessions]); const handlePlanningClose = useCallback(() => { setIsPlanningOpen(false); setPlanningInitialPlan(null); setPlanningResumeSessionId(undefined); }, []); const handlePlanningTaskCreated = useCallback((task: Task) => { addToast(`Created ${task.id} from planning mode`, "success"); setIsPlanningOpen(false); setPlanningInitialPlan(null); }, [addToast]); const handlePlanningTasksCreated = useCallback((createdTasks: Task[]) => { const ids = createdTasks.map((task) => task.id).join(", "); addToast(`Created ${ids} from planning mode`, "success"); setIsPlanningOpen(false); setPlanningInitialPlan(null); }, [addToast]); // Handle planning mode from new task dialog const handleNewTaskPlanningMode = useCallback((initialPlan: string) => { setPlanningInitialPlan(initialPlan); setIsPlanningOpen(true); }, []); // Handle subtask breakdown from inline/quick create const handleSubtaskBreakdown = useCallback((description: string) => { setSubtaskInitialDescription(description); setIsSubtaskOpen(true); }, []); const handleSubtaskClose = useCallback(() => { setIsSubtaskOpen(false); setSubtaskInitialDescription(null); setSubtaskResumeSessionId(undefined); }, []); const handleSubtaskTasksCreated = useCallback((createdTasks: Task[]) => { const ids = createdTasks.map((task) => task.id).join(", "); addToast(`Created ${ids} from subtask breakdown`, "success"); setIsSubtaskOpen(false); setSubtaskInitialDescription(null); }, [addToast]); // Usage indicator handlers const handleOpenUsage = useCallback(() => setUsageOpen(true), []); const handleCloseUsage = useCallback(() => setUsageOpen(false), []); // Schedules modal handlers const handleOpenSchedules = useCallback(() => setSchedulesOpen(true), []); const handleCloseSchedules = useCallback(() => setSchedulesOpen(false), []); const handleToggleAutoMerge = useCallback(async () => { const next = !autoMerge; setAutoMerge(next); try { await updateSettings({ autoMerge: next }, currentProject?.id); } catch { setAutoMerge(!next); // revert on failure } }, [autoMerge, currentProject?.id]); const handleToggleGlobalPause = useCallback(async () => { const next = !globalPaused; setGlobalPaused(next); try { await updateSettings({ globalPause: next }, currentProject?.id); } catch { setGlobalPaused(!next); // revert on failure } }, [globalPaused, currentProject?.id]); const handleToggleEnginePause = useCallback(async () => { const next = !enginePaused; setEnginePaused(next); try { await updateSettings({ enginePaused: next }, currentProject?.id); } catch { setEnginePaused(!next); // revert on failure } }, [enginePaused, currentProject?.id]); const handleDetailOpen = useCallback((task: TaskDetail) => { setDetailTask(task); }, []); const handleDetailClose = useCallback(() => { // If the modal was opened from a deep link (?task=...), remove the task // param from the URL so refreshing does not reopen it. Preserve any other // query parameters (e.g. ?project=...). if (deepLinkTaskIdRef.current) { const params = new URLSearchParams(window.location.search); params.delete("task"); const qs = params.toString(); window.history.replaceState( null, "", qs ? `${window.location.pathname}?${qs}` : window.location.pathname, ); deepLinkTaskIdRef.current = null; } setDetailTask(null); }, []); const handleGitHubImport = useCallback((task: Task) => { addToast(`Imported ${task.id} from GitHub`, "success"); }, [addToast]); const handleToggleTerminal = useCallback(() => { setTerminalOpen((prev) => !prev); }, []); const handleOpenFiles = useCallback(() => { setFilesOpen(true); }, []); const handleOpenChangedFiles = useCallback((taskId: string, worktree: string | undefined, column: string) => { setChangedFilesState({ taskId, worktree, column }); }, []); const handleCloseChangedFiles = useCallback(() => { setChangedFilesState(null); }, []); const handleWorkspaceChange = useCallback((workspace: string) => { setFileBrowserWorkspace(workspace); }, []); // Activity log handlers const handleOpenActivityLog = useCallback(() => setActivityLogOpen(true), []); const handleCloseActivityLog = useCallback(() => setActivityLogOpen(false), []); // Git Manager handlers const handleOpenGitManager = useCallback(() => setGitManagerOpen(true), []); const handleCloseGitManager = useCallback(() => setGitManagerOpen(false), []); // Agent handlers const handleCloseAgents = useCallback(() => setAgentsOpen(false), []); // Scripts handlers const handleOpenScripts = useCallback(() => setScriptsOpen(true), []); const handleCloseScripts = useCallback(() => setScriptsOpen(false), []); const handleRunScript = useCallback(async (name: string, command: string) => { // Close the scripts modal immediately so the terminal becomes the // topmost surface once it opens. setScriptsOpen(false); // Launch the script command in the interactive terminal modal. // Reset the initial command ref so the terminal knows to run the // new command even if it is already open. setTerminalInitialCommand(command); setTerminalOpen(true); }, []); // Terminal close handler const handleTerminalClose = useCallback(() => { setTerminalOpen(false); setTerminalInitialCommand(undefined); }, []); // Render main content based on view mode const renderMainContent = () => { if (viewMode === "overview") { return ( ); } // Project view if (taskView === "agents") { return ; } if (taskView === "board") { return ( ); } // List view return ( ); }; return ( <>
setSettingsOpen(true)} onOpenGitHubImport={() => setGitHubImportOpen(true)} onOpenPlanning={handlePlanningOpen} onResumePlanning={handleResumePlanning} activePlanningSessionCount={bgPlanningSessions.length} onOpenUsage={handleOpenUsage} onOpenActivityLog={handleOpenActivityLog} onOpenSchedules={handleOpenSchedules} onOpenGitManager={handleOpenGitManager} onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)} onOpenMissions={viewMode === "project" && currentProject ? () => setMissionsOpen(true) : undefined} onOpenScripts={handleOpenScripts} onRunScript={handleRunScript} onToggleTerminal={handleToggleTerminal} onOpenFiles={handleOpenFiles} filesOpen={filesOpen} globalPaused={globalPaused} enginePaused={enginePaused} onToggleGlobalPause={handleToggleGlobalPause} onToggleEnginePause={handleToggleEnginePause} view={taskView} onChangeView={viewMode === "project" && currentProject ? handleChangeTaskView : undefined} searchQuery={searchQuery} onSearchChange={setSearchQuery} projects={projects} currentProject={currentProject} onSelectProject={handleSelectProject} onViewAllProjects={handleViewAllProjects} projectId={currentProject?.id} />
{renderMainContent()}
{viewMode === "project" && currentProject && ( { if (session.type === "planning") { setPlanningResumeSessionId(session.id); setIsPlanningOpen(true); } else if (session.type === "subtask") { setSubtaskResumeSessionId(session.id); setIsSubtaskOpen(true); } else if (session.type === "mission_interview") { setMissionResumeSessionId(session.id); setMissionsOpen(true); } }} onDismissBackgroundSession={bgDismiss} /> )} {detailTask && ( setDetailTask(prev => prev ? { ...prev, ...updated } : prev)} addToast={addToast} githubTokenConfigured={githubTokenConfigured} /> )} {settingsOpen && ( { setSettingsOpen(false); setSettingsInitialSection(undefined); }} addToast={addToast} initialSection={settingsInitialSection} projectId={currentProject?.id} themeMode={themeMode} colorTheme={colorTheme} onThemeModeChange={setThemeMode} onColorThemeChange={setColorTheme} /> )} setGitHubImportOpen(false)} onImport={handleGitHubImport} tasks={tasks} /> {filesOpen && ( setFilesOpen(false)} onWorkspaceChange={handleWorkspaceChange} /> )} {changedFilesState && ( )} {schedulesOpen && ( )} { const task = tasks.find((t) => t.id === taskId); if (task) { handleDetailOpen(task as TaskDetail); } }} /> setWorkflowStepsOpen(false)} addToast={addToast} projectId={currentProject?.id} /> { setMissionsOpen(false); setMissionResumeSessionId(undefined); }} addToast={addToast} projectId={currentProject?.id} resumeSessionId={missionResumeSessionId} availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))} onSelectTask={(taskId) => { const task = tasks.find((t) => t.id === taskId); if (task) { setDetailTask(task as TaskDetail); } }} /> {setupWizardOpen && ( setSetupWizardOpen(false)} /> )} {modelOnboardingOpen && ( )} ); } export function App() { return ( ); }