Files
fusion/packages/dashboard/app/App.tsx
gsxdsm d73f227cc6 feat(dashboard): redesign Planning modal as two-pane session manager
- Sidebar lists all planning sessions for the project (any status) with
  inline status badges, relative timestamps, "+ New session", and an
  inline delete confirm. Mobile collapses to a single pane with a back
  chevron in the header.
- Closing the modal (X / Escape / overlay) no longer cancels the server
  session — it stays in the list, resumable. Only an explicit Delete
  cancels + removes. Removes the now-redundant minimize button.
- Add re-sync on reopen so a session whose terminal SSE event was missed
  doesn't stay stuck on a stale loading view; previously only a hard
  reload recovered.
- Persist user-chosen modal width/height across opens via localStorage,
  driven by a ResizeObserver on the modal element.
- Theme the modal scrollbars to match the rest of the app.
- Banner dismiss only hides the banner now — it must not delete the
  underlying session, since sessions are first-class in the new sidebar.
- Refresh background sessions list on SSE reconnect so the footer "AI N"
  pill never gets stuck on tombstoned sessions after a network blip.
- Drop selection + broadcast completion on Create Task / Create Tasks
  so the footer count drops in lockstep instead of waiting on SSE.
- Auto-scroll the AI thinking output as new tokens stream in (only when
  already pinned to the tail).
- Guard overlay-click dismissal with a mousedown-on-overlay check so
  releasing a resize drag outside the modal doesn't close it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:58:35 -07:00

1001 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback, useEffect, useMemo, useRef, lazy, Suspense } from "react";
import type { Task, 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 { MissionManager } from "./components/MissionManager";
import { MailboxView } from "./components/MailboxView";
import { PageErrorBoundary } from "./components/ErrorBoundary";
import { AppModals } from "./components/AppModals";
import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorPage";
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
import { SetupWarningBanner } from "./components/SetupWarningBanner";
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations";
import {
isOnboardingCompleted,
isOnboardingResumable,
isPostOnboardingDismissed,
} from "./components/model-onboarding-state";
import type { SectionId } from "./components/SettingsModal";
import { MobileNavBar } from "./components/MobileNavBar";
import { QuickChatFAB } from "./components/QuickChatFAB";
import { ToastContainer } from "./components/ToastContainer";
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 { ConfirmDialogProvider } from "./hooks/useConfirm";
import { useTheme } from "./hooks/useTheme";
import { useModalManager } from "./hooks/useModalManager";
import { useAppSettings } from "./hooks/useAppSettings";
import { useDeepLink } from "./hooks/useDeepLink";
import { useFavorites } from "./hooks/useFavorites";
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
import { useSetupReadiness } from "./hooks/useSetupReadiness";
import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { useViewState, type TaskView } from "./hooks/useViewState";
import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers";
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
import { NodeProvider, useNodeContext } from "./context/NodeContext";
import type { AiSessionSummary } from "./api";
import { fetchUnreadCount, reportDashboardPerf } from "./api";
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
import { subscribeSse } from "./sse-bus";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
// ChatView's CSS is imported eagerly so the styles bundle into the main
// CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS
// link asynchronously, producing a brief flash of unstyled chat UI on
// first render.
import "./components/ChatView.css";
const AgentsView = lazy(() => import("./components/AgentsView").then((m) => ({ default: m.AgentsView })));
const DocumentsView = lazy(() => import("./components/DocumentsView").then((m) => ({ default: m.DocumentsView })));
const InsightsView = lazy(() => import("./components/InsightsView").then((m) => ({ default: m.InsightsView })));
const NodesView = lazy(() => import("./components/NodesView").then((m) => ({ default: m.NodesView })));
const ChatView = lazy(() => import("./components/ChatView").then((m) => ({ default: m.ChatView })));
const RoadmapsView = lazy(() => import("./components/RoadmapsView").then((m) => ({ default: m.RoadmapsView })));
const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView })));
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
// Warm lazy chunks during browser idle so first navigation to each view is
// instant. Each chunk is ~1080 kB; total prefetch finishes well under a
// second on broadband. Uses requestIdleCallback so it never blocks render.
function prefetchLazyViews() {
const idle =
(typeof window !== "undefined" && (window as Window & { requestIdleCallback?: (cb: () => void) => number }).requestIdleCallback) ||
((cb: () => void) => setTimeout(cb, 200));
idle(() => {
void import("./components/AgentsView");
void import("./components/DocumentsView");
void import("./components/InsightsView");
void import("./components/NodesView");
void import("./components/ChatView");
void import("./components/RoadmapsView");
void import("./components/SkillsView");
void import("./components/MemoryView");
void import("./components/DevServerView");
void import("./components/TodoView");
});
}
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
function AppInner() {
const { toasts, addToast, removeToast } = useToast();
const isElectron = typeof window !== "undefined" && Boolean((window as Window & { electronAPI?: unknown }).electronAPI);
// Warm lazy view chunks during browser idle so first navigation is instant.
useEffect(() => {
prefetchLazyViews();
}, []);
// Project management hooks - MUST be called before any conditional logic
const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects } = useProjects();
const { nodes } = useNodes();
// Node context for local/remote node switching - must be called before useCurrentProject
const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext();
// Current project with node-aware persistence
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects, { nodeId: currentNodeId });
const {
hasAiProvider,
hasGithub,
loading: setupReadinessLoading,
hasWarnings,
} = useSetupReadiness(currentProject?.id);
const {
updateAvailable,
latestVersion,
currentVersion,
dismissed: updateBannerDismissed,
dismiss: dismissUpdateBanner,
} = useUpdateCheck();
// Sync node context with useNodes() results:
// - Resolve saved node ID to full NodeConfig when nodes list loads
// - Fall back to local if selected node is missing or deleted
useEffect(() => {
// If we have a saved node ID but no currentNode yet (initial hydration),
// resolve it from the nodes list
if (currentNodeId && !currentNode && nodes.length > 0) {
const foundNode = nodes.find((n) => n.id === currentNodeId);
if (foundNode) {
setCurrentNode(foundNode);
return;
}
}
// If we have a currentNode but the saved ID no longer exists in nodes list,
// fall back to local view
if (currentNodeId && nodes.length > 0) {
const nodeExists = nodes.some((n) => n.id === currentNodeId);
if (!nodeExists) {
// Selected node was deleted or unregistered - fall back to local
clearCurrentNode();
}
}
}, [currentNodeId, currentNode, nodes, setCurrentNode, clearCurrentNode]);
// Search query state - must be defined before useTasks
const [searchQuery, setSearchQuery] = useState("");
// Remote node data and events when in remote mode (pass searchQuery for server-side filtering)
const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id, searchQuery: searchQuery || undefined });
useRemoteNodeEvents(currentNodeId);
// Use remote data when in remote mode, local data otherwise
const effectiveProjects = isRemote && remoteData.projects.length > 0 ? remoteData.projects : projects;
// Theme management - required before useViewState
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
// Background AI sessions - required before useModalManager
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
const sessionsNeedingInput = bgSessions.filter(
(session) => session.status === "awaiting_input" || session.status === "error"
);
// Modal state/handlers - required before useViewState
const modalManager = useModalManager({
projectId: currentProject?.id,
planningSessions: bgPlanningSessions,
});
// View state must be defined before useTasks since useTasks depends on taskView for SSE gating
const { viewMode, setViewMode, taskView, handleChangeTaskView } = useViewState({
projectsLoading,
projectsError,
currentProjectLoading,
currentProject,
projectsLength: projects.length,
setupWizardOpen: modalManager.setupWizardOpen,
openSetupWizard: modalManager.openSetupWizard,
themeMode,
setThemeMode,
});
const handleTaskViewChange = useCallback((newView: TaskView) => {
if (newView === "missions") {
setMissionResumeSessionId(undefined);
setMissionTargetId(undefined);
setMilestoneSliceResumeSessionId(undefined);
}
handleChangeTaskView(newView);
}, [handleChangeTaskView]);
// Tasks hook with project context and search query
// SSE is only enabled for board/list views to free connection slots for mission detail fetches
const taskSseEnabled = taskView === "board" || taskView === "list";
const { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, lastFetchTimeMs } = useTasks(
{
...(currentProject ? { projectId: currentProject.id } : {}),
searchQuery: searchQuery || undefined,
sseEnabled: taskSseEnabled,
}
);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const mountTimeRef = useRef(performance.now());
const projectsReadyLoggedRef = useRef(false);
const projectReadyLoggedRef = useRef(false);
const loadingStage = useMemo<DashboardLoaderStage>(() => {
if (projectsLoading) return "projects";
if (currentProjectLoading) return "project";
return "tasks";
}, [projectsLoading, currentProjectLoading]);
useEffect(() => {
if (!projectsLoading && !projectsReadyLoggedRef.current) {
projectsReadyLoggedRef.current = true;
const msg = `projects loaded at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
console.log(`[App] ${msg}`);
reportDashboardPerf("[App]", msg);
}
if (!currentProjectLoading && !projectReadyLoggedRef.current) {
projectReadyLoggedRef.current = true;
const msg = `current-project resolved at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
console.log(`[App] ${msg}`);
reportDashboardPerf("[App]", msg);
}
}, [projectsLoading, currentProjectLoading]);
useEffect(() => {
if (initialLoadComplete) {
return;
}
if (projectsLoading || currentProjectLoading) {
return;
}
const settleStart = performance.now();
const settleTimer = window.setTimeout(() => {
const msg = `dashboard ready at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount (settle delay=${Math.round(performance.now() - settleStart)}ms)`;
console.log(`[App] ${msg}`);
reportDashboardPerf("[App]", msg);
setInitialLoadComplete(true);
}, 200);
return () => {
window.clearTimeout(settleTimer);
};
}, [initialLoadComplete, projectsLoading, currentProjectLoading]);
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
// App-level mailbox unread count state (used for header/mobile nav badges)
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
const refreshMailboxUnreadCount = useCallback(() => {
fetchUnreadCount(currentProject?.id)
.then((data: { unreadCount: number }) => {
setMailboxUnreadCount(data.unreadCount);
})
.catch((err) => {
console.warn("[App] Failed to fetch mailbox unread count:", err);
});
}, [currentProject?.id]);
// Initial fetch + live updates from mailbox SSE events.
useEffect(() => {
refreshMailboxUnreadCount();
const params = new URLSearchParams();
if (currentProject?.id) {
params.set("projectId", currentProject.id);
}
const query = params.size > 0 ? `?${params.toString()}` : "";
return subscribeSse(`/api/events${query}`, {
events: {
"message:sent": refreshMailboxUnreadCount,
"message:received": refreshMailboxUnreadCount,
"message:read": refreshMailboxUnreadCount,
"message:deleted": refreshMailboxUnreadCount,
},
});
}, [currentProject?.id, refreshMailboxUnreadCount]);
// Nodes management is an overlay view (not a modal), so it stays local to App.
const [nodesOpen, setNodesOpen] = useState(false);
const [retryingProjects, setRetryingProjects] = useState(false);
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
const [quickChatOpen, setQuickChatOpen] = useState(false);
const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false);
const [setupWarningDismissed, setSetupWarningDismissed] = useState(
() => getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true",
);
useEffect(() => {
setSetupWarningDismissed(
getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true",
);
}, [currentProject?.id]);
useEffect(() => {
const handleDaemonAuthFailure = () => {
setAuthTokenRecoveryOpen(true);
};
window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure);
return () => {
window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure);
};
}, []);
const handleDismissSetupWarning = useCallback(() => {
setScopedItem(SETUP_WARNING_DISMISSED_KEY, "true", currentProject?.id);
setSetupWarningDismissed(true);
}, [currentProject?.id]);
// Settings state
const {
maxConcurrent,
autoMerge,
globalPaused,
enginePaused,
taskStuckTimeoutMs,
showQuickChatFAB,
prAuthAvailable,
experimentalFeatures,
insightsEnabled,
roadmapEnabled,
memoryEnabled,
devServerEnabled,
todosEnabled,
toggleAutoMerge,
toggleGlobalPause,
toggleEnginePause,
refresh: refreshAppSettings,
} = useAppSettings(currentProject?.id);
const skillsEnabled = experimentalFeatures.skillsView === true;
const nodesEnabled = experimentalFeatures.nodesView === true;
const agentsEnabled = true;
// Redirect to board if feature-gated views are disabled.
// Only run after settings have been loaded (experimentalFeatures is non-empty)
useEffect(() => {
if (Object.keys(experimentalFeatures).length === 0) return;
if (taskView === "insights" && !insightsEnabled) {
handleChangeTaskView("board");
}
if (taskView === "roadmaps" && !roadmapEnabled) {
handleChangeTaskView("board");
}
if (taskView === "agents" && !agentsEnabled) {
handleChangeTaskView("board");
}
if (taskView === "memory" && !memoryEnabled) {
handleChangeTaskView("board");
}
if ((taskView === "devserver" || taskView === "dev-server") && !devServerEnabled) {
handleChangeTaskView("board");
}
if (taskView === "todos" && !todosEnabled) {
handleChangeTaskView("board");
}
}, [taskView, insightsEnabled, roadmapEnabled, experimentalFeatures, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, todosEnabled]);
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
useEffect(() => {
if (nodesOpen && !nodesEnabled) {
setNodesOpen(false);
}
}, [nodesOpen, nodesEnabled]);
const {
availableModels,
favoriteProviders,
favoriteModels,
toggleFavoriteProvider,
toggleFavoriteModel,
} = useFavorites();
// Auth and onboarding bootstrap logic extracted to a dedicated hook.
useAuthOnboarding({
projectId: currentProject?.id,
setupWizardOpen: modalManager.setupWizardOpen,
openModelOnboarding: modalManager.openModelOnboarding,
openSettings: modalManager.openSettings,
});
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,
projects,
projectsLoading,
currentProject,
setCurrentProject,
addToast,
openTaskDetail: modalManager.openDetailTask,
closeTaskDetail: modalManager.closeDetailTask,
});
// Task handlers
const {
handleBoardQuickCreate,
handleModalCreate,
handlePlanningTaskCreated,
handlePlanningTasksCreated,
handleSubtaskTasksCreated,
handleGitHubImport,
} = useTaskHandlers({
createTask,
onPlanningTaskCreated: modalManager.onPlanningTaskCreated,
onPlanningTasksCreated: modalManager.onPlanningTasksCreated,
onSubtaskTasksCreated: modalManager.onSubtaskTasksCreated,
addToast,
});
const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes") => {
if (initialTab === "changes") {
modalManager.openDetailWithChangesTab(task);
return;
}
modalManager.openDetailTask(task, initialTab);
}, [modalManager]);
const handleOpenNodes = useCallback(() => {
if (!nodesEnabled) return;
setNodesOpen((prev) => !prev);
}, [nodesEnabled]);
const handleOpenProjectDirectory = useCallback(() => {
modalManager.setFileWorkspace("project");
modalManager.openFiles();
}, [modalManager]);
const handleRetryProjects = useCallback(async () => {
setRetryingProjects(true);
try {
await refreshProjects();
} finally {
setRetryingProjects(false);
}
}, [refreshProjects]);
const handleOpenMission = useCallback((missionId: string) => {
setMissionTargetId(missionId);
setMissionResumeSessionId(undefined);
handleChangeTaskView("missions");
}, [handleChangeTaskView]);
const handleOpenBackgroundSession = useCallback((session: AiSessionSummary) => {
if (session.type === "planning") {
modalManager.openPlanningWithSession(session.id);
} else if (session.type === "subtask") {
modalManager.openSubtaskWithSession(session.id);
} else if (session.type === "mission_interview") {
setMissionTargetId(undefined);
setMissionResumeSessionId(session.id);
setMilestoneSliceResumeSessionId(undefined);
handleChangeTaskView("missions");
} else if (session.type === "milestone_interview" || session.type === "slice_interview") {
// For milestone/slice interviews, we need to fetch the session to get the target ID
// Then navigate to missions view with the resume session ID
setMissionResumeSessionId(undefined);
setMissionTargetId(undefined);
setMilestoneSliceResumeSessionId(session.id);
handleChangeTaskView("missions");
}
}, [handleChangeTaskView, modalManager]);
// Dismissing the "needs input" banner only hides the prompt — it must NOT
// delete the underlying session. Sessions remain accessible from the
// Planning modal's sidebar (or the AI background tasks pill) so the user
// can return to them later. The banner already tracks dismissals locally
// via its own `dismissedIds` set, so these handlers are intentional no-ops.
const handleDismissNeedingInputSession = useCallback(() => {
// intentional no-op
}, []);
const handleDismissAllNeedingInputSessions = useCallback(() => {
// intentional no-op
}, []);
const showBackendConnectionErrorPage =
!projectsLoading &&
!currentProjectLoading &&
projects.length === 0 &&
!currentProject &&
Boolean(projectsError);
// Render main content based on view mode
const renderMainContent = () => {
if (showBackendConnectionErrorPage) {
return (
<BackendConnectionErrorPage
errorMessage={projectsError ?? "Failed to fetch projects"}
isRetrying={retryingProjects}
onRetry={handleRetryProjects}
/>
);
}
if (nodesOpen) {
return (
<div className="nodes-management-overlay">
<PageErrorBoundary>
<Suspense fallback={null}>
<NodesView addToast={addToast} onClose={() => setNodesOpen(false)} />
</Suspense>
</PageErrorBoundary>
</div>
);
}
if (viewMode === "overview") {
return (
<PageErrorBoundary>
<ProjectOverview
projects={projects}
loading={projectsLoading}
onSelectProject={handleSelectProject}
onAddProject={handleAddProject}
onPauseProject={handlePauseProject}
onResumeProject={handleResumeProject}
onRemoveProject={handleRemoveProject}
nodes={nodes}
/>
</PageErrorBoundary>
);
}
// Project view
if (taskView === "skills") {
if (!skillsEnabled) {
// Redirect to board if skills view is not enabled
handleChangeTaskView("board");
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<SkillsView
addToast={addToast}
projectId={currentProject?.id}
onClose={() => handleChangeTaskView("board")}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "chat") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<ChatView addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "mailbox") {
return (
<PageErrorBoundary>
<MailboxView
projectId={currentProject?.id}
addToast={addToast}
onUnreadCountChange={setMailboxUnreadCount}
/>
</PageErrorBoundary>
);
}
if (taskView === "roadmaps") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<RoadmapsView addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "missions") {
return (
<PageErrorBoundary>
<MissionManager
isInline={true}
isOpen={true}
onClose={() => {
setMissionTargetId(undefined);
setMissionResumeSessionId(undefined);
setMilestoneSliceResumeSessionId(undefined);
handleChangeTaskView("board");
}}
addToast={addToast}
projectId={currentProject?.id}
onSelectTask={(taskId) => {
const task = tasks.find((t) => t.id === taskId);
if (task) modalManager.openDetailTask(task as TaskDetail);
}}
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
resumeSessionId={missionResumeSessionId}
targetMissionId={missionTargetId}
milestoneSliceResumeSessionId={milestoneSliceResumeSessionId}
onMilestoneSliceResumeFetchError={() => setMilestoneSliceResumeSessionId(undefined)}
/>
</PageErrorBoundary>
);
}
if (taskView === "agents" && agentsEnabled) {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<AgentsView addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "documents") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<DocumentsView
projectId={currentProject?.id}
addToast={addToast}
onOpenDetail={modalManager.openDetailTask}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "insights") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<InsightsView
projectId={currentProject?.id}
addToast={addToast}
onClose={() => handleChangeTaskView("board")}
/>
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "memory") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<MemoryView addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "todos") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<TodoView addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "devserver" || taskView === "dev-server") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<DevServerView addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "board") {
return (
<PageErrorBoundary>
<Board
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onPauseTask={pauseTask}
onOpenDetail={modalManager.openDetailTask}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}
onNewTask={modalManager.openNewTask}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
autoMerge={autoMerge}
onToggleAutoMerge={toggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onDeleteTask={deleteTask}
onArchiveAllDone={archiveAllDone}
onLoadArchivedTasks={loadArchivedTasks}
searchQuery={searchQuery}
availableModels={availableModels}
onOpenDetailWithTab={handleOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={handleOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
/>
</PageErrorBoundary>
);
}
// List view
return (
<PageErrorBoundary>
<ListView
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
projectId={currentProject?.id}
onMoveTask={moveTask}
onOpenDetail={modalManager.openDetailTask}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={modalManager.openNewTask}
onQuickCreate={handleBoardQuickCreate}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
availableModels={availableModels}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
searchQuery={searchQuery}
lastFetchTimeMs={lastFetchTimeMs}
/>
</PageErrorBoundary>
);
};
if (!initialLoadComplete) {
return (
<>
<DashboardLoader stage={loadingStage} />
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);
}
const showOnboardingResumeCard = !modalManager.modelOnboardingOpen && isOnboardingResumable();
const showPostOnboardingRecommendations =
!modalManager.modelOnboardingOpen &&
!showOnboardingResumeCard &&
isOnboardingCompleted() &&
!isPostOnboardingDismissed();
return (
<>
<Header
isElectron={isElectron}
onOpenSettings={handleOpenSettings}
onOpenGitHubImport={modalManager.openGitHubImport}
onOpenPlanning={modalManager.openPlanning}
onResumePlanning={modalManager.resumePlanning}
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={modalManager.openUsage}
onOpenActivityLog={modalManager.openActivityLog}
onOpenSystemStats={modalManager.openSystemStats}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
onOpenSchedules={modalManager.openSchedules}
onOpenGitManager={modalManager.openGitManager}
onOpenNodes={handleOpenNodes}
showNodesButton={nodesEnabled}
onOpenWorkflowSteps={modalManager.openWorkflowSteps}
onOpenScripts={modalManager.openScripts}
onRunScript={modalManager.runScript}
onToggleTerminal={modalManager.toggleTerminal}
onOpenFiles={modalManager.openFiles}
filesOpen={modalManager.filesOpen}
globalPaused={globalPaused}
enginePaused={enginePaused}
onToggleGlobalPause={toggleGlobalPause}
onToggleEnginePause={toggleEnginePause}
view={taskView}
onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : undefined}
showSkillsTab={skillsEnabled}
showAgentsTab={agentsEnabled}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
projects={effectiveProjects}
currentProject={currentProject}
onSelectProject={handleSelectProject}
onViewAllProjects={handleViewAllProjects}
projectId={currentProject?.id}
mobileNavEnabled={isMobile}
// Node switching props
availableNodes={nodes}
currentNode={currentNode}
onSelectNode={(node) => {
if (node === null) {
clearCurrentNode();
} else {
setCurrentNode(node);
}
}}
isRemote={isRemote}
experimentalFeatures={{
insights: insightsEnabled,
roadmap: roadmapEnabled,
memoryView: memoryEnabled,
devServer: devServerEnabled,
devServerView: devServerEnabled,
todoView: todosEnabled,
}}
/>
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && (
<SessionNotificationBanner
sessions={sessionsNeedingInput}
onResumeSession={handleOpenBackgroundSession}
onDismissSession={handleDismissNeedingInputSession}
onDismissAll={handleDismissAllNeedingInputSessions}
/>
)}
{viewMode === "project" && currentProject && showOnboardingResumeCard && (
<OnboardingResumeCard onResume={modalManager.openModelOnboarding} />
)}
{viewMode === "project" && currentProject && showPostOnboardingRecommendations && (
<PostOnboardingRecommendations
onOpenModelOnboarding={modalManager.openModelOnboarding}
onOpenSettings={(section) => modalManager.openSettings(section as SectionId)}
/>
)}
{viewMode === "project" && currentProject && updateAvailable && latestVersion && currentVersion && !updateBannerDismissed && (
<UpdateAvailableBanner
latestVersion={latestVersion}
currentVersion={currentVersion}
onDismiss={dismissUpdateBanner}
/>
)}
{viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && (
<SetupWarningBanner
hasAiProvider={hasAiProvider}
hasGithub={hasGithub}
onDismiss={handleDismissSetupWarning}
/>
)}
<div
className={`project-content${viewMode === "project" && currentProject ? " project-content--with-footer" : ""}${isMobile ? " project-content--with-mobile-nav" : ""}`}
>
{renderMainContent()}
</div>
{viewMode === "project" && currentProject && !nodesOpen && (
<ExecutorStatusBar
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
projectId={currentProject.id}
taskStuckTimeoutMs={taskStuckTimeoutMs}
backgroundSessions={bgSessions}
backgroundGenerating={bgGenerating}
backgroundNeedsInput={bgNeedsInput}
onOpenBackgroundSession={handleOpenBackgroundSession}
onDismissBackgroundSession={bgDismiss}
lastFetchTimeMs={lastFetchTimeMs}
currentProjectPath={currentProject.path}
onOpenProjectDirectory={handleOpenProjectDirectory}
/>
)}
<MobileNavBar
view={taskView}
onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : () => {}}
footerVisible={viewMode === "project" && !!currentProject}
modalOpen={modalManager.anyModalOpen}
onOpenSettings={handleOpenSettings}
onOpenActivityLog={modalManager.openActivityLog}
onOpenSystemStats={modalManager.openSystemStats}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
onOpenGitManager={modalManager.openGitManager}
onOpenWorkflowSteps={modalManager.openWorkflowSteps}
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={() => modalManager.openUsage(null)}
onViewAllProjects={handleViewAllProjects}
onRunScript={modalManager.runScript}
projectId={currentProject?.id}
showSkillsTab={skillsEnabled}
experimentalFeatures={{
insights: insightsEnabled,
roadmap: roadmapEnabled,
memoryView: memoryEnabled,
devServer: devServerEnabled,
devServerView: devServerEnabled,
todoView: todosEnabled,
}}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && (
<QuickChatFAB
projectId={currentProject.id}
addToast={addToast}
showFAB={showQuickChatFAB}
open={quickChatOpen}
onOpenChange={setQuickChatOpen}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
/>
)}
<AppModals
projectId={currentProject?.id}
tasks={tasks}
projects={projects}
currentProject={currentProject}
addToast={addToast}
toasts={toasts}
removeToast={removeToast}
modalManager={modalManager}
projectActions={{ handleAddProject, handleSetupComplete, handleModelOnboardingComplete }}
taskHandlers={{
handleModalCreate,
handlePlanningTaskCreated,
handlePlanningTasksCreated,
handleSubtaskTasksCreated,
handleGitHubImport,
}}
taskOperations={{ moveTask, deleteTask, mergeTask, retryTask, duplicateTask }}
deepLink={{ handleDetailClose }}
settings={{ prAuthAvailable, themeMode, colorTheme, setThemeMode, setColorTheme }}
onSettingsClose={() => {
modalManager.closeSettings();
void refreshAppSettings();
}}
onReopenOnboarding={() => {
modalManager.closeSettings();
modalManager.openModelOnboarding();
}}
/>
<AuthTokenRecoveryDialog open={authTokenRecoveryOpen} />
</>
);
}
export function App() {
return (
<ToastProvider>
<NodeProvider>
<ConfirmDialogProvider>
<AppInner />
</ConfirmDialogProvider>
</NodeProvider>
</ToastProvider>
);
}