Files
fusion/packages/dashboard/app/App.tsx
gsxdsm 075402c11d feat(FN-1081): add dashboard nodes management workflow
- Add node API typings and client methods plus a polling useNodes hook with visibility-triggered refresh
- Introduce NodesView with NodeCard, AddNodeModal, and NodeDetailModal to register, inspect, update, health-check, and remove nodes
- Wire the Nodes surface into the app shell/header and add project node assignment UI with project card node badges
- Add unit coverage for useNodes, NodesView, NodeCard, Header, and ProjectCard behavior, plus node-specific dashboard styling
2026-04-07 23:30:32 -07:00

926 lines
35 KiB
TypeScript

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, fetchUnreadCount, fetchAgents } from "./api";
import type { ModelInfo, ProjectInfo, Agent } 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 { 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 { NodesView } from "./components/NodesView";
import { MailboxModal } from "./components/MailboxModal";
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 { useNodes } from "./hooks/useNodes";
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 { nodes } = useNodes();
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<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>(() => {
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<string | null>(null);
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
const [detailTaskInitialTab, setDetailTaskInitialTab] = useState<"definition" | "logs" | "changes" | "commits" | "comments" | "model" | "workflow">("definition");
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 [activityLogOpen, setActivityLogOpen] = useState(false);
const [mailboxOpen, setMailboxOpen] = useState(false);
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
const [mailboxAgents, setMailboxAgents] = useState<Agent[]>([]);
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
const [missionsOpen, setMissionsOpen] = useState(false);
const [agentsOpen, setAgentsOpen] = useState(false);
const [nodesOpen, setNodesOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
const [modelOnboardingOpen, setModelOnboardingOpen] = useState(false);
// Settings state
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [rootDir, setRootDir] = useState<string>(".");
const [autoMerge, setAutoMerge] = useState(true);
const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
// 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<string | null>(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<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 = 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);
setDetailTaskInitialTab("definition");
}, []);
const handleOpenDetailWithTab = useCallback((task: TaskDetail, initialTab: "changes") => {
setDetailTask(task);
setDetailTaskInitialTab(initialTab);
}, []);
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 handleWorkspaceChange = useCallback((workspace: string) => {
setFileBrowserWorkspace(workspace);
}, []);
// Activity log handlers
const handleOpenActivityLog = useCallback(() => setActivityLogOpen(true), []);
const handleCloseActivityLog = useCallback(() => setActivityLogOpen(false), []);
const handleOpenMailbox = useCallback(() => {
setMailboxOpen(true);
// Refresh unread count and agents when opening mailbox
fetchUnreadCount(currentProject?.id).then((data) => {
setMailboxUnreadCount(data.unreadCount);
}).catch(() => {});
fetchAgents(undefined, currentProject?.id).then((agents) => {
setMailboxAgents(agents);
}).catch(() => {});
}, [currentProject?.id]);
const handleCloseMailbox = useCallback(() => setMailboxOpen(false), []);
// Mission link handler from TaskCard
const handleOpenMission = useCallback((missionId: string) => {
setMissionTargetId(missionId);
setMissionsOpen(true);
}, []);
// Git Manager handlers
const handleOpenGitManager = useCallback(() => setGitManagerOpen(true), []);
const handleCloseGitManager = useCallback(() => setGitManagerOpen(false), []);
// Agent handlers
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
// Node management view handlers
const handleOpenNodes = useCallback(() => {
setNodesOpen((prev) => !prev);
}, []);
const handleCloseNodes = useCallback(() => {
setNodesOpen(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 (nodesOpen) {
return (
<div className="nodes-management-overlay">
<div className="nodes-management-overlay__header">
<button className="btn btn-sm" onClick={handleCloseNodes}>Close Nodes</button>
</div>
<NodesView addToast={addToast} />
</div>
);
}
if (viewMode === "overview") {
return (
<ProjectOverview
projects={projects}
loading={projectsLoading}
onSelectProject={handleSelectProject}
onAddProject={handleAddProject}
onPauseProject={handlePauseProject}
onResumeProject={handleResumeProject}
onRemoveProject={handleRemoveProject}
nodes={nodes}
/>
);
}
// Project view
if (taskView === "agents") {
return <AgentsView addToast={addToast} projectId={currentProject?.id} />;
}
if (taskView === "board") {
return (
<Board
tasks={tasks}
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}
onNewTask={handleNewTaskOpen}
onPlanningMode={handleNewTaskPlanningMode}
onSubtaskBreakdown={handleSubtaskBreakdown}
autoMerge={autoMerge}
onToggleAutoMerge={handleToggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onArchiveAllDone={archiveAllDone}
searchQuery={searchQuery}
availableModels={availableModels}
onOpenDetailWithTab={handleOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={handleOpenMission}
/>
);
}
// List view
return (
<ListView
tasks={tasks}
projectId={currentProject?.id}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={handleNewTaskOpen}
onQuickCreate={handleBoardQuickCreate}
onPlanningMode={handleNewTaskPlanningMode}
onSubtaskBreakdown={handleSubtaskBreakdown}
availableModels={availableModels}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
/>
);
};
return (
<>
<Header
onOpenSettings={() => setSettingsOpen(true)}
onOpenGitHubImport={() => setGitHubImportOpen(true)}
onOpenPlanning={handlePlanningOpen}
onResumePlanning={handleResumePlanning}
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={handleOpenUsage}
onOpenActivityLog={handleOpenActivityLog}
onOpenMailbox={handleOpenMailbox}
mailboxUnreadCount={mailboxUnreadCount}
onOpenSchedules={handleOpenSchedules}
onOpenGitManager={handleOpenGitManager}
onOpenNodes={handleOpenNodes}
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}
/>
<div
className={`project-content${viewMode === "project" && currentProject ? " project-content--with-footer" : ""}`}
>
{renderMainContent()}
</div>
{viewMode === "project" && currentProject && !nodesOpen && (
<ExecutorStatusBar
tasks={tasks}
projectId={currentProject.id}
taskStuckTimeoutMs={taskStuckTimeoutMs}
backgroundSessions={bgSessions}
backgroundGenerating={bgGenerating}
backgroundNeedsInput={bgNeedsInput}
onOpenBackgroundSession={(session) => {
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 && (
<TaskDetailModal
task={detailTask}
projectId={currentProject?.id}
tasks={tasks}
onClose={handleDetailClose}
onOpenDetail={handleDetailOpen}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
onMergeTask={mergeTask}
onRetryTask={retryTask}
onDuplicateTask={duplicateTask}
onTaskUpdated={(updated) => setDetailTask(prev => prev ? { ...prev, ...updated } : prev)}
addToast={addToast}
githubTokenConfigured={githubTokenConfigured}
initialTab={detailTaskInitialTab}
/>
)}
{settingsOpen && (
<SettingsModal
onClose={() => {
setSettingsOpen(false);
setSettingsInitialSection(undefined);
}}
addToast={addToast}
initialSection={settingsInitialSection}
projectId={currentProject?.id}
themeMode={themeMode}
colorTheme={colorTheme}
onThemeModeChange={setThemeMode}
onColorThemeChange={setColorTheme}
/>
)}
<GitHubImportModal
isOpen={githubImportOpen}
onClose={() => setGitHubImportOpen(false)}
onImport={handleGitHubImport}
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}
/>
<ScriptsModal
isOpen={scriptsOpen}
onClose={handleCloseScripts}
addToast={addToast}
onRunScript={handleRunScript}
projectId={currentProject?.id}
/>
{filesOpen && (
<FileBrowserModal
initialWorkspace={fileBrowserWorkspace}
isOpen={true}
onClose={() => setFilesOpen(false)}
onWorkspaceChange={handleWorkspaceChange}
/>
)}
<UsageIndicator
isOpen={usageOpen}
onClose={handleCloseUsage}
/>
{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);
}
}}
/>
<GitManagerModal
isOpen={gitManagerOpen}
onClose={handleCloseGitManager}
tasks={tasks}
addToast={addToast}
/>
<WorkflowStepManager
isOpen={workflowStepsOpen}
onClose={() => setWorkflowStepsOpen(false)}
addToast={addToast}
projectId={currentProject?.id}
/>
<MissionManager
isOpen={missionsOpen}
onClose={() => { setMissionsOpen(false); setMissionResumeSessionId(undefined); setMissionTargetId(undefined); }}
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) {
setDetailTask(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={() => setSetupWizardOpen(false)}
/>
)}
{modelOnboardingOpen && (
<ModelOnboardingModal
onComplete={handleModelOnboardingComplete}
addToast={addToast}
/>
)}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);
}
export function App() {
return (
<ToastProvider>
<AppInner />
</ToastProvider>
);
}