Files
fusion/packages/dashboard/app/App.tsx
gsxdsm 3ee47dec86 merge: integrate origin/main into feature/workflow-steps (FN-7039)
Resolve conflicts from 56 upstream commits:
- db.ts: renumber my migrations around main's new migration 130 (columnDwellMs) —
  enable-id normalization 130→131, drop-table 131→132, SCHEMA_VERSION→132.
- index.ts / routes.ts: take main's new MCP exports/imports; keep WORKFLOW_STEP_TEMPLATES
  array removed (kept the WorkflowStepTemplate type).
- merger.ts: keep the legacy post-merge execution path removed (U7c) over main's version.
- ci-workflow.test.ts (from main): add port-4040/process-supervisor allowlist markers to
  the gate-script assertions that reference the checker filenames (pre-existing self-match).

Gate green (engine-core 299, ci-shape 62); boot smoke PASS; migration tests + typecheck clean.
2026-06-26 08:50:22 -07:00

1655 lines
71 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 { useTranslation } from "react-i18next";
import {
type Task,
type TaskDetail,
type WorkflowStep,
} from "@fusion/core";
import { Header, useViewportMode } from "./components/Header";
import { TaskDetailContent } from "./components/TaskDetailModal";
import { FloatingWindow } from "./components/FloatingWindow";
import { AppModals } from "./components/AppModals";
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
import { TopProgressBar } from "./components/TopProgressBar";
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { type CliActionId } from "./components/SessionNotificationBanner";
import {
isOnboardingCompleted,
isOnboardingResumable,
isPostOnboardingDismissed,
} from "./components/model-onboarding-state";
import type { SectionId } from "./components/SettingsModal";
import { MobileNavBar } from "./components/MobileNavBar";
import { LeftSidebarNav } from "./components/LeftSidebarNav";
import { useRightDockController } from "./components/useRightDockController";
import { QuickChatFAB } from "./components/QuickChatFAB";
import { ToastContainer } from "./components/ToastContainer";
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
import { useGitHubStarPromptShown, markGitHubStarPromptShown } from "./hooks/useGitHubStarPrompt";
import { useSessionBannersHidden } from "./hooks/useSessionBannerPref";
import { useTasks } from "./hooks/useTasks";
import { useProjects } from "./hooks/useProjects";
import { useAgents } from "./hooks/useAgents";
import { useNodes } from "./hooks/useNodes";
import { useCurrentProject } from "./hooks/useCurrentProject";
import { I18nextProvider } from "react-i18next";
import i18n from "./i18n";
import { ToastProvider, useToast } from "./hooks/useToast";
import { ConfirmDialogProvider } from "./hooks/useConfirm";
import { useTheme } from "./hooks/useTheme";
import { useModalManager, type DetailTaskOrigin, type DetailTaskTab } from "./hooks/useModalManager";
import { useAppSettings } from "./hooks/useAppSettings";
import { useDeepLink } from "./hooks/useDeepLink";
import { useFavorites } from "./hooks/useFavorites";
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
import { useMobileKeyboard } from "./hooks/useMobileKeyboard";
import { isIOS, useMobileKeyboardViewportLock, useMobileViewportRestoreReset } from "./hooks/useMobileScrollLock";
import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags";
import { useSetupReadiness } from "./hooks/useSetupReadiness";
import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { useViewState, type TaskView } from "./hooks/useViewState";
import { NavigationHistoryProvider, useNavigationHistory } from "./hooks/useNavigationHistory";
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
import { isPluginViewId, isPluginViewRegistered } from "./plugins/pluginViewRegistry";
import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews";
import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers";
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
import { isLikelyTabSuspensionError } from "./hooks/visibilitySuspension";
import { NodeProvider, useNodeContext } from "./context/NodeContext";
import { FileBrowserProvider } from "./context/FileBrowserContext";
import { ShellProvider } from "./context/ShellContext";
import { RetryWarningProvider } from "./context/RetryWarningContext";
import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext";
import { useShellConnection } from "./hooks/useShellConnection";
import { useStashOrphanCount } from "./hooks/useStashOrphanCount";
import { useChatUnreadBadge } from "./hooks/useChatUnreadBadge";
import { useMailboxUnread } from "./hooks/useMailboxUnread";
import { useApprovalBanner } from "./hooks/useApprovalBanner";
import { useBranchTaskFilters } from "./hooks/useBranchTaskFilters";
import { useDashboardHealth } from "./hooks/useDashboardHealth";
import { useAuthTokenRecovery } from "./hooks/useAuthTokenRecovery";
import { useScopedDismissFlag } from "./hooks/useScopedDismissFlag";
import { useCapacityRiskBanner } from "./hooks/useCapacityRiskBanner";
import { useMainPanelTaskDetail } from "./hooks/useMainPanelTaskDetail";
import { useBoardScrollRestore } from "./hooks/useBoardScrollRestore";
import { usePoppedOutTasks } from "./hooks/usePoppedOutTasks";
import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal";
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
import { ShellConnectionStatus } from "./components/ShellConnectionStatus";
import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native";
import type { AiSessionSummary, PluginDashboardViewEntry } from "./api";
import { fetchTaskDetail, fetchWorkflowSteps } from "./api";
import {
SETUP_WARNING_DISMISSED_KEY,
RETRY_WARNING_RATIO,
buildRemoteDashboardUrl,
requiresNativeShellOnboarding,
shouldShowFirstEverBootLoader,
isSessionNeedingInputForBanner,
getCliActionDisabledReasonForBanner,
executeCliSessionBannerAction,
} from "./utils/appLifecycle";
// Re-export the unit-tested lifecycle helpers so existing `from "./App"` /
// `from "../../App"` imports keep resolving after the bodies moved to utils.
export {
didEnterAwaitingApproval,
didEnterDone,
requiresNativeShellOnboarding,
shouldShowFirstEverBootLoader,
isSessionNeedingInputForBanner,
getCliActionDisabledReasonForBanner,
executeCliSessionBannerAction,
} from "./utils/appLifecycle";
import { subscribeSse } from "./sse-bus";
import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
import { MainContent } from "./components/dashboard/MainContent";
import { DashboardBanners } from "./components/dashboard/DashboardBanners";
import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types";
import type { GraphWorkflowSelection } from "./components/GraphWorkflowSwitcherSlot";
// 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 IS_TEST_ENV = import.meta.env.MODE === "test";
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 ResearchView = lazy(() => import("./components/ResearchView").then((m) => ({ default: m.ResearchView })));
const EvalsView = lazy(() => import("./components/EvalsView").then((m) => ({ default: m.EvalsView })));
const ChatView = lazy(() => import("./components/ChatView").then((m) => ({ default: m.ChatView })));
const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView })));
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
const SecretsView = lazy(() => import("./components/SecretsView").then((m) => ({ default: m.SecretsView })));
const CommandCenter = lazy(() => import("./components/command-center/CommandCenter").then((m) => ({ default: m.CommandCenter })));
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
const GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView })));
const PullRequestView = lazy(() => import("./components/PullRequestView").then((m) => ({ default: m.PullRequestView })));
/*
FNXC:Navigation 2026-06-22-00:00:
Workflows, Import Tasks (GitHub import), and Automations render as embedded main-content views (presentation="embedded") via these lazy chunks; the same components still mount as modals in AppModals for the mobile overflow path.
*/
/*
FNXC:DashboardLazyViews 2026-06-22-00:00:
WorkflowEditorView, ImportTasksView, and AutomationsView are embedded main-content presentations that REUSE already-documented chunks (WorkflowNodeEditor, plus the GitHub import and scheduled-tasks modals mounted in AppModals). They are excluded from the curated "Lazy-Loaded Heavy Views" App-level inventory via the leading-underscore convention so the docs guard counts each heavy chunk once; renaming the underlying component would double-count it.
*/
const _WorkflowEditorView = lazy(() => import("./components/WorkflowNodeEditor").then((m) => ({ default: m.WorkflowNodeEditor })));
const _ImportTasksView = lazy(() => import("./components/GitHubImportModal").then((m) => ({ default: m.GitHubImportModal })));
const _AutomationsView = lazy(() => import("./components/ScheduledTasksModal").then((m) => ({ default: m.ScheduledTasksModal })));
/*
FNXC:Settings 2026-06-22-00:00:
SettingsView is the embedded main-content presentation of the SettingsModal chunk. It REUSES the already-documented SettingsModal lazy chunk (mounted in AppModals), so it uses the leading-underscore convention to stay out of the curated "Lazy-Loaded Heavy Views" inventory and avoid double-counting.
*/
const _SettingsView = lazy(() => import("./components/SettingsModal").then((m) => ({ default: m.SettingsView })));
// Warm lazy chunks during browser idle so first navigation to each view is
// instant. Each chunk is ~10–80 kB; total prefetch finishes well under a
// second on broadband. Uses requestIdleCallback so it never blocks render.
function prefetchLazyViews() {
if (IS_TEST_ENV) {
return;
}
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/ResearchView");
void import("./components/EvalsView");
void import("./components/ChatView");
void import("./components/SkillsView");
void import("./components/MemoryView");
void import("./components/SecretsView");
void import("./components/command-center/CommandCenter");
void import("./components/DevServerView");
void import("./components/TodoView");
void import("./components/GoalsView");
void import("./components/PullRequestView");
});
}
registerBundledPluginViews();
function AppInner() {
const { t } = useTranslation("app");
const { toasts, addToast, removeToast } = useToast();
const { shellApi, state: shellState, ready: shellReady, openConnectionManagerSignal } = useShellConnection();
const shellHost = useShellHostContext();
// 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 hasEverLoadedProjectsRef = useRef(projects.length > 0);
const { nodes } = useNodes();
useEffect(() => {
if (projects.length > 0) {
hasEverLoadedProjectsRef.current = true;
}
}, [projects.length]);
// 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("");
// Host capability handed to plugin dashboard views: subscribe to a plugin's
// custom SSE events (forwarded by the server as `plugin:custom`, scoped to the
// current project) over the shared bus — so plugins push live updates without
// deep-importing the dashboard's sse-bus or opening their own EventSource.
const subscribePluginEvents = useCallback(
(pluginId: string, onEvent: (e: { event: string; payload: unknown }) => void) => {
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: {
"plugin:custom": (event: MessageEvent) => {
try {
const d = JSON.parse(event.data) as { pluginId?: string; event?: string; payload?: unknown };
if (d.pluginId === pluginId && typeof d.event === "string") {
onEvent({ event: d.event, payload: d.payload });
}
} catch {
// Ignore malformed plugin:custom payloads.
}
},
},
});
},
[currentProject?.id],
);
// 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, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors } = useTheme();
// Background AI sessions - required before useModalManager
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
/*
* FNXC:SessionBanner 2026-06-14-19:32:
* CLI agent sessions use `waiting_on_input` and `needs_attention` to represent user-actionable states. The banner feed must include those statuses in addition to the legacy planning-session statuses so visible CLI actions cannot be silently hidden from users.
*/
const sessionsNeedingInput = bgSessions.filter(isSessionNeedingInputForBanner);
const sessionBannersHidden = useSessionBannersHidden();
// Modal state/handlers - required before useViewState
const modalManager = useModalManager({
projectId: currentProject?.id,
planningSessions: bgPlanningSessions,
});
// Viewport mode and mobile detection — MUST be before useViewState so that
// useNavigationHistory (and pushNav) are defined before handleTaskViewChange
// references them, avoiding a TDZ violation.
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
// Navigation history for browser back button (desktop + mobile).
const { pushNav, replaceCurrent, removeNav } = useNavigationHistory({ enabled: true });
// 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 { views: rawPluginDashboardViews } = usePluginDashboardViews(currentProject?.id);
const graphPluginTaskView = useMemo(() => {
// Prefer API response for the graph view (supports dynamic plugin discovery)
const graphView = rawPluginDashboardViews.find(
(entry) => entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph",
);
if (graphView) return `plugin:${graphView.pluginId}:${graphView.view.viewId}` as const;
// Fall back to bundled static registration so the graph view works even when
// the plugin is not installed/loaded through the API (e.g. fresh DB).
if (isPluginViewRegistered("fusion-plugin-dependency-graph", "graph")) {
return `plugin:fusion-plugin-dependency-graph:graph` as const;
}
return null;
}, [rawPluginDashboardViews]);
// History-aware view change handler — pushes nav entry on back-navigation stack.
const handleTaskViewChange = useCallback((newView: TaskView) => {
if (newView === "missions") {
setMissionResumeSessionId(undefined);
setMissionTargetId(undefined);
setMilestoneSliceResumeSessionId(undefined);
}
if (newView !== "goalsView") {
setGoalAnchorId(undefined);
}
const previousView = taskView;
handleChangeTaskView(newView);
if (previousView !== newView) {
pushNav({ type: "view", revert: () => handleChangeTaskView(previousView) });
}
}, [handleChangeTaskView, taskView, pushNav]);
// FNXC:DashboardLiveUpdates 2026-06-26-01:08:
// SSE remains enabled only for board/list views to free connection slots for mission detail fetches. The false→true missed-event catch-up lives inside useTasks so App keeps the routing gate only and cannot double-fetch on task-view re-entry.
const taskSseEnabled = taskView === "board" || taskView === "list";
const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks(
{
...(currentProject ? { projectId: currentProject.id } : {}),
searchQuery: searchQuery || undefined,
sseEnabled: taskSseEnabled,
}
);
/*
FNXC:Navigation 2026-06-22-00:00:
Snapshot of the task whose detail is shown in the main panel (Board card click → full-panel detail). Kept as a snapshot so the view survives a tasks revalidation; renderMainContent prefers the live row from `tasks` by id and falls back to this snapshot.
FNXC:TaskDetail 2026-06-23-00:41:
Board task-card secondary actions can deep-link into the inline main-panel task detail. Files-changed must land on the embedded Changes tab instead of reopening the task in the modal path.
*/
const { task: mainPanelDetailTask, initialTab: mainPanelDetailInitialTab, setTask: setMainPanelDetailTask, setInitialTab: setMainPanelDetailInitialTab } = useMainPanelTaskDetail();
const { capture: captureCurrentBoardScrollSnapshot, requestRestore } = useBoardScrollRestore(taskView);
const mainPanelDetailNavRevertRef = useRef<(() => void) | null>(null);
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Open popped-out task-detail windows. Each entry is a task snapshot rendered inside its own movable, resizable, non-blocking FloatingWindow. Several can be open at once and coexist with the right-dock pop-out and terminal (all click-through overlays). Snapshots survive a tasks revalidation; rendering prefers the live row by id and falls back to the snapshot. Pop-out dedupes by task id — re-popping an already-open task is a no-op (its window stays; focus-to-front in FloatingWindow handles re-raising on click).
*/
const { tasks: poppedOutTasks, popOut: popOutTaskDetail, close: closePoppedOutTask } = usePoppedOutTasks();
const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
const [graphWorkflowSelection, setGraphWorkflowSelection] = useState<GraphWorkflowSelection | null>(null);
const [researchReadinessVersion, setResearchReadinessVersion] = useState(0);
const mountTimeRef = useRef(performance.now());
const projectsReadyLoggedRef = useRef(false);
const projectReadyLoggedRef = useRef(false);
const dashboardReadyLoggedRef = 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`;
if (!IS_TEST_ENV) {
console.log(`[App] ${msg}`);
}
}
if (!currentProjectLoading && !projectReadyLoggedRef.current) {
projectReadyLoggedRef.current = true;
const msg = `current-project resolved at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
if (!IS_TEST_ENV) {
console.log(`[App] ${msg}`);
}
}
}, [projectsLoading, currentProjectLoading]);
const initialLoadComplete = !projectsLoading && !currentProjectLoading;
const isFirstEverBoot = shouldShowFirstEverBootLoader(projectsLoading, projects.length);
useEffect(() => {
if (!initialLoadComplete || dashboardReadyLoggedRef.current) {
return;
}
dashboardReadyLoggedRef.current = true;
const msg = `dashboard ready at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
if (!IS_TEST_ENV) {
console.log(`[App] ${msg}`);
}
}, [initialLoadComplete]);
const [quickChatOpen, setQuickChatOpen] = useState(false);
const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
// Keyboard visibility controls both MobileNavBar rendering and whether
// the project content reserves bottom padding for the mobile nav bar.
// When a modal is open, modal-local inputs can trigger the keyboard without
// affecting the underlying dashboard layout — the modal handles its own
// viewport. Without this guard, modal keyboard state leaks into the app-level
// layout, causing stale bottom-padding offsets after the keyboard closes.
//
// Android-gated: with `interactive-widget=resizes-content` the layout
// viewport itself shrinks with the soft keyboard, so we DON'T want to
// also hide the nav bar / strip its padding — that produces a layout
// jump while the focused input is settling, which Android Chrome treats
// as the focus target moving and dismisses the keyboard immediately.
// iOS doesn't shrink the layout viewport, so the iOS path keeps the
// hide-nav-on-keyboard behavior intact.
//
// FN-5707: keep nav pinning cross-platform, but only apply the footer
// keyboard-collapse class on iOS. On Android, collapsing the footer to
// `bottom: 0` overlaps it with the nav bar because the layout viewport
// already shrinks and the stacked footer position is already correct.
const { footerHidden, navKeyboardOpen, footerKeyboardOpen } = computeMobileBarKeyboardFlags({
isMobile,
keyboardOpen,
anyModalOpen: modalManager.anyModalOpen,
overlayOpen: isMobile && quickChatOpen,
isIOS: isIOS(),
});
const mobileKeyboardOpen = footerHidden;
const mobileNavKeyboardOpen = navKeyboardOpen;
// App-level scroll lock for inline editing (TaskCard inline edit, etc.):
// when the keyboard is up outside of any modal, pin the body so iOS can't
// shift the document or visualViewport, and so the dashboard snaps back
// into place when the keyboard dismisses. Modals manage their own lock
// via useMobileScrollLock — the reference-counted hook handles overlap.
useMobileKeyboardViewportLock(mobileKeyboardOpen);
// Complements FN-6362's keyboard metrics reset by recovering stale document scroll on foreground.
useMobileViewportRestoreReset(isMobile);
// App-level mailbox/chat unread state (used for header/mobile nav badges)
const { mailboxUnreadCount, mailboxPendingApprovalCount, setMailboxUnreadCount, refresh: mailboxRefresh } = useMailboxUnread(currentProject?.id);
const { chatHasUnreadResponse } = useChatUnreadBadge(currentProject?.id, { taskView, quickChatOpen });
const { stashOrphanCount } = useStashOrphanCount(currentProject?.id);
const [showGitHubStarPrompt, setShowGitHubStarPrompt] = useState(false);
const gitHubStarPromptShown = useGitHubStarPromptShown();
const handleStarPrompt = useCallback(() => setShowGitHubStarPrompt(true), []);
const { candidate: approvalBannerCandidate, dismissApproval } = useApprovalBanner({
tasks,
currentProjectId: currentProject?.id,
gitHubStarPromptShown,
onStarPrompt: handleStarPrompt,
onMailboxRefresh: mailboxRefresh,
});
const {
branchFilter,
baseBranchFilter,
branchOptions,
baseBranchOptions,
filteredBoardTasks,
onBranchFilterChange: handleBranchFilterChange,
onBaseBranchFilterChange: handleBaseBranchFilterChange,
} = useBranchTaskFilters({ boardSourceTasks, currentProjectId: currentProject?.id });
const [retryingProjects, setRetryingProjects] = useState(false);
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
const [goalAnchorId, setGoalAnchorId] = useState<string | undefined>(undefined);
const [selectedPrId, setSelectedPrId] = useState<string | undefined>(() => {
if (typeof window === "undefined") return undefined;
const v = new URL(window.location.href).searchParams.get("pr");
return v ?? undefined;
});
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
useEffect(() => {
if (taskView !== "goalsView" && goalAnchorId !== undefined) {
setGoalAnchorId(undefined);
}
}, [goalAnchorId, taskView]);
useEffect(() => {
if (taskView !== "pull-requests" && selectedPrId !== undefined) {
setSelectedPrId(undefined);
}
}, [selectedPrId, taskView]);
const { open: authTokenRecoveryOpen } = useAuthTokenRecovery();
const {
health: dashboardHealth,
setHealth: setDashboardHealth,
refreshing: dbCorruptionRefreshing,
refreshError: dbCorruptionRefreshError,
refresh: refreshDbCorruptionHealth,
} = useDashboardHealth();
const { dismissed: setupWarningDismissed, dismiss: handleDismissSetupWarning } = useScopedDismissFlag(SETUP_WARNING_DISMISSED_KEY, currentProject?.id);
// Settings state
const {
maxConcurrent,
autoMerge,
globalPaused,
isTestMode,
taskStuckTimeoutMs,
staleHighFanoutBlockerAgeThresholdMs,
capacityRiskBannerEnabled,
capacityRiskTodoThreshold,
quickChatButtonMode,
maxTotalRetriesBeforeFail,
prAuthAvailable,
settingsLoaded,
experimentalFeatures,
insightsEnabled,
memoryEnabled,
devServerEnabled,
todosEnabled,
goalsEnabled,
setQuickChatButtonModeImmediate,
toggleAutoMerge,
refresh: refreshAppSettings,
} = useAppSettings(currentProject?.id);
const pluginDashboardViews = useMemo<PluginDashboardViewEntry[]>(() => {
/*
FNXC:RoadmapsNavigation 2026-06-22-18:50:
The roadmap app view and experimental toggle were removed from the dashboard surface. Filter any plugin-provided Roadmaps dashboard view here so an installed/persisted plugin cannot reintroduce the sidebar destination.
*/
return rawPluginDashboardViews.filter(
(entry) => !(entry.pluginId === "fusion-plugin-roadmap" && entry.view.viewId === "roadmaps"),
);
}, [rawPluginDashboardViews]);
const { stats: agentStats } = useAgents(currentProject?.id);
const inProgressCount = useMemo(
() => boardSourceTasks.filter((task) => task.column === "in-progress").length,
[boardSourceTasks],
);
const inReviewCount = useMemo(
() => boardSourceTasks.filter((task) => task.column === "in-review").length,
[boardSourceTasks],
);
const { signal: capacityRiskSignal, dismissed: capacityRiskDismissed, dismiss: handleDismissCapacityRisk } = useCapacityRiskBanner({
agentStats,
inProgressCount,
inReviewCount,
capacityRiskBannerEnabled,
capacityRiskTodoThreshold,
settingsLoaded,
currentProjectId: currentProject?.id,
});
/* FNXC:DefaultNavigation 2026-06-23-01:26: Skills graduated from Experimental and should remain visible on upgrades even when stale `experimentalFeatures.skillsView=false` is present. */
const skillsEnabled = true;
const nodesEnabled = experimentalFeatures.nodesView === true;
const researchEnabled = experimentalFeatures.researchView === true;
const evalsEnabled = experimentalFeatures.evalsView === true;
/* FNXC:QuickAddSubtaskFlag 2026-06-21-00:00: Missing or false `subtaskBreakdown` settings must hide the AI Subtask quick-add handoff across List, Board, and New Task Modal surfaces; only an explicit true wires the callback. */
const subtaskBreakdownEnabled = experimentalFeatures.subtaskBreakdown === true;
/*
FNXC:Navigation 2026-06-19-00:00:
Experimental left sidebar navigation replaces the Header view shortcuts with a persistent sidebar on non-mobile project screens, while mobile continues to use the bottom navigation bar as the only primary navigation surface.
FNXC:Navigation 2026-06-21-00:00:
Left sidebar navigation is now the default primary navigation on non-mobile project screens. Keep `leftSidebarNav: false` as the explicit opt-out and keep mobile on the bottom navigation bar.
*/
const leftSidebarNavEnabled = experimentalFeatures.leftSidebarNav !== false;
/* FNXC:Navigation 2026-06-22-18:00: The right dock panel is no longer experimental or user-toggleable; tablet/desktop project screens always support it regardless of any stale persisted `rightDock` setting. */
const rightDockEnabled = true;
const executorFooterVisible = viewMode === "project" && !!currentProject;
const rightDockActive = rightDockEnabled && !isMobile && executorFooterVisible;
const sidebarActive = leftSidebarNavEnabled && !isMobile && executorFooterVisible;
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
const agentsEnabled = true;
// Settings close handler with side effects — used by both AppModals
// onSettingsClose and the nav entry close callback so back-navigation
// also refreshes app settings and increments research-readiness.
// MUST be defined after useAppSettings so refreshAppSettings is not TDZ.
const handleSettingsClose = useCallback(() => {
modalManager.closeSettings();
setResearchReadinessVersion((current) => current + 1);
void refreshAppSettings();
}, [modalManager, refreshAppSettings]);
const handleSettingsCloseWithNav = useCallback(() => {
removeNav(handleSettingsClose);
handleSettingsClose();
}, [handleSettingsClose, removeNav]);
// Redirect to board if feature-gated views are disabled.
useEffect(() => {
if (!settingsLoaded) return;
if (isPluginViewId(taskView)) return;
if (taskView === "graph" && !graphPluginTaskView) {
handleChangeTaskView("board");
return;
}
if (taskView === "skills" && !skillsEnabled) {
handleChangeTaskView("board");
}
if (taskView === "insights" && !insightsEnabled) {
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 === "research" && !researchEnabled) {
handleChangeTaskView("board");
}
if (taskView === "evals" && !evalsEnabled) {
handleChangeTaskView("board");
}
if (taskView === "goalsView" && !goalsEnabled) {
handleChangeTaskView("board");
}
if (taskView === "todos" && !todosEnabled) {
handleChangeTaskView("board");
}
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, goalsEnabled, todosEnabled, graphPluginTaskView]);
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: _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,
});
const handleInsightTaskCreate = useCallback(
async ({ insightId, title, description }: { insightId: string; title: string; description: string }) => {
await createTask({
title,
description,
column: "triage",
source: {
sourceType: "dashboard_ui",
sourceMetadata: {
origin: "insights",
insightId,
},
},
});
},
[createTask],
);
// Task handlers
const {
handleBoardQuickCreate,
handleModalCreate,
handlePlanningTaskCreated,
handlePlanningTasksCreated,
handleSubtaskTasksCreated,
handleGitHubImport,
} = useTaskHandlers({
createTask,
ingestCreatedTasks,
onPlanningTaskCreated: modalManager.onPlanningTaskCreated,
onPlanningTasksCreated: modalManager.onPlanningTasksCreated,
onSubtaskTasksCreated: modalManager.onSubtaskTasksCreated,
addToast,
});
const handleOpenTaskLogs = useCallback(async (taskId: string) => {
try {
const task = await fetchTaskDetail(taskId, currentProject?.id);
modalManager.openDetailTask(task, "logs");
pushNav({ type: "modal", close: modalManager.closeDetailTask });
} catch (err) {
addToast(`Failed to open task logs: ${(err as Error).message}`, "error");
}
}, [modalManager, currentProject?.id, addToast, pushNav]);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
useEffect(() => {
let cancelled = false;
fetchWorkflowSteps(currentProject?.id)
.then((steps) => {
if (!cancelled) {
setWorkflowSteps(steps);
}
})
.catch(() => {
if (!cancelled) {
setWorkflowSteps([]);
}
});
return () => {
cancelled = true;
};
}, [currentProject?.id]);
// History-aware modal open handlers — push nav entries for back-navigation.
const openDetailTask = useCallback((task: Task | TaskDetail, tab?: Parameters<typeof modalManager.openDetailTask>[1], opts?: { origin?: DetailTaskOrigin }) => {
modalManager.openDetailTask(task, tab, opts);
pushNav({ type: "modal", close: modalManager.closeDetailTask });
}, [modalManager, pushNav]);
/*
FNXC:Navigation 2026-06-22-00:00:
Board card clicks open task detail as a full main-content view that replaces the board (design: "Full main panel (replaces board)"), instead of the TaskDetailModal overlay. We store a snapshot of the clicked task and navigate to the registered `task-detail` view; renderMainContent renders TaskDetailContent embedded with a Back-to-board button. Only the Board uses this handler — list-view split-detail, right-dock cards, and other openDetail callers keep the modal behavior.
FNXC:TaskDetailBack 2026-06-25-00:00:
Browser and Android Back must close the currently viewed full-panel task detail before leaving the prior dashboard view. The history entry owns an idempotent revert callback that clears stale snapshot state for board/list origins or restores the previous task snapshot for nested task-detail links, and explicit Back-to-board consumes that same entry without pushing a contradictory view entry during popstate.
*/
const openTaskDetailInMainPanel = useCallback((task: Task | TaskDetail, initialTab: DetailTaskTab = "chat") => {
const previousView = taskView;
const previousDetailTask = mainPanelDetailTask;
const previousDetailTab = mainPanelDetailInitialTab;
if (previousView === "task-detail" && previousDetailTask?.id === task.id && previousDetailTab === initialTab) {
setMainPanelDetailTask(task);
return;
}
if (previousView !== "task-detail") {
captureCurrentBoardScrollSnapshot();
}
const revertMainPanelDetail = () => {
if (previousView === "task-detail" && previousDetailTask) {
setMainPanelDetailTask(previousDetailTask);
setMainPanelDetailInitialTab(previousDetailTab);
handleChangeTaskView("task-detail");
mainPanelDetailNavRevertRef.current = null;
return;
}
requestRestore();
setMainPanelDetailTask(null);
setMainPanelDetailInitialTab("chat");
handleChangeTaskView(previousView);
mainPanelDetailNavRevertRef.current = null;
};
setMainPanelDetailTask(task);
setMainPanelDetailInitialTab(initialTab);
handleChangeTaskView("task-detail");
mainPanelDetailNavRevertRef.current = revertMainPanelDetail;
pushNav({ type: "view", revert: revertMainPanelDetail });
}, [captureCurrentBoardScrollSnapshot, handleChangeTaskView, mainPanelDetailInitialTab, mainPanelDetailTask, pushNav, requestRestore, taskView]);
// FNXC:Navigation 2026-06-22-00:00: Leaving task-detail clears the snapshot so a stale task never lingers if the view is reopened empty.
const closeTaskDetailMainPanel = useCallback(() => {
const revert = mainPanelDetailNavRevertRef.current;
if (revert) {
removeNav(revert);
mainPanelDetailNavRevertRef.current = null;
}
requestRestore();
setMainPanelDetailTask(null);
setMainPanelDetailInitialTab("chat");
handleChangeTaskView("board");
}, [handleChangeTaskView, removeNav, requestRestore]);
const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => {
if (initialTab === "changes") {
openTaskDetailInMainPanel(task, "changes");
return;
}
modalManager.openDetailTask(task, initialTab);
pushNav({ type: "modal", close: modalManager.closeDetailTask });
}, [modalManager, openTaskDetailInMainPanel, pushNav]);
/*
FNXC:Settings 2026-06-22-00:00:
Settings is now a main-content destination. The header/sidebar entry points navigate to the embedded `settings` view (carrying the requested deep-link section via setSettingsSection) instead of opening the modal overlay. handleTaskViewChange owns the back-navigation history entry, so no modal nav entry is pushed here.
*/
const openSettingsWithNav = useCallback((section?: Parameters<typeof modalManager.openSettings>[0]) => {
modalManager.setSettingsSection(section);
handleTaskViewChange("settings");
}, [modalManager, handleTaskViewChange]);
const openNewTaskWithNav = useCallback(() => {
modalManager.openNewTask();
pushNav({ type: "modal", close: modalManager.closeNewTask });
}, [modalManager, pushNav]);
/*
FNXC:Navigation 2026-06-21-00:00:
FN-6886 keeps the existing planning payload setters but routes every programmatic Planning Mode entry point to the docked `planning` view instead of pushing a modal overlay history entry.
*/
const openPlanningWithNav = useCallback(() => {
modalManager.openPlanning();
handleTaskViewChange("planning");
}, [handleTaskViewChange, modalManager]);
const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string, workflowId?: string | null) => {
modalManager.openPlanningWithInitialPlan(initialPlan, workflowId);
handleTaskViewChange("planning");
}, [handleTaskViewChange, modalManager]);
const resumePlanningWithNav = useCallback(() => {
modalManager.resumePlanning();
handleTaskViewChange("planning");
}, [handleTaskViewChange, modalManager]);
const openSubtaskBreakdownWithNav = useCallback((description: string, workflowId?: string | null) => {
modalManager.openSubtaskBreakdown(description, workflowId);
pushNav({ type: "modal", close: modalManager.closeSubtask });
}, [modalManager, pushNav]);
const openGroupModalWithNav = useCallback((groupId: string) => {
modalManager.openGroupModal(groupId);
pushNav({ type: "modal", close: modalManager.closeGroupModal });
}, [modalManager, pushNav]);
const openGitHubImportWithNav = useCallback(() => {
modalManager.openGitHubImport();
pushNav({ type: "modal", close: modalManager.closeGitHubImport });
}, [modalManager, pushNav]);
const toggleTerminalWithNav = useCallback(() => {
if (!modalManager.terminalOpen) {
modalManager.toggleTerminal();
pushNav({ type: "modal", close: modalManager.closeTerminal });
} else {
removeNav(modalManager.closeTerminal);
modalManager.toggleTerminal();
}
}, [modalManager, pushNav, removeNav]);
const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => {
modalManager.openFiles(workspace, initialFile);
pushNav({ type: "modal", close: modalManager.closeFiles });
}, [modalManager, pushNav]);
const openFileInBrowser = useCallback((path: string, opts?: { workspace?: string; line?: number; col?: number }) => {
modalManager.openFiles(opts?.workspace, path);
pushNav({ type: "modal", close: modalManager.closeFiles });
}, [modalManager, pushNav]);
const openActivityLogWithNav = useCallback(() => {
modalManager.openActivityLog();
pushNav({ type: "modal", close: modalManager.closeActivityLog });
}, [modalManager, pushNav]);
const openGitManagerWithNav = useCallback(() => {
modalManager.openGitManager();
pushNav({ type: "modal", close: modalManager.closeGitManager });
}, [modalManager, pushNav]);
const openSchedulesWithNav = useCallback(() => {
modalManager.openSchedules();
pushNav({ type: "modal", close: modalManager.closeSchedules });
}, [modalManager, pushNav]);
const openScriptsWithNav = useCallback(() => {
modalManager.openScripts();
pushNav({ type: "modal", close: modalManager.closeScripts });
}, [modalManager, pushNav]);
const openWorkflowEditorWithNav = useCallback((workflowId?: string) => {
modalManager.openWorkflowEditor(undefined, workflowId);
pushNav({ type: "modal", close: modalManager.closeWorkflowEditor });
}, [modalManager, pushNav]);
const openCreateWorkflowWithNav = useCallback(() => {
modalManager.openWorkflowEditor("create");
pushNav({ type: "modal", close: modalManager.closeWorkflowEditor });
}, [modalManager, pushNav]);
const openUsageWithNav = useCallback((anchorRect?: DOMRect | null) => {
modalManager.openUsage(anchorRect);
pushNav({ type: "modal", close: modalManager.closeUsage });
}, [modalManager, pushNav]);
// Modal-to-modal transition: scripts -> terminal uses replaceCurrent
const runScriptWithNav = useCallback(async (name: string, command: string) => {
await modalManager.runScript(name, command);
replaceCurrent({ type: "modal", close: modalManager.closeTerminal });
}, [modalManager, replaceCurrent]);
// Modal-to-modal transition: settings -> onboarding uses replaceCurrent
const reopenOnboardingWithNav = useCallback(() => {
modalManager.closeSettings();
modalManager.openModelOnboarding();
replaceCurrent({ type: "modal", close: modalManager.closeModelOnboarding });
}, [modalManager, replaceCurrent]);
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 handleCliAction = useCallback(
(session: AiSessionSummary, action: CliActionId) =>
executeCliSessionBannerAction(session, action, {
currentProjectId: currentProject?.id,
retryTask,
moveTask,
openAuthenticationSettings: () => openSettingsWithNav("authentication" as SectionId),
addToast,
}),
[addToast, currentProject?.id, modalManager, moveTask, retryTask],
);
const [shellOnboardingComplete, setShellOnboardingComplete] = useState(false);
const [shellConnectionManagerOpen, setShellConnectionManagerOpen] = useState(false);
const [shellConnectionStatus, setShellConnectionStatus] = useState<ShellConnectionNativeResult | null>(null);
const requiresShellOnboarding = requiresNativeShellOnboarding(shellState, shellReady, shellOnboardingComplete);
useEffect(() => {
if (!shellApi || openConnectionManagerSignal === 0) {
return;
}
setShellConnectionManagerOpen(true);
}, [shellApi, openConnectionManagerSignal]);
useEffect(() => {
let cancelled = false;
void getShellConnectionNativeResult(shellHost.host).then((result) => {
if (!cancelled) {
setShellConnectionStatus(result);
}
});
return () => {
cancelled = true;
};
}, [shellHost.host, shellState.activeProfileId, shellState.desktopMode, shellState.host, shellState.profiles]);
useEffect(() => {
if (shellState.host !== "desktop-shell") {
return;
}
if (shellState.desktopMode !== "local") {
return;
}
if (shellState.localServer?.status !== "ready" || !shellState.localServer.port) {
return;
}
if (window.location.port === String(shellState.localServer.port)) {
return;
}
window.location.href = `http://localhost:${shellState.localServer.port}`;
}, [shellState]);
useEffect(() => {
if (shellState.host !== "desktop-shell" || shellState.desktopMode !== "remote") {
return;
}
const activeProfile = shellState.profiles.find((profile) => profile.id === shellState.activeProfileId);
if (!activeProfile || typeof window === "undefined") {
return;
}
const nextUrl = buildRemoteDashboardUrl(activeProfile.serverUrl, activeProfile.authToken ?? null);
if (window.location.href !== nextUrl) {
window.location.href = nextUrl;
}
}, [shellState]);
const isSuppressedProjectResumeError =
Boolean(projectsError) &&
isLikelyTabSuspensionError(projectsError ?? "") &&
hasEverLoadedProjectsRef.current;
const showBackendConnectionErrorPage =
!projectsLoading &&
!currentProjectLoading &&
projects.length === 0 &&
!currentProject &&
Boolean(projectsError) &&
!isSuppressedProjectResumeError;
// Props for the extracted <MainContent> switch (see components/dashboard/MainContent.tsx).
// Every value is passed by its App name; the switch renders the same subtrees as before.
const mainContentProps: MainContentProps = {
showBackendConnectionErrorPage,
projectsError,
t,
retryingProjects,
handleRetryProjects,
shellApi,
taskView,
modalManager,
handleChangeTaskView,
addToast,
currentProject,
themeMode,
setThemeMode,
colorTheme,
setColorTheme,
dashboardFontScalePct,
setDashboardFontScalePct,
shadcnCustomColors,
setShadcnCustomColors,
resolvedThemeMode,
setQuickChatButtonModeImmediate,
reopenOnboardingWithNav,
viewMode,
projects,
projectsLoading,
handleSelectProject,
handleAddProject,
handlePauseProject,
handleResumeProject,
handleRemoveProject,
nodes,
graphPluginTaskView,
graphWorkflowSelection,
setGraphWorkflowSelection,
isRemote,
remoteData,
tasks,
workflowSteps,
subscribePluginEvents,
openDetailTask,
openFileInBrowser,
prAuthAvailable,
autoMerge,
settingsLoaded,
skillsEnabled,
experimentalFeatures,
setQuickChatOpen,
setMailboxUnreadCount,
setMissionTargetId,
setMissionResumeSessionId,
setMilestoneSliceResumeSessionId,
missionResumeSessionId,
missionTargetId,
milestoneSliceResumeSessionId,
setGoalAnchorId,
goalAnchorId,
agentsEnabled,
agentOnboardingEnabled,
handleOpenTaskLogs,
popOutTaskDetail,
selectedPrId,
insightsEnabled,
handleInsightTaskCreate,
researchEnabled,
openSettingsWithNav,
researchReadinessVersion,
evalsEnabled,
memoryEnabled,
goalsEnabled,
handleOpenMission,
todosEnabled,
openPlanningWithInitialPlanWithNav,
ingestCreatedTasks,
nodesEnabled,
openWorkflowEditorWithNav,
handlePlanningTaskCreated,
handlePlanningTasksCreated,
handleGitHubImport,
devServerEnabled,
mainPanelDetailTask,
filteredBoardTasks,
maxConcurrent,
moveTask,
pauseTask,
openTaskDetailInMainPanel,
openGroupModalWithNav,
handleBoardQuickCreate,
openNewTaskWithNav,
subtaskBreakdownEnabled,
openSubtaskBreakdownWithNav,
toggleAutoMerge,
globalPaused,
updateTask,
retryTask,
archiveTask,
unarchiveTask,
deleteTask,
archiveAllDone,
loadArchivedTasks,
searchQuery,
availableModels,
favoriteProviders,
favoriteModels,
handleOpenDetailWithTab,
handleToggleFavorite,
handleToggleModelFavorite,
taskStuckTimeoutMs,
staleHighFanoutBlockerAgeThresholdMs,
lastFetchTimeMs,
openCreateWorkflowWithNav,
sidebarActive,
isMobile,
mainPanelDetailInitialTab,
closeTaskDetailMainPanel,
setMainPanelDetailTask,
mergeTask,
resetTask,
duplicateTask,
unpauseTask,
capacityRiskBannerEnabled,
capacityRiskDismissed,
capacityRiskSignal,
handleDismissCapacityRisk,
AgentsView,
ChatView,
CommandCenter,
DevServerView,
DocumentsView,
EvalsView,
GoalsView,
InsightsView,
MemoryView,
PullRequestView,
ResearchView,
SecretsView,
SkillsView,
TodoView,
_AutomationsView,
_ImportTasksView,
_SettingsView,
_WorkflowEditorView,
};
const showOnboardingResumeCard = !modalManager.modelOnboardingOpen && isOnboardingResumable();
const showPostOnboardingRecommendations =
!modalManager.modelOnboardingOpen &&
!showOnboardingResumeCard &&
isOnboardingCompleted() &&
!isPostOnboardingDismissed();
// Top progress bar reflects any in-flight revalidation: projects, current-project, or tasks.
// Add new sources here, not inside TopProgressBar.
const isRevalidating = projectsLoading || currentProjectLoading || isStale;
// Props for the extracted <DashboardBanners> cluster (see components/dashboard/DashboardBanners.tsx).
// Every value is passed by its App name; the cluster renders the same banners as before.
const dashboardBannersProps: DashboardBannersProps = {
viewMode,
currentProject,
isTestMode,
dashboardHealth,
setDashboardHealth,
taskView,
modalManager,
sessionBannersHidden,
sessionsNeedingInput,
handleOpenBackgroundSession,
handleDismissNeedingInputSession,
handleDismissAllNeedingInputSessions,
handleCliAction,
getCliActionDisabledReasonForBanner,
openSettingsWithNav,
showOnboardingResumeCard,
showPostOnboardingRecommendations,
updateAvailable,
latestVersion,
currentVersion,
updateBannerDismissed,
dismissUpdateBanner,
refreshDbCorruptionHealth,
dbCorruptionRefreshing,
dbCorruptionRefreshError,
setupReadinessLoading,
hasWarnings,
setupWarningDismissed,
handleDismissSetupWarning,
hasAiProvider,
hasGithub,
approvalBannerCandidate,
dismissApproval,
mailboxPendingApprovalCount,
handleTaskViewChange,
showGitHubStarPrompt,
gitHubStarPromptShown,
markGitHubStarPromptShown,
setShowGitHubStarPrompt,
};
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openFileInBrowser, openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
return (
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
<FileBrowserProvider openFile={openFileInBrowser}>
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
{isFirstEverBoot ? (
<>
<DashboardLoader stage={loadingStage} />
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
) : (
<>
<TopProgressBar visible={isRevalidating} />
<Header
shellHost={shellHost.host}
onOpenSettings={openSettingsWithNav}
onOpenGitHubImport={openGitHubImportWithNav}
onOpenUsage={openUsageWithNav}
onOpenActivityLog={openActivityLogWithNav}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
chatHasUnreadResponse={chatHasUnreadResponse}
stashOrphanCount={stashOrphanCount}
onOpenSchedules={openSchedulesWithNav}
onOpenGitManager={openGitManagerWithNav}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onOpenFiles={openFilesWithNav}
filesOpen={modalManager.filesOpen}
todosEnabled={todosEnabled}
view={taskView}
onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : undefined}
showSkillsTab={skillsEnabled}
showAgentsTab={agentsEnabled}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
branchFilter={branchFilter}
baseBranchFilter={baseBranchFilter}
branchOptions={branchOptions}
baseBranchOptions={baseBranchOptions}
onBranchFilterChange={handleBranchFilterChange}
onBaseBranchFilterChange={handleBaseBranchFilterChange}
projects={effectiveProjects}
currentProject={currentProject}
onSelectProject={handleSelectProject}
onViewAllProjects={handleViewAllProjects}
projectId={currentProject?.id}
mobileNavEnabled={isMobile}
leftSidebarNavActive={sidebarActive}
rightDockAvailable={rightDockActive}
rightDockOpen={rightDock.open}
onToggleRightDock={rightDock.toggle}
// Node switching props
availableNodes={nodes}
currentNode={currentNode}
onSelectNode={(node) => {
if (node === null) {
clearCurrentNode();
} else {
setCurrentNode(node);
}
}}
isRemote={isRemote}
experimentalFeatures={{
insights: insightsEnabled,
memoryView: memoryEnabled,
devServer: devServerEnabled,
devServerView: devServerEnabled,
researchView: researchEnabled,
evalsView: evalsEnabled,
goalsView: goalsEnabled,
leftSidebarNav: leftSidebarNavEnabled,
rightDock: rightDockEnabled,
}}
pluginDashboardViews={pluginDashboardViews}
shellConnectionControl={
!isMobile && shellConnectionStatus ? (
<ShellConnectionStatus
status={shellConnectionStatus}
onError={(message) => addToast(message, "error")}
/>
) : undefined
}
/>
<DashboardBanners {...dashboardBannersProps} />
<div className={`dashboard-project-shell${sidebarActive ? " dashboard-project-shell--with-sidebar" : ""}${rightDockActive ? " dashboard-project-shell--with-right-dock" : ""}`} data-testid="dashboard-project-shell">
{sidebarActive && (
<LeftSidebarNav
view={taskView}
onChangeView={handleTaskViewChange}
onNewTask={openNewTaskWithNav}
onOpenSettings={openSettingsWithNav}
todosEnabled={todosEnabled}
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
chatHasUnreadResponse={chatHasUnreadResponse}
experimentalFeatures={{
insights: insightsEnabled,
memoryView: memoryEnabled,
devServerView: devServerEnabled,
researchView: researchEnabled,
evalsView: evalsEnabled,
goalsView: goalsEnabled,
}}
pluginDashboardViews={pluginDashboardViews}
showAgentsTab={agentsEnabled}
showSkillsTab={skillsEnabled}
projects={effectiveProjects}
currentProject={currentProject}
onSelectProject={handleSelectProject}
onViewAllProjects={handleViewAllProjects}
footerVisible={executorFooterVisible}
/>
)}
<div
className={`project-content${executorFooterVisible && (!isMobile || !mobileKeyboardOpen) ? " project-content--with-footer" : ""}${isMobile && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
>
<MainContent {...mainContentProps} />
</div>
{rightDock.dock}
</div>
{rightDock.modal}
{executorFooterVisible && currentProject && (
<ExecutorStatusBar
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
projectId={currentProject.id}
taskStuckTimeoutMs={taskStuckTimeoutMs}
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
backgroundSessions={bgSessions}
backgroundGenerating={bgGenerating}
backgroundNeedsInput={bgNeedsInput}
onOpenBackgroundSession={handleOpenBackgroundSession}
onDismissBackgroundSession={bgDismiss}
lastFetchTimeMs={lastFetchTimeMs}
currentProjectPath={currentProject.path}
onOpenProjectDirectory={handleOpenProjectDirectory}
keyboardOpen={footerKeyboardOpen}
hideWhenKeyboardOpen={mobileKeyboardOpen}
onToggleTerminal={toggleTerminalWithNav}
quickChatButtonMode={quickChatButtonMode}
onOpenQuickChat={() => setQuickChatOpen(true)}
onOpenScripts={openScriptsWithNav}
onRunScript={runScriptWithNav}
/>
)}
<MobileNavBar
view={taskView}
onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : () => {}}
footerVisible={viewMode === "project" && !!currentProject}
modalOpen={modalManager.anyModalOpen}
keyboardOpen={mobileNavKeyboardOpen}
onOpenSettings={openSettingsWithNav}
onOpenActivityLog={openActivityLogWithNav}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
chatHasUnreadResponse={chatHasUnreadResponse}
stashOrphanCount={stashOrphanCount}
onOpenGitManager={openGitManagerWithNav}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onOpenSchedules={openSchedulesWithNav}
onOpenScripts={openScriptsWithNav}
onToggleTerminal={toggleTerminalWithNav}
onOpenFiles={openFilesWithNav}
onOpenGitHubImport={openGitHubImportWithNav}
onOpenPlanning={openPlanningWithNav}
onResumePlanning={resumePlanningWithNav}
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={() => openUsageWithNav(null)}
onViewAllProjects={handleViewAllProjects}
onRunScript={runScriptWithNav}
projectId={currentProject?.id}
showSkillsTab={skillsEnabled}
experimentalFeatures={{
insights: insightsEnabled,
memoryView: memoryEnabled,
devServer: devServerEnabled,
devServerView: devServerEnabled,
todoView: todosEnabled,
researchView: researchEnabled,
evalsView: evalsEnabled,
goalsView: goalsEnabled,
}}
pluginDashboardViews={pluginDashboardViews}
shellConnectionControl={
isMobile && shellConnectionStatus ? (
<ShellConnectionStatus
status={shellConnectionStatus}
onError={(message) => addToast(message, "error")}
/>
) : undefined
}
/>
{/*
FNXC:ChatModal 2026-06-22-13:24:
Quick Chat is replaced by the full ChatView in a movable/resizable FloatingWindow. The launcher icon is only the minimized entry point: clicking it opens the Chat modal, and the modal's minimize button closes the window back into that icon. Main Chat can also pop out into this same full Chat modal.
FNXC:ChatModal 2026-06-22-14:57:
Reopening Quick Chat from the FAB restores the last floating Chat window geometry through FloatingWindow's persisted/clamped geometry key. The modal's maximize button routes to the full Chat view and closes the floating modal without clearing ChatView's shared session selection state.
*/}
{viewMode === "project" && currentProject && (
<QuickChatFAB
showFAB={quickChatButtonMode === "floating"}
open={quickChatOpen}
onOpenChange={setQuickChatOpen}
/>
)}
{quickChatOpen && currentProject && (
<FloatingWindow
windowKey="chat-modal"
title="Chat"
onClose={() => setQuickChatOpen(false)}
hideHeader
dragHandleSelector=".chat-view--floating .view-header"
className="floating-window--chat"
persistGeometryKey="kb-dashboard-chat-floating-window"
defaultSize={{ width: 980, height: 680 }}
/*
FNXC:ChatModal 2026-06-23-22:14:
The full Chat pop-out must be resizable into a very narrow desktop utility window. ChatView already switches to its mobile one-pane layout at narrow widths, so allow the FloatingWindow to shrink below the old two-pane desktop minimum while preserving enough width for composer controls.
*/
minSize={{ width: 300, height: 420 }}
>
<Suspense fallback={null}>
<ChatView
addToast={addToast}
projectId={currentProject.id}
experimentalFeatures={experimentalFeatures}
floating
onMaximize={() => {
handleTaskViewChange("chat");
setQuickChatOpen(false);
}}
onMinimize={() => setQuickChatOpen(false)}
onClose={() => setQuickChatOpen(false)}
/>
</Suspense>
</FloatingWindow>
)}
{/*
FNXC:FloatingWindow 2026-06-22-20:45:
One movable, resizable, non-blocking FloatingWindow per popped-out task. Each hosts the same embedded TaskDetailContent List/Board use, wired to the same App task handlers. Live row preferred by id; falls back to the snapshot. Terminal/destructive actions and the window close button both remove the entry. Multiple entries → multiple coexisting windows; FloatingWindow's per-window z-counter handles focus-to-front so the clicked one comes on top.
FNXC:TaskDetail 2026-06-22-12:20:
Task pop-outs use TaskDetailContent's own gray header as the only visible header, matching the one-header fixed task modal while keeping FloatingWindow drag/resize. The generic Maximize title chrome is hidden; close now lives beside edit inside the task header.
*/}
{poppedOutTasks.map((snapshot) => {
const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot;
const close = () => closePoppedOutTask(snapshot.id);
return (
<FloatingWindow
key={snapshot.id}
windowKey={`task-detail-${snapshot.id}`}
title={liveTask.id}
onClose={close}
hideHeader
dragHandleSelector=".task-detail-content--embedded > .modal-header"
>
<TaskDetailContent
task={liveTask}
projectId={currentProject?.id}
tasks={tasks}
embedded
onOpenDetail={popOutTaskDetail}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
onMergeTask={mergeTask}
onRetryTask={retryTask}
onResetTask={resetTask}
onDuplicateTask={duplicateTask}
onRequestClose={close}
addToast={addToast}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMerge}
/>
</FloatingWindow>
);
})}
<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,
}}
onPlanningMode={openPlanningWithInitialPlanWithNav}
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, retryTask, resetTask, duplicateTask }}
deepLink={{ handleDetailClose }}
settings={{ prAuthAvailable, autoMerge, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate }}
onSettingsClose={handleSettingsCloseWithNav}
onReopenOnboarding={reopenOnboardingWithNav}
onOpenApprovals={(_approvalId) => handleTaskViewChange("mailbox")}
agentOnboardingEnabled={agentOnboardingEnabled}
/>
<AuthTokenRecoveryDialog open={authTokenRecoveryOpen} />
{shellApi && (
<>
<NativeShellOnboardingModal
open={requiresShellOnboarding}
shellApi={shellApi}
shellState={shellState}
onComplete={() => setShellOnboardingComplete(true)}
/>
<NativeShellConnectionManager
open={shellConnectionManagerOpen}
shellApi={shellApi}
shellState={shellState}
onClose={() => setShellConnectionManagerOpen(false)}
/>
</>
)}
</>
)}
</RetryWarningProvider>
</FileBrowserProvider>
</NavigationHistoryProvider>
);
}
export function App() {
return (
<I18nextProvider i18n={i18n}>
<ToastProvider>
<ShellHostProvider>
<ShellProvider>
<NodeProvider>
<ConfirmDialogProvider>
<AppInner />
</ConfirmDialogProvider>
</NodeProvider>
</ShellProvider>
</ShellHostProvider>
</ToastProvider>
</I18nextProvider>
);
}