Make every New Task entry point seed the dialog from the active Board or List workflow. - Resolve implicit workflow selection through the modal manager while preserving explicit choices and All workflows behavior. - Forward selected workflow IDs from List view and navigation-backed dialog entry points. - Add cross-surface regression coverage, operator documentation, and a patch changeset. Files changed: .changeset/fn-9143-new-task-selected-workflow.md | 7 +++ docs/dashboard-guide.md | 2 +- packages/dashboard/app/App.tsx | 4 +- packages/dashboard/app/components/AppModals.tsx | 4 +- packages/dashboard/app/components/Column.tsx | 4 +- .../dashboard/app/components/LeftSidebarNav.tsx | 4 +- packages/dashboard/app/components/ListView.tsx | 7 ++- .../components/__tests__/LeftSidebarNav.test.tsx | 1 + .../app/components/__tests__/ListView.test.tsx | 13 +++-- .../new-task-dialog-selected-workflow.test.tsx | 46 ++++++++++++++++++ .../dashboard/app/components/dashboard/types.ts | 2 +- .../app/hooks/__tests__/useModalManager.test.ts | 55 ++++++++++++++++++++++ packages/dashboard/app/hooks/useModalManager.ts | 19 ++++++-- 13 files changed, 148 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-9143 Fusion-Task-Lineage: 43951c5d-24d2-4243-826a-c6f416607882 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2312 lines
108 KiB
TypeScript
2312 lines
108 KiB
TypeScript
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 { TerminalModal } from "./components/TerminalModal";
|
||
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 { mergeTaskSnapshot, useTasks } from "./hooks/useTasks";
|
||
import { useBoardWorkflows } from "./hooks/useBoardWorkflows";
|
||
import type { ExecutorColumnFlags } from "./hooks/useExecutorStats";
|
||
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 { useDashboardKeyboardShortcuts } from "./hooks/useDashboardKeyboardShortcuts";
|
||
import { ModalDismissPreferenceProvider } from "./hooks/useOverlayDismiss";
|
||
import { QuickAddSubmitOnEnterProvider } from "./hooks/useQuickAddSubmitOnEnter";
|
||
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 { recordActivity } from "./utils/activity-trace";
|
||
import { closeViewShortcut, retainViewNavRevert } from "./utils/dashboardShortcutToggles";
|
||
import { useSetupReadiness } from "./hooks/useSetupReadiness";
|
||
import { useGithubSetupWarningDelay } from "./hooks/useGithubSetupWarningDelay";
|
||
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 { CostBadgeProvider } from "./context/CostBadgeContext";
|
||
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, type PoppedOutTaskEntry } 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,
|
||
resolveDesktopShellRedirectTarget,
|
||
requiresNativeShellOnboarding,
|
||
shouldShowFirstEverBootLoader,
|
||
shouldShowSessionInBanner,
|
||
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,
|
||
shouldShowSessionInBanner,
|
||
getCliActionDisabledReasonForBanner,
|
||
executeCliSessionBannerAction,
|
||
} from "./utils/appLifecycle";
|
||
import { subscribeSse } from "./sse-bus";
|
||
import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
|
||
import { MainContent } from "./components/dashboard/MainContent";
|
||
import { PlanningKeepAlive } from "./components/dashboard/PlanningKeepAlive";
|
||
import { NATIVE_STRUCTURE_OPEN_EVENT, type NativeStructureOpenEventDetail } from "./components/nativeStructureNavigation";
|
||
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";
|
||
import { useChatMailReportRouting } from "./components/chatReportHandoff";
|
||
|
||
const IS_TEST_ENV = import.meta.env.MODE === "test";
|
||
export const TASK_DETAIL_FLOATING_GEOMETRY_KEY = "floating-window:task-detail";
|
||
|
||
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 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/GoalsView");
|
||
void import("./components/PullRequestView");
|
||
});
|
||
}
|
||
|
||
registerBundledPluginViews();
|
||
|
||
export function shouldOpenBoardTaskInDock(openTasksInRightSidebar: boolean, rightDockActive: boolean, initialTab?: DetailTaskTab): boolean {
|
||
return !initialTab && openTasksInRightSidebar && rightDockActive;
|
||
}
|
||
|
||
export type BoardTaskOpenRoute = "popup" | "dock" | "main-panel";
|
||
|
||
export function getBoardTaskOpenRoute(options: {
|
||
isMobile: boolean;
|
||
openMobileTasksInPopup: boolean;
|
||
openTasksInRightSidebar: boolean;
|
||
rightDockActive: boolean;
|
||
initialTab?: DetailTaskTab;
|
||
}): BoardTaskOpenRoute {
|
||
if (options.openMobileTasksInPopup) {
|
||
return "popup";
|
||
}
|
||
if (shouldOpenBoardTaskInDock(options.openTasksInRightSidebar, options.rightDockActive, options.initialTab)) {
|
||
return "dock";
|
||
}
|
||
return "main-panel";
|
||
}
|
||
|
||
export interface DashboardShortcutPopupState {
|
||
poppedOutTaskEntries: Array<Pick<PoppedOutTaskEntry, "task" | "originTaskView">>;
|
||
quickChatOpen: boolean;
|
||
terminalOpen: boolean;
|
||
modalClosers: Array<[boolean, () => void]>;
|
||
}
|
||
|
||
export interface DashboardShortcutPopupHandlers {
|
||
closePoppedOutTask: (taskId: string, originTaskView?: TaskView) => void;
|
||
closeQuickChat: () => void;
|
||
closeTerminal: () => void;
|
||
}
|
||
|
||
/*
|
||
FNXC:TaskPopupViewGating 2026-07-15-15:20:
|
||
FN-8016 scopes every newly opened task popup to its exact dashboard origin, not only Board/List. Legacy entries without an origin predate this contract and deliberately remain visible on every view for backwards compatibility.
|
||
*/
|
||
export function isTaskPopupVisibleForView(options: {
|
||
taskPopupsBoardListOnly: boolean;
|
||
taskView: TaskView;
|
||
originTaskView?: TaskView;
|
||
}): boolean {
|
||
if (!options.taskPopupsBoardListOnly || options.originTaskView === undefined) return true;
|
||
return options.originTaskView === options.taskView;
|
||
}
|
||
|
||
export function taskPopupIdentityKey(taskId: string, originTaskView?: TaskView): string {
|
||
return `${taskId}:${originTaskView ?? "global"}`;
|
||
}
|
||
|
||
/*
|
||
FNXC:DashboardShortcuts 2026-07-04-12:02:
|
||
The App-level Escape close order is factored into a pure helper so regression tests can prove the real dashboard shell ordering without rendering every lazy dashboard surface. The helper must close exactly one surface and return false when no popup is open so component-local Escape handlers remain authoritative.
|
||
*/
|
||
export function closeTopmostDashboardPopupForShortcut(
|
||
state: DashboardShortcutPopupState,
|
||
handlers: DashboardShortcutPopupHandlers,
|
||
): boolean {
|
||
const lastPoppedOutTask = state.poppedOutTaskEntries[state.poppedOutTaskEntries.length - 1];
|
||
if (lastPoppedOutTask) {
|
||
handlers.closePoppedOutTask(lastPoppedOutTask.task.id, lastPoppedOutTask.originTaskView);
|
||
return true;
|
||
}
|
||
if (state.quickChatOpen) {
|
||
handlers.closeQuickChat();
|
||
return true;
|
||
}
|
||
if (state.terminalOpen) {
|
||
handlers.closeTerminal();
|
||
return true;
|
||
}
|
||
const match = state.modalClosers.find(([open]) => open);
|
||
if (!match) return false;
|
||
match[1]();
|
||
return true;
|
||
}
|
||
|
||
function AppInner() {
|
||
const { t } = useTranslation("app");
|
||
const { toasts, addToast, removeToast } = useToast();
|
||
useEffect(() => {
|
||
const latest = toasts[toasts.length - 1];
|
||
if (latest) recordActivity({ kind: latest.type === "error" ? "error" : "toast", label: latest.message });
|
||
}, [toasts]);
|
||
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, projectsLoading });
|
||
|
||
const {
|
||
hasAiProvider,
|
||
hasGithub,
|
||
loading: setupReadinessLoading,
|
||
} = useSetupReadiness(currentProject?.id);
|
||
const showGithubSetupWarning = useGithubSetupWarningDelay({
|
||
projectId: currentProject?.id,
|
||
hasGithub,
|
||
loading: setupReadinessLoading,
|
||
});
|
||
const visibleSetupHasWarnings = !hasAiProvider || (!hasGithub && showGithubSetupWarning);
|
||
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()}` : "";
|
||
/*
|
||
FNXC:PluginEvents 2026-07-26-16:46:
|
||
Resync contract (see SseSubscription in sse-bus.ts). This subscription is a pure relay: it parses
|
||
`plugin:custom`, filters by pluginId, and hands the payload to the plugin view's callback. It
|
||
holds no state of its own, so there is nothing here to refetch — and the host cannot synthesize a
|
||
refetch for the plugin either, because `plugin:custom` payloads are opaque and no generic
|
||
"current plugin state" endpoint exists. Adding a refetch here would only add a request to the
|
||
visible-edge burst without correcting anything, so this takes the documented replaySafe opt-out.
|
||
RESIDUAL RISK, stated deliberately: a plugin view that accumulates state purely from these events
|
||
still diverges across a hidden-suspend gap. The fix belongs in the plugin surface, which must
|
||
resync when its own view remounts or through its own authoritative fetch; the relay cannot do it.
|
||
*/
|
||
return subscribeSse(`/api/events${query}`, {
|
||
replaySafe: {
|
||
reason:
|
||
"relay-only: no host state; plugin:custom payloads are opaque so the host has no authoritative refetch, plugin views own their resync",
|
||
},
|
||
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, planningSessions: bgPlanningSessions } = useBackgroundSessions(currentProject?.id);
|
||
/*
|
||
* FNXC:SessionBanner 2026-07-16-20:55:
|
||
* FN-8229 replaces the removed footer AI pill with this banner feed. It keeps
|
||
* non-planning generating and error sessions observable while Planning owns
|
||
* all of its statuses through the docked view and navigation badge.
|
||
*/
|
||
const sessionsNeedingInput = bgSessions.filter(shouldShowSessionInBanner);
|
||
const sessionBannersHidden = useSessionBannersHidden();
|
||
const planningNeedsInput = bgPlanningSessions.some((s) => s.status === "awaiting_input");
|
||
|
||
// 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 });
|
||
const viewNavRevertRef = useRef(new Map<TaskView, (() => void)[]>());
|
||
|
||
// View state must be defined before useTasks since useTasks depends on taskView for SSE gating
|
||
const { viewMode, setViewMode, taskView, setTaskView, handleChangeTaskView } = useViewState({
|
||
projectsLoading,
|
||
projectsError,
|
||
currentProjectLoading,
|
||
currentProject,
|
||
projectsLength: projects.length,
|
||
setupWizardOpen: modalManager.setupWizardOpen,
|
||
openSetupWizard: modalManager.openSetupWizard,
|
||
themeMode,
|
||
setThemeMode,
|
||
});
|
||
|
||
useEffect(() => {
|
||
recordActivity({ kind: "view", label: String(viewMode) });
|
||
}, [viewMode]);
|
||
useEffect(() => {
|
||
/*
|
||
FNXC:ReportPipeline 2026-07-18-12:50:
|
||
Report traces must retain uncaught client errors independently of toast UI.
|
||
Window errors reveal failures that never produce a typed error toast.
|
||
*/
|
||
const recordError = (event: ErrorEvent) => recordActivity({ kind: "error", label: event.message });
|
||
window.addEventListener("error", recordError);
|
||
return () => window.removeEventListener("error", recordError);
|
||
}, []);
|
||
|
||
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]);
|
||
|
||
/*
|
||
FNXC:DashboardShortcuts 2026-07-16-00:00:
|
||
FN-8069 makes Settings and Command Center shortcuts true toggles. Retain the exact view-revert callback pushed to navigation history so a shortcut can remove that identity-matched entry and restore the captured prior view for shortcut and Header/MobileNavBar opens alike; the callback deletes itself for shortcut close and Browser Back paths (Runfusion/Fusion#2118).
|
||
*/
|
||
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) {
|
||
const revert = retainViewNavRevert(
|
||
newView,
|
||
previousView,
|
||
viewNavRevertRef.current,
|
||
handleChangeTaskView,
|
||
);
|
||
pushNav({ type: "view", revert });
|
||
}
|
||
}, [handleChangeTaskView, taskView, pushNav]);
|
||
|
||
/*
|
||
FNXC:NativeStructureEmbed 2026-07-19-19:30:
|
||
NativeStructurePreview deliberately reports callback/view-state destinations instead of URLs.
|
||
Listen once at the dashboard root so cards from general, task-bound, floating, and dock chat
|
||
open their owning view without duplicating navigation logic at any chat render call-site.
|
||
*/
|
||
useEffect(() => {
|
||
const openNativeStructure = (event: Event) => {
|
||
const { payload } = (event as CustomEvent<NativeStructureOpenEventDetail>).detail;
|
||
if (!payload?.available) return;
|
||
const target = payload.openTarget;
|
||
if (target.view === "missions") {
|
||
// FNXC:NativeStructureEmbed 2026-07-20-01:00: Mission navigation resets stale selection
|
||
// state. Navigate before setting this preview's target so the destination opens the
|
||
// referenced mission rather than an unselected Missions view.
|
||
handleTaskViewChange("missions");
|
||
setMissionTargetId(target.missionId ?? target.id);
|
||
return;
|
||
}
|
||
if (target.view === "goals") {
|
||
handleTaskViewChange("goalsView");
|
||
setGoalAnchorId(target.id);
|
||
return;
|
||
}
|
||
if (target.view === "roadmaps") {
|
||
// FNXC:NativeStructureEmbed 2026-07-19-12:45: Roadmap previews open the registered
|
||
// plugin destination through TaskView state, never a synthetic URL deep-link.
|
||
handleTaskViewChange("plugin:fusion-plugin-roadmap:roadmaps");
|
||
return;
|
||
}
|
||
handleTaskViewChange(target.view);
|
||
};
|
||
window.addEventListener(NATIVE_STRUCTURE_OPEN_EVENT, openNativeStructure);
|
||
return () => window.removeEventListener(NATIVE_STRUCTURE_OPEN_EVENT, openNativeStructure);
|
||
}, [handleTaskViewChange]);
|
||
|
||
// 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";
|
||
/*
|
||
FNXC:WorkflowResolvedColumns 2026-07-31-03:50:
|
||
HOISTED above `useTasks` so its planner-activity stamp can be a role question.
|
||
|
||
The board-workflow payload is the only per-task trait source on this screen, and it depends on
|
||
`projectId` alone — nothing about tasks — so reading it first is safe. `useTasks` previously gated
|
||
that stamp on the literal `{triage, todo}` pair, which matches nothing on a renamed board, so the
|
||
planning border and pulsing badge never appeared while the planner was working the card.
|
||
|
||
REMOTE NODES GET NO FLAGS, deliberately: their rows belong to another store, so local
|
||
board-workflow metadata must never be applied to their ids — the same rule the footer index below
|
||
already follows. They keep the legacy fallback.
|
||
*/
|
||
const { boardWorkflows: footerBoardWorkflows } = useBoardWorkflows({ projectId: currentProject?.id });
|
||
const resolveTaskColumnFlagsForActivity = useCallback((task: Task) => {
|
||
if (isRemote || !footerBoardWorkflows) return undefined;
|
||
const workflowId = footerBoardWorkflows.taskWorkflowIds[task.id] ?? footerBoardWorkflows.defaultWorkflowId;
|
||
return footerBoardWorkflows.workflows
|
||
.find((workflow) => workflow.id === workflowId)
|
||
?.columns.find((column) => column.id === task.column)?.flags;
|
||
}, [footerBoardWorkflows, isRemote]);
|
||
|
||
const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, bypassReview, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, loadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, ingestCreatedTasks, lastFetchTimeMs } = useTasks(
|
||
{
|
||
...(currentProject ? { projectId: currentProject.id } : {}),
|
||
searchQuery: searchQuery || undefined,
|
||
sseEnabled: taskSseEnabled,
|
||
resolveColumnFlags: resolveTaskColumnFlagsForActivity,
|
||
}
|
||
);
|
||
const footerTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
|
||
const footerColumnFlagsByTaskId = useMemo(() => {
|
||
const index = new Map<string, ExecutorColumnFlags>();
|
||
// FNXC:ConcurrencyIndicators 2026-08-04-10:00: remote tasks belong to a
|
||
// different store, so local board-workflow metadata must never be applied to
|
||
// their ids. Until the remote node supplies its own traits, use only the
|
||
// documented literal fallback rather than fabricate custom lifecycle state.
|
||
if (isRemote || !footerBoardWorkflows) return index;
|
||
const workflowsById = new Map(footerBoardWorkflows.workflows.map((workflow) => [workflow.id, workflow]));
|
||
// Build traits for the exact local rows supplied to the footer.
|
||
for (const task of footerTasks) {
|
||
const workflow = workflowsById.get(footerBoardWorkflows.taskWorkflowIds[task.id] ?? footerBoardWorkflows.defaultWorkflowId);
|
||
const flags = workflow?.columns.find((column) => column.id === task.column)?.flags;
|
||
if (flags) index.set(task.id, flags);
|
||
}
|
||
return index;
|
||
}, [footerBoardWorkflows, footerTasks, isRemote]);
|
||
/*
|
||
FNXC:ConcurrencyIndicators 2026-08-03-12:00:
|
||
FN-8453 threads board workflow traits into the footer so custom intake,
|
||
complete, WIP, and merge columns share the same live-agent predicate as the engine.
|
||
*/
|
||
|
||
/*
|
||
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-07-15-15:20:
|
||
FN-8016 identifies a popped-out task detail by task id plus origin view. The same task can therefore coexist in separate view-scoped FloatingWindows while re-opening it on one view refreshes only that entry.
|
||
*/
|
||
const { entries: poppedOutTaskEntries, popOut: popOutTaskDetail, close: closePoppedOutTask, closeAll: closeAllPoppedOutTasks } = usePoppedOutTasks();
|
||
const popupNavCloseRef = useRef(new Map<string, () => void>());
|
||
|
||
/*
|
||
FNXC:TaskDetailSwipeBack 2026-07-15-10:32:
|
||
Mobile task popups keep Board or List visible, but they are still task-detail
|
||
surfaces. Give each newly opened popup its own navigation callback so browser,
|
||
iOS swipe, and Android Back dismiss only that popup rather than leaving the
|
||
Fusion stack empty and allowing Back to skip past the originating task view.
|
||
*/
|
||
const closePoppedOutTaskWithNav = useCallback((taskId: string, originTaskView?: TaskView) => {
|
||
const identityKey = taskPopupIdentityKey(taskId, originTaskView);
|
||
const closeFromHistory = popupNavCloseRef.current.get(identityKey);
|
||
if (closeFromHistory) {
|
||
popupNavCloseRef.current.delete(identityKey);
|
||
removeNav(closeFromHistory);
|
||
}
|
||
closePoppedOutTask(taskId, originTaskView);
|
||
}, [closePoppedOutTask, removeNav]);
|
||
|
||
const popOutTaskDetailForCurrentView = useCallback((task: Task | TaskDetail, initialTab?: DetailTaskTab) => {
|
||
const identityKey = taskPopupIdentityKey(task.id, taskView);
|
||
const alreadyOpen = poppedOutTaskEntries.some((entry) => entry.task.id === task.id && entry.originTaskView === taskView);
|
||
if (isMobile && !alreadyOpen) {
|
||
const closeFromHistory = () => {
|
||
popupNavCloseRef.current.delete(identityKey);
|
||
closePoppedOutTask(task.id, taskView);
|
||
};
|
||
popupNavCloseRef.current.set(identityKey, closeFromHistory);
|
||
pushNav({ type: "modal", close: closeFromHistory });
|
||
}
|
||
popOutTaskDetail(task, taskView, initialTab);
|
||
}, [isMobile, poppedOutTaskEntries, popOutTaskDetail, pushNav, closePoppedOutTask, taskView]);
|
||
|
||
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 [chatComposerPrefill, setChatComposerPrefill] = useState<{ text: string; nonce: number } | null>(null);
|
||
const [quickChatEverOpenedProjectId, setQuickChatEverOpenedProjectId] = useState<string | null>(null);
|
||
const quickChatProjectIdRef = useRef<string | undefined>(undefined);
|
||
|
||
/*
|
||
FNXC:ChatModal 2026-07-18-00:00:
|
||
FN-8257 requires Quick Chat to mount only after its first open, then remain mounted and hidden
|
||
across close/reopen so ChatView retains its selected session, transcript, and scroll position.
|
||
Reset the latch when the project changes so a hidden ChatView cannot leak one project's state
|
||
into another project; the FloatingWindow key supplies the matching React identity boundary.
|
||
*/
|
||
useEffect(() => {
|
||
const projectId = currentProject?.id;
|
||
if (quickChatProjectIdRef.current !== projectId) {
|
||
quickChatProjectIdRef.current = projectId;
|
||
setQuickChatEverOpenedProjectId(quickChatOpen && projectId ? projectId : null);
|
||
return;
|
||
}
|
||
if (quickChatOpen && projectId) {
|
||
setQuickChatEverOpenedProjectId(projectId);
|
||
}
|
||
}, [currentProject?.id, quickChatOpen]);
|
||
|
||
/*
|
||
FNXC:PlanningKeepAlive 2026-07-22-12:30:
|
||
Planning Mode mounts only after its first open for the current project, then stays mounted-but-hidden across sidebar navigation so the interview survives round-trips (FN remount-churn fix R5). This mirrors Quick Chat's quickChatEverOpenedProjectId latch above: reset on project change so one project's kept-alive interview can never leak into another; the PlanningKeepAlive key supplies the matching React identity boundary.
|
||
*/
|
||
const [planningEverOpenedProjectId, setPlanningEverOpenedProjectId] = useState<string | null>(null);
|
||
const planningLatchProjectIdRef = useRef<string | undefined>(undefined);
|
||
const planningViewActive = taskView === "planning";
|
||
useEffect(() => {
|
||
const projectId = currentProject?.id;
|
||
if (planningLatchProjectIdRef.current !== projectId) {
|
||
planningLatchProjectIdRef.current = projectId;
|
||
setPlanningEverOpenedProjectId(planningViewActive && projectId ? projectId : null);
|
||
return;
|
||
}
|
||
if (planningViewActive && projectId) {
|
||
setPlanningEverOpenedProjectId(projectId);
|
||
}
|
||
}, [currentProject?.id, planningViewActive]);
|
||
|
||
/*
|
||
FNXC:GitHubImportChat 2026-07-30-12:00:
|
||
Import Tasks can hand a selected GitHub link to Quick Chat without creating a task. Keep the
|
||
value and nonce at App scope so both modal and embedded imports seed every Chat presentation.
|
||
*/
|
||
const openChatWithPrefill = useCallback((text: string) => {
|
||
setChatComposerPrefill({ text, nonce: Date.now() });
|
||
setQuickChatOpen(true);
|
||
}, []);
|
||
|
||
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.
|
||
//
|
||
// FNXC:MobileChatKeyboardLayout 2026-06-26-09:04:
|
||
// When the keyboard is up on mobile we now hide the executor footer and
|
||
// drop the reserved footer+nav padding on BOTH platforms (see
|
||
// computeMobileBarKeyboardFlags) so the composer sits flush above the
|
||
// keyboard with no empty gap. This supersedes the earlier Android gate
|
||
// (FN-5707), which kept the footer visible and left a ~80px dead band
|
||
// where the off-screen nav bar's padding remained reserved.
|
||
// `footerKeyboardOpen` (the footer `bottom: 0` collapse class) stays
|
||
// iOS-only: it only matters when the footer is still rendered (e.g. over
|
||
// a modal), where Android's resizes-content already stacks it correctly.
|
||
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 } = 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,
|
||
});
|
||
|
||
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);
|
||
/*
|
||
FNXC:CommandCenterAgentActivity 2026-08-10-01:54:
|
||
Agent activity raises a focused-agent anchor through App's existing view router rather than changing routing itself. The request id mirrors goalAnchorId but preserves repeated activation of one agent.
|
||
*/
|
||
const [agentAnchor, setAgentAnchor] = useState<{ agentId: string; requestId: number } | 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 !== "agents" && agentAnchor !== undefined) {
|
||
setAgentAnchor(undefined);
|
||
}
|
||
}, [agentAnchor, 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,
|
||
mergeStrategy,
|
||
planAutoApproveEnabled,
|
||
showWorktreeGrouping,
|
||
globalPaused,
|
||
isTestMode,
|
||
staleHighFanoutBlockerAgeThresholdMs,
|
||
capacityRiskBannerEnabled,
|
||
capacityRiskTodoThreshold,
|
||
openTasksInRightSidebar,
|
||
openMobileTasksInPopup,
|
||
taskPopupsBoardListOnly,
|
||
showCostBadgeOnCards,
|
||
modelPricingOverrides,
|
||
taskDetailChatFirst,
|
||
quickChatButtonMode,
|
||
mobileNavPrimaryItems,
|
||
quickChatCloseOnOutsideClick,
|
||
dashboardKeyboardShortcuts,
|
||
dismissModalsOnOutsideClick,
|
||
quickAddSubmitOnEnter,
|
||
skipConfirmationDialogs,
|
||
maxTotalRetriesBeforeFail,
|
||
prAuthAvailable,
|
||
settingsLoaded,
|
||
experimentalFeatures,
|
||
insightsEnabled,
|
||
memoryEnabled,
|
||
devServerEnabled,
|
||
goalsEnabled,
|
||
setQuickChatButtonModeImmediate,
|
||
setMobileNavPrimaryItemsImmediate,
|
||
toggleAutoMerge,
|
||
togglePlanAutoApprove,
|
||
refresh: refreshAppSettings,
|
||
} = useAppSettings(currentProject?.id);
|
||
|
||
const taskPopupsVisibleOnCurrentView = useCallback((originTaskView?: TaskView) => isTaskPopupVisibleForView({
|
||
taskPopupsBoardListOnly,
|
||
taskView,
|
||
originTaskView,
|
||
}), [taskPopupsBoardListOnly, taskView]);
|
||
/*
|
||
FNXC:TaskPopupViewGating 2026-07-15-15:20:
|
||
FN-8016 defaults popup rendering to the originating view for every dashboard surface. Entries without an origin are legacy snapshots and intentionally remain globally visible.
|
||
|
||
FNXC:TaskPopupViewGating 2026-07-22-13:15:
|
||
FN remount-churn fix R7: this memo no longer drives the render list (all entries render; off-view windows hide via FloatingWindow `hidden`). It remains the "currently visible" set consumed by the Escape/back-navigation shortcut handler below, which must only dismiss windows the user can actually see.
|
||
*/
|
||
const visiblePoppedOutTaskEntries = useMemo(
|
||
() => poppedOutTaskEntries.filter((entry) => taskPopupsVisibleOnCurrentView(entry.originTaskView)),
|
||
[poppedOutTaskEntries, taskPopupsVisibleOnCurrentView],
|
||
);
|
||
|
||
/*
|
||
FNXC:RoadmapsNavigation 2026-07-19-12:00:
|
||
Preserve the plugin manifest's roadmaps entry now that the bundled host registers its view.
|
||
Native-structure roadmap-item previews use this as their callback-driven open destination.
|
||
*/
|
||
const pluginDashboardViews = useMemo<PluginDashboardViewEntry[]>(
|
||
() => rawPluginDashboardViews,
|
||
[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;
|
||
const ideationEnabled = experimentalFeatures.ideationView === 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 mobileNavVisible = 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");
|
||
}
|
||
/*
|
||
FNXC:Navigation 2026-07-30-00:00:
|
||
FN-8352 promotes Ideation to a default-off experimental top-level view.
|
||
Redirect persisted and deep-linked disabled views to Board so MainContent
|
||
never leaves users on a blank unavailable surface.
|
||
*/
|
||
if (taskView === "ideation" && !ideationEnabled) {
|
||
handleChangeTaskView("board");
|
||
}
|
||
if (taskView === "goalsView" && !goalsEnabled) {
|
||
handleChangeTaskView("board");
|
||
}
|
||
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, ideationEnabled, goalsEnabled, 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,
|
||
});
|
||
|
||
/*
|
||
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
||
Project-scoped task-detail surfaces are not all owned by modalManager: depending on
|
||
settings, tasks open as popped-out FloatingWindows, in the main panel (task-detail view),
|
||
or in the right dock, and Quick Chat is an App-level floating window. A project swap must
|
||
dismiss ALL of them, so useProjectActions receives this composite via a ref — the pieces
|
||
it closes (main-panel detail, right dock) are constructed later in this component, and
|
||
the ref keeps the callback stable while always invoking the latest wiring.
|
||
*/
|
||
const closeProjectScopedUiRef = useRef<() => void>(() => {});
|
||
const closeProjectScopedUi = useCallback(() => closeProjectScopedUiRef.current(), []);
|
||
|
||
const {
|
||
handleSelectProject,
|
||
handleViewAllProjects,
|
||
handleOpenSettings: _handleOpenSettings,
|
||
handleAddProject,
|
||
handleSetupComplete,
|
||
handleModelOnboardingComplete,
|
||
handlePauseProject,
|
||
handleResumeProject,
|
||
handleRemoveProject,
|
||
handleToggleFavorite,
|
||
handleToggleModelFavorite,
|
||
} = useProjectActions({
|
||
setCurrentProject,
|
||
clearCurrentProject,
|
||
setViewMode,
|
||
setTaskView,
|
||
currentProject,
|
||
refreshProjects,
|
||
toggleFavoriteProvider,
|
||
toggleFavoriteModel,
|
||
addToast,
|
||
openSettings: modalManager.openSettings,
|
||
openSetupWizard: modalManager.openSetupWizard,
|
||
closeSetupWizard: modalManager.closeSetupWizard,
|
||
closeModelOnboarding: modalManager.closeModelOnboarding,
|
||
closeProjectScopedModals: closeProjectScopedUi,
|
||
});
|
||
|
||
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 }) => {
|
||
/*
|
||
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
|
||
Do not hard-code `column: "triage"` — this surface has no workflow picker, so it inherits the project-default workflow, and the store resolves the landing column from that workflow's intake column (e.g. Coding (Ideas) → "ideas") instead of forcing triage.
|
||
*/
|
||
await createTask({
|
||
title,
|
||
description,
|
||
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]);
|
||
|
||
/*
|
||
FNXC:TaskDetailSwipeBack 2026-06-29-14:21:
|
||
Mobile task-detail opens that use the modal path must still push a history entry even when the originating surface is the single-pane mobile list. AppModals owns nested-detail restoration, while this first-open path keeps the baseline dismiss-to-origin callback (`modalManager.closeDetailTask`) for the top-level modal entry.
|
||
*/
|
||
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) => {
|
||
const previousView = taskView;
|
||
const previousDetailTask = mainPanelDetailTask;
|
||
const previousDetailTab = mainPanelDetailInitialTab;
|
||
|
||
if (previousView === "task-detail" && previousDetailTask?.id === task.id && previousDetailTab === initialTab) {
|
||
setMainPanelDetailTask((current) => current?.id === task.id ? mergeTaskSnapshot(current, task) : 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]);
|
||
|
||
/*
|
||
FNXC:TaskPopupDeepTabs 2026-07-21-00:00:
|
||
FN-8478 makes every board TaskCard deep-tab action, including files changed, honor Open tasks as popups. Route once to the popup with its requested tab so a click never opens both a FloatingWindow and a main-panel/modal detail surface.
|
||
*/
|
||
const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => {
|
||
if (openMobileTasksInPopup) {
|
||
popOutTaskDetailForCurrentView(task, initialTab);
|
||
return;
|
||
}
|
||
if (initialTab === "changes") {
|
||
openTaskDetailInMainPanel(task, "changes");
|
||
return;
|
||
}
|
||
modalManager.openDetailTask(task, initialTab);
|
||
pushNav({ type: "modal", close: modalManager.closeDetailTask });
|
||
}, [modalManager, openMobileTasksInPopup, openTaskDetailInMainPanel, popOutTaskDetailForCurrentView, 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]);
|
||
|
||
/*
|
||
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
||
FN-7553's openCommandCenter shortcut reuses the same handleTaskViewChange nav-history owner as openSettingsWithNav/openPlanningWithNav above, so Command Center never gets a second/duplicate nav destination beyond the existing Header/LeftSidebarNav/MobileNavBar "command-center" view entries.
|
||
*/
|
||
const openCommandCenterWithNav = useCallback(() => {
|
||
handleTaskViewChange("command-center");
|
||
}, [handleTaskViewChange]);
|
||
|
||
const openNewTaskWithNav = useCallback((workflowId?: string | null) => {
|
||
modalManager.openNewTask(workflowId);
|
||
pushNav({ type: "modal", close: modalManager.closeNewTask });
|
||
}, [modalManager, pushNav]);
|
||
|
||
const closeFilesWithNav = useCallback(() => {
|
||
removeNav(modalManager.closeFiles);
|
||
modalManager.closeFiles();
|
||
}, [modalManager, removeNav]);
|
||
|
||
const closeNewTaskWithNav = useCallback(() => {
|
||
removeNav(modalManager.closeNewTask);
|
||
modalManager.closeNewTask();
|
||
}, [modalManager, removeNav]);
|
||
|
||
/*
|
||
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, sourceIssue?: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string }) => {
|
||
modalManager.openPlanningWithInitialPlan(initialPlan, workflowId, sourceIssue);
|
||
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 closeTerminalWithNav = useCallback(() => {
|
||
removeNav(modalManager.closeTerminal);
|
||
modalManager.closeTerminal();
|
||
}, [modalManager, removeNav]);
|
||
|
||
const toggleTerminalWithNav = useCallback(() => {
|
||
if (!modalManager.terminalOpen) {
|
||
modalManager.toggleTerminal();
|
||
pushNav({ type: "modal", close: modalManager.closeTerminal });
|
||
} else {
|
||
closeTerminalWithNav();
|
||
}
|
||
}, [closeTerminalWithNav, modalManager, pushNav]);
|
||
|
||
const closeTopmostPopupForShortcut = useCallback(() => {
|
||
/*
|
||
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
||
Escape should close only one visible dashboard popup per key press. Floating user surfaces close before fixed app modals so a Quick Chat or task popout on top does not accidentally dismiss the underlying Terminal, Settings, or Task Detail modal.
|
||
|
||
FNXC:DashboardShortcuts 2026-07-04-12:02:
|
||
Popped-out tasks are the most recent floating work surface and must close before Quick Chat. This preserves the topmost-popup invariant when a task popout overlays chat, then falls back to Terminal and modal-manager surfaces one layer per Escape.
|
||
*/
|
||
return closeTopmostDashboardPopupForShortcut(
|
||
{
|
||
poppedOutTaskEntries: visiblePoppedOutTaskEntries,
|
||
quickChatOpen,
|
||
terminalOpen: modalManager.terminalOpen,
|
||
modalClosers: [
|
||
[modalManager.filesOpen, modalManager.closeFiles],
|
||
[modalManager.workflowEditorOpen, modalManager.closeWorkflowEditor],
|
||
[modalManager.gitManagerOpen, modalManager.closeGitManager],
|
||
[modalManager.activityLogOpen, modalManager.closeActivityLog],
|
||
[modalManager.scriptsOpen, modalManager.closeScripts],
|
||
[modalManager.agentsOpen, modalManager.closeAgents],
|
||
[modalManager.usageOpen, modalManager.closeUsage],
|
||
[modalManager.schedulesOpen, modalManager.closeSchedules],
|
||
[modalManager.githubImportOpen, modalManager.closeGitHubImport],
|
||
[modalManager.settingsOpen, modalManager.closeSettings],
|
||
[Boolean(modalManager.detailTask), modalManager.closeDetailTask],
|
||
[Boolean(modalManager.groupModalGroupId), modalManager.closeGroupModal],
|
||
[modalManager.isSubtaskOpen, modalManager.closeSubtask],
|
||
[modalManager.isPlanningOpen, modalManager.closePlanning],
|
||
[modalManager.newTaskModalOpen, modalManager.closeNewTask],
|
||
[modalManager.setupWizardOpen, modalManager.closeSetupWizard],
|
||
[modalManager.modelOnboardingOpen, modalManager.closeModelOnboarding],
|
||
],
|
||
},
|
||
{
|
||
closePoppedOutTask: closePoppedOutTaskWithNav,
|
||
closeQuickChat: () => setQuickChatOpen(false),
|
||
closeTerminal: closeTerminalWithNav,
|
||
},
|
||
);
|
||
}, [closePoppedOutTaskWithNav, closeTerminalWithNav, modalManager, quickChatOpen, visiblePoppedOutTaskEntries]);
|
||
|
||
const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => {
|
||
modalManager.openFiles(workspace, initialFile);
|
||
pushNav({ type: "modal", close: modalManager.closeFiles });
|
||
}, [modalManager, pushNav]);
|
||
|
||
const closeViewShortcutWithNav = useCallback((view: TaskView) => {
|
||
closeViewShortcut(
|
||
view,
|
||
viewNavRevertRef.current,
|
||
removeNav,
|
||
() => handleChangeTaskView("board"),
|
||
);
|
||
}, [handleChangeTaskView, removeNav]);
|
||
|
||
/*
|
||
FNXC:DashboardShortcuts 2026-07-16-00:00:
|
||
All six configurable shortcuts toggle. Modal toggles retain their existing nav-aware closer; Settings and Command Center remove and invoke the exact retained history revert, restoring the captured prior view rather than Board. This applies equally to shortcut and existing Header/MobileNavBar opens without duplicate destinations or replayable history entries (Runfusion/Fusion#2118).
|
||
*/
|
||
useDashboardKeyboardShortcuts({
|
||
shortcuts: dashboardKeyboardShortcuts,
|
||
toggleQuickChat: () => setQuickChatOpen((open) => !open),
|
||
toggleTerminal: toggleTerminalWithNav,
|
||
closeTopmostPopup: closeTopmostPopupForShortcut,
|
||
toggleFiles: () => modalManager.filesOpen ? closeFilesWithNav() : openFilesWithNav(),
|
||
toggleSettings: () => taskView === "settings" ? closeViewShortcutWithNav("settings") : openSettingsWithNav(),
|
||
toggleCommandCenter: () => taskView === "command-center" ? closeViewShortcutWithNav("command-center") : openCommandCenterWithNav(),
|
||
toggleNewTask: () => modalManager.newTaskModalOpen ? closeNewTaskWithNav() : openNewTaskWithNav(),
|
||
});
|
||
|
||
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") {
|
||
/*
|
||
FNXC:Navigation 2026-07-15-00:00:
|
||
Background planning sessions must navigate to the embedded `taskView === "planning"` surface as well as setting resume state. This mirrors the mission interview branches below and the openPlanning*WithNav helpers, so footer and needs-input resume entry points actually render the reconnected session.
|
||
*/
|
||
modalManager.openPlanningWithSession(session.id);
|
||
handleChangeTaskView("planning");
|
||
} 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 session notification banner 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,
|
||
/*
|
||
FNXC:WorkflowLifecycleColumns 2026-08-01-02:10:
|
||
Resolve Cancel's destination from the card's OWN workflow. Without this the banner moved to a
|
||
hardcoded `"todo"`, which `moves.ts` REJECTS on a board that does not declare it — the button
|
||
threw instead of cancelling. Wired here rather than left optional: an unsupplied parameter is
|
||
the inert shape this program has already found five times.
|
||
*/
|
||
resolveCancelColumn: (taskId: string) => {
|
||
if (!footerBoardWorkflows) return undefined;
|
||
const workflow = footerBoardWorkflows.workflows.find(
|
||
(candidate) => candidate.id === (footerBoardWorkflows.taskWorkflowIds[taskId] ?? footerBoardWorkflows.defaultWorkflowId),
|
||
);
|
||
return workflow?.columns.find((column) => column.flags?.hold === true)?.id;
|
||
},
|
||
}),
|
||
[addToast, currentProject?.id, footerBoardWorkflows, 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]);
|
||
|
||
/*
|
||
* FNXC:DesktopSwitchServer 2026-07-04-13:20:
|
||
* Single shared decision path for the in-dashboard "Switch server" navigation, covering BOTH directions
|
||
* (remote -> local and local -> remote). This mirrors the working native-menu / desktopLaunchMode flow by
|
||
* navigating the renderer to the selected server's origin, since the in-dashboard switch does not route
|
||
* through the Electron main-process launch-mode handlers. See resolveDesktopShellRedirectTarget in
|
||
* appLifecycle.ts for the pure decision logic and negative-state guards (not-running runtime, already-on-
|
||
* target origin, non-desktop hosts, missing active profile).
|
||
*/
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const target = resolveDesktopShellRedirectTarget(shellState, window.location.href);
|
||
if (target) {
|
||
window.location.href = target;
|
||
}
|
||
}, [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 rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, columnFlagsByTaskId: footerColumnFlagsByTaskId, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetailForCurrentView, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onRevertTask: revertTask, onMergeTask: mergeTask, onRetryTask: retryTask, onPauseTask: pauseTask, onUnpauseTask: unpauseTask, onBypassReview: bypassReview, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), 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, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
|
||
|
||
/*
|
||
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
|
||
Board card clicks are the only task-open path governed by openTasksInRightSidebar. When the project setting is enabled and the tablet/desktop right dock is active, the board keeps its current view and asks the dock controller to render task detail; otherwise the existing full main-panel replacement remains the fallback, including mobile and hidden-footer states.
|
||
|
||
FNXC:MobileTaskPopups 2026-07-21-00:00:
|
||
FN-8478 makes board TaskCard deep-tab opens use the all-viewport popup route when enabled, preserving the requested tab. Popup routing remains first so neither dock nor main-panel detail can double-open behind the FloatingWindow.
|
||
*/
|
||
const openBoardTaskDetail = useCallback((task: Task | TaskDetail, initialTab?: DetailTaskTab) => {
|
||
const route = getBoardTaskOpenRoute({
|
||
isMobile,
|
||
openMobileTasksInPopup,
|
||
openTasksInRightSidebar,
|
||
rightDockActive,
|
||
initialTab,
|
||
});
|
||
|
||
if (route === "popup") {
|
||
popOutTaskDetailForCurrentView(task, initialTab);
|
||
return;
|
||
}
|
||
|
||
if (route === "dock") {
|
||
rightDock.openTaskInDock(task);
|
||
return;
|
||
}
|
||
|
||
openTaskDetailInMainPanel(task, initialTab);
|
||
}, [isMobile, openMobileTasksInPopup, openTaskDetailInMainPanel, openTasksInRightSidebar, popOutTaskDetailForCurrentView, rightDock, rightDockActive]);
|
||
|
||
useEffect(() => {
|
||
if (!openTasksInRightSidebar) {
|
||
rightDock.closeDockTask();
|
||
}
|
||
}, [openTasksInRightSidebar, rightDock]);
|
||
|
||
/*
|
||
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
||
The composite project-switch reset. Beyond modalManager's project-scoped modals, dismiss
|
||
every task-detail surface the open-task routing can target (popped-out FloatingWindows,
|
||
main-panel task-detail view, right-dock task) and close the Quick Chat floating window —
|
||
all of these showed the previous project's content over the new project. Assigned each
|
||
render so the ref-stable callback handed to useProjectActions always sees current state.
|
||
*/
|
||
closeProjectScopedUiRef.current = () => {
|
||
modalManager.closeProjectScopedModals();
|
||
closeAllPoppedOutTasks();
|
||
if (mainPanelDetailTask) {
|
||
closeTaskDetailMainPanel();
|
||
}
|
||
rightDock.closeDockTask();
|
||
setQuickChatOpen(false);
|
||
};
|
||
|
||
const { mailComposerPrefill, onSendAsReport: handleSendChatMessageAsReport } = useChatMailReportRouting(
|
||
() => handleChangeTaskView("mailbox"),
|
||
() => setQuickChatOpen(false),
|
||
);
|
||
|
||
const mainContentProps: MainContentProps = {
|
||
showBackendConnectionErrorPage,
|
||
projectsError,
|
||
t,
|
||
retryingProjects,
|
||
handleRetryProjects,
|
||
shellApi,
|
||
taskView,
|
||
pluginDashboardViews,
|
||
modalManager,
|
||
handleChangeTaskView,
|
||
refreshAppSettings,
|
||
addToast,
|
||
currentProject,
|
||
themeMode,
|
||
setThemeMode,
|
||
colorTheme,
|
||
setColorTheme,
|
||
dashboardFontScalePct,
|
||
setDashboardFontScalePct,
|
||
shadcnCustomColors,
|
||
setShadcnCustomColors,
|
||
resolvedThemeMode,
|
||
setQuickChatButtonModeImmediate,
|
||
setMobileNavPrimaryItemsImmediate,
|
||
reopenOnboardingWithNav,
|
||
viewMode,
|
||
projects,
|
||
projectsLoading,
|
||
handleSelectProject,
|
||
handleAddProject,
|
||
handlePauseProject,
|
||
handleResumeProject,
|
||
handleRemoveProject,
|
||
nodes,
|
||
graphPluginTaskView,
|
||
graphWorkflowSelection,
|
||
setGraphWorkflowSelection,
|
||
isRemote,
|
||
remoteData,
|
||
tasks,
|
||
workflowSteps,
|
||
subscribePluginEvents,
|
||
openDetailTask,
|
||
openFileInBrowser,
|
||
prAuthAvailable,
|
||
autoMerge,
|
||
mergeStrategy,
|
||
planAutoApproveEnabled,
|
||
settingsLoaded,
|
||
openMobileTasksInPopup,
|
||
taskDetailChatFirst,
|
||
skillsEnabled,
|
||
experimentalFeatures,
|
||
setQuickChatOpen,
|
||
chatComposerPrefill,
|
||
mailComposerPrefill,
|
||
onSendAsReport: handleSendChatMessageAsReport,
|
||
onOpenChatWithPrefill: openChatWithPrefill,
|
||
setMailboxUnreadCount,
|
||
setMissionTargetId,
|
||
setMissionResumeSessionId,
|
||
setMilestoneSliceResumeSessionId,
|
||
missionResumeSessionId,
|
||
missionTargetId,
|
||
milestoneSliceResumeSessionId,
|
||
setGoalAnchorId,
|
||
goalAnchorId,
|
||
agentAnchor,
|
||
setAgentAnchor,
|
||
agentsEnabled,
|
||
agentOnboardingEnabled,
|
||
handleOpenTaskLogs,
|
||
popOutTaskDetail: popOutTaskDetailForCurrentView,
|
||
selectedPrId,
|
||
insightsEnabled,
|
||
handleInsightTaskCreate,
|
||
researchEnabled,
|
||
openSettingsWithNav,
|
||
researchReadinessVersion,
|
||
evalsEnabled,
|
||
ideationEnabled,
|
||
memoryEnabled,
|
||
goalsEnabled,
|
||
handleOpenMission,
|
||
openPlanningWithInitialPlanWithNav,
|
||
ingestCreatedTasks,
|
||
nodesEnabled,
|
||
openWorkflowEditorWithNav,
|
||
handleGitHubImport,
|
||
devServerEnabled,
|
||
mainPanelDetailTask,
|
||
filteredBoardTasks,
|
||
maxConcurrent,
|
||
showWorktreeGrouping,
|
||
moveTask,
|
||
pauseTask,
|
||
openBoardTaskDetail,
|
||
openTaskDetailInMainPanel,
|
||
openGroupModalWithNav,
|
||
handleBoardQuickCreate,
|
||
openNewTaskWithNav,
|
||
subtaskBreakdownEnabled,
|
||
openSubtaskBreakdownWithNav,
|
||
toggleAutoMerge,
|
||
togglePlanAutoApprove,
|
||
globalPaused,
|
||
updateTask,
|
||
retryTask,
|
||
archiveTask,
|
||
unarchiveTask,
|
||
revertTask,
|
||
deleteTask,
|
||
archiveAllDone,
|
||
loadArchivedTasks,
|
||
loadMoreArchivedTasks,
|
||
archivedHasMore,
|
||
archivedLoadingMore,
|
||
searchQuery,
|
||
availableModels,
|
||
favoriteProviders,
|
||
favoriteModels,
|
||
handleOpenDetailWithTab,
|
||
handleToggleFavorite,
|
||
handleToggleModelFavorite,
|
||
staleHighFanoutBlockerAgeThresholdMs,
|
||
lastFetchTimeMs,
|
||
openCreateWorkflowWithNav,
|
||
sidebarActive,
|
||
isMobile,
|
||
mainPanelDetailInitialTab,
|
||
closeTaskDetailMainPanel,
|
||
setMainPanelDetailTask,
|
||
mergeTask,
|
||
resetTask,
|
||
duplicateTask,
|
||
unpauseTask,
|
||
capacityRiskBannerEnabled,
|
||
capacityRiskDismissed,
|
||
capacityRiskSignal,
|
||
handleDismissCapacityRisk,
|
||
// FNXC:WorkflowLifecycleColumns 2026-07-30-12:15: reuse the footer's per-task column traits
|
||
// so main-content views resolve lifecycle roles instead of matching column names.
|
||
columnFlagsByTaskId: footerColumnFlagsByTaskId,
|
||
AgentsView,
|
||
ChatView,
|
||
CommandCenter,
|
||
DevServerView,
|
||
DocumentsView,
|
||
EvalsView,
|
||
GoalsView,
|
||
InsightsView,
|
||
MemoryView,
|
||
PullRequestView,
|
||
ResearchView,
|
||
SecretsView,
|
||
SkillsView,
|
||
_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,
|
||
authTokenRecoveryOpen,
|
||
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: visibleSetupHasWarnings,
|
||
setupWarningDismissed,
|
||
handleDismissSetupWarning,
|
||
hasAiProvider,
|
||
hasGithub,
|
||
showGithubSetupWarning,
|
||
approvalBannerCandidate,
|
||
dismissApproval,
|
||
mailboxPendingApprovalCount,
|
||
handleTaskViewChange,
|
||
showGitHubStarPrompt,
|
||
gitHubStarPromptShown,
|
||
markGitHubStarPromptShown,
|
||
setShowGitHubStarPrompt,
|
||
};
|
||
return (
|
||
<ConfirmDialogProvider skipConfirmations={skipConfirmationDialogs}>
|
||
<ModalDismissPreferenceProvider enabled={dismissModalsOnOutsideClick}>
|
||
<QuickAddSubmitOnEnterProvider enabled={quickAddSubmitOnEnter}>
|
||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
|
||
<FileBrowserProvider openFile={openFileInBrowser}>
|
||
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
|
||
<CostBadgeProvider value={{ enabled: showCostBadgeOnCards, pricingOverrides: modelPricingOverrides }}>
|
||
{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}
|
||
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,
|
||
ideationView: ideationEnabled,
|
||
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-stack" data-testid="dashboard-project-stack">
|
||
<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}
|
||
mailboxUnreadCount={mailboxUnreadCount}
|
||
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
||
chatHasUnreadResponse={chatHasUnreadResponse}
|
||
planningNeedsInput={planningNeedsInput}
|
||
experimentalFeatures={{
|
||
insights: insightsEnabled,
|
||
memoryView: memoryEnabled,
|
||
devServerView: devServerEnabled,
|
||
researchView: researchEnabled,
|
||
evalsView: evalsEnabled,
|
||
ideationView: ideationEnabled,
|
||
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 && mobileNavVisible && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
|
||
>
|
||
<MainContent {...mainContentProps} />
|
||
{/*
|
||
FNXC:PlanningKeepAlive 2026-07-22-12:30:
|
||
Kept-alive Planning Mode renders as a sibling of the MainContent switch inside .project-content (which is position:relative for the hidden out-of-flow overlay state). Keyed by project id + planningEntryGeneration so project switches and payload-carrying planning entry points remount with fresh-open semantics while plain navigation restores the live instance.
|
||
*/}
|
||
{viewMode === "project" && currentProject && planningEverOpenedProjectId === currentProject.id && (
|
||
<PlanningKeepAlive
|
||
key={`${currentProject.id}:${modalManager.planningEntryGeneration}`}
|
||
active={planningViewActive}
|
||
projectId={currentProject.id}
|
||
tasks={tasks}
|
||
bgPlanningSessions={bgPlanningSessions}
|
||
modalManager={modalManager}
|
||
handleChangeTaskView={handleTaskViewChange}
|
||
handlePlanningTaskCreated={handlePlanningTaskCreated}
|
||
handlePlanningTasksCreated={handlePlanningTasksCreated}
|
||
openBoardTaskDetail={openBoardTaskDetail}
|
||
openWorkflowEditorWithNav={openWorkflowEditorWithNav}
|
||
/>
|
||
)}
|
||
</div>
|
||
{rightDock.dock}
|
||
</div>
|
||
{/*
|
||
FNXC:Terminal 2026-07-26-11:40:
|
||
Mount the terminal ONLY while it is open. It used to be mounted for the whole session (visibility driven purely by `isOpen`), so a closed terminal still ran `useTerminalSessions` + `useTerminal`: a live PTY WebSocket, its heartbeat interval, and — because xterm is torn down on close, leaving no `onData` subscriber — an UNBOUNDED client-side buffer of every byte the shell emitted while the user was elsewhere. Background timers/sockets are a primary tab-discard signal on iOS Safari and Chrome Android, and the growing buffer is the memory pressure that triggers the discard; together they are why returning to the dashboard after a few minutes costs a full white-splash reload.
|
||
This does NOT regress the "persist across tab switches" requirement: `terminalOpen` survives view switches, so switching views keeps the modal mounted and its buffer intact. Only an explicit close unmounts. Terminal tabs are server-side PTY sessions restored on reopen, with scrollback replayed by the server on reconnect.
|
||
|
||
FNXC:Terminal 2026-07-26-14:10 (CORRECTION — the paragraph above originally ended "close already disposed xterm and its scrollback, so nothing is lost that closing did not already discard"; that was FALSE and must not be reasserted):
|
||
Closing DID dispose xterm, but the WebSocket stayed open with zero `onData` subscribers, so `useTerminal`'s `initialBufferRef.current.data` accumulated EVERY byte emitted while closed and `onData()` replayed the whole array verbatim when xterm re-initialized on reopen. Reopen was therefore lossless for arbitrarily long closed-terminal output. It no longer is: unmounting closes the socket, and reopen now starts from the server's replay — `MAX_SCROLLBACK_SIZE = 50000` CHARACTERS in `packages/dashboard/src/terminal-service.ts` (~600-800 typical lines), not lines and not unbounded.
|
||
Keeping the component mounted is nonetheless the WRONG repair, because the property it preserved was itself the defect: that buffer has no cap and is never drained while closed, so a long-running command (a watch build, `tail -f`) left in a closed terminal grows the heap without bound for as long as the app is open — strictly worse than losing scrollback, and precisely the memory pressure that gets the tab discarded. There is no in-component way to keep both properties: the buffer lives inside `useTerminal`, which cannot outlive the mount.
|
||
The real ceiling is the server ring, and the correct place to recover the lost history is to raise `MAX_SCROLLBACK_SIZE` (the repo's own CLI-agent session ring is 512 KiB by comparison) or to bound-and-persist the client buffer outside the component. Both are outside this change's file scope; this comment records the deliberate, known trade so it is not rediscovered as a mystery.
|
||
*/}
|
||
{currentProject && modalManager.terminalOpen && (
|
||
<TerminalModal
|
||
isOpen={modalManager.terminalOpen}
|
||
onClose={closeTerminalWithNav}
|
||
initialCommand={modalManager.terminalInitialCommand}
|
||
initialCommandGeneration={modalManager.terminalInitialCommandGeneration}
|
||
projectId={currentProject.id}
|
||
footerVisible={executorFooterVisible}
|
||
/>
|
||
)}
|
||
</div>
|
||
{rightDock.modal}
|
||
{executorFooterVisible && currentProject && (
|
||
<ExecutorStatusBar
|
||
tasks={footerTasks}
|
||
projectId={currentProject.id}
|
||
columnFlagsByTaskId={footerColumnFlagsByTaskId}
|
||
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
|
||
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={mobileNavVisible ? handleTaskViewChange : () => {}}
|
||
footerVisible={mobileNavVisible}
|
||
hidden={!mobileNavVisible}
|
||
modalOpen={modalManager.anyModalOpen}
|
||
keyboardOpen={mobileNavKeyboardOpen}
|
||
mobileNavPrimaryItems={mobileNavPrimaryItems}
|
||
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}
|
||
planningNeedsInput={planningNeedsInput}
|
||
onOpenUsage={() => openUsageWithNav(null)}
|
||
onViewAllProjects={handleViewAllProjects}
|
||
onRunScript={runScriptWithNav}
|
||
projectId={currentProject?.id}
|
||
showSkillsTab={skillsEnabled}
|
||
experimentalFeatures={{
|
||
insights: insightsEnabled,
|
||
memoryView: memoryEnabled,
|
||
devServer: devServerEnabled,
|
||
devServerView: devServerEnabled,
|
||
researchView: researchEnabled,
|
||
evalsView: evalsEnabled,
|
||
ideationView: ideationEnabled,
|
||
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.
|
||
|
||
FNXC:ChatModal 2026-06-27-00:00:
|
||
Quick Chat is a transient utility window, so it opts into FloatingWindow's outside-click dismissal in addition to minimize, close, and maximize controls. Task pop-outs intentionally do not opt in because they are persistent workspace windows that should survive page clicks.
|
||
|
||
FNXC:ChatModal 2026-06-28-00:00:
|
||
Outside-click dismissal is now governed by the project-scoped quickChatCloseOnOutsideClick setting, default-on to preserve FN-7152 behavior. Other FloatingWindow callers still do not pass closeOnOutsidePointerDown, so task pop-outs and utility windows remain persistent.
|
||
*/}
|
||
{viewMode === "project" && currentProject && (
|
||
<QuickChatFAB
|
||
showFAB={quickChatButtonMode === "floating"}
|
||
open={quickChatOpen}
|
||
onOpenChange={setQuickChatOpen}
|
||
/>
|
||
)}
|
||
{currentProject && quickChatEverOpenedProjectId === currentProject.id && (
|
||
<FloatingWindow
|
||
key={currentProject.id}
|
||
windowKey="chat-modal"
|
||
hidden={!quickChatOpen}
|
||
title="Chat"
|
||
onClose={() => setQuickChatOpen(false)}
|
||
closeOnOutsidePointerDown={quickChatCloseOnOutsideClick}
|
||
hideHeader
|
||
dragHandleSelector=".chat-view--floating .view-header"
|
||
className="floating-window--chat"
|
||
layer="task-detail"
|
||
/*
|
||
FNXC:ChatModal 2026-07-17-15:55:
|
||
Quick Chat and task-detail popups must share the task popup interaction stack: either
|
||
overlapping surface claims the front on pointer/focus. Other utility FloatingWindows keep
|
||
their higher utility band, so this scoped opt-in cannot change Terminal, Files, or New Task.
|
||
*/
|
||
/*
|
||
FNXC:ModalGeometryPersistence 2026-07-26-21:00:
|
||
Quick Chat is a full-screen sheet at both the narrow and short-viewport CSS breakpoints.
|
||
Suspend desktop geometry restoration, writes, drag, and resize controls for both surfaces
|
||
so a short sheet cannot corrupt the desktop window it restores after rotation.
|
||
*/
|
||
suspendGeometryPersistenceOnMobile
|
||
suspendGeometryPersistenceOnShortViewport
|
||
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
|
||
initialComposerDraft={chatComposerPrefill?.text}
|
||
initialComposerDraftNonce={chatComposerPrefill?.nonce}
|
||
onSendAsReport={handleSendChatMessageAsReport}
|
||
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.
|
||
|
||
FNXC:TaskPopupGeometry 2026-07-03-00:00:
|
||
Every task-detail FloatingWindow keeps its per-task windowKey for DOM identity, dedupe, cascade fallback, and z-index independence, but all task-detail popups share one persisted geometry key so operators do not resize or reposition the popup between tasks.
|
||
|
||
FNXC:TaskPopupLayer 2026-07-17-15:55:
|
||
Task-detail popups and Quick Chat share the task-detail interaction stack, so pointer/focus
|
||
moves either overlapping surface above the other. Other utility windows retain their separate,
|
||
higher utility band and cannot be reordered by task-popup interaction.
|
||
|
||
FNXC:TaskPopupViewGating 2026-07-22-13:15:
|
||
FN remount-churn fix R7 supersedes the FN-8016 remount behavior: ALL popped-out entries render, and off-origin-view windows are hidden via FloatingWindow's hidden contract (visibility-based, aria-hidden, effects suspended) instead of being filtered out of the render array. Returning to the origin view is an instant reveal of the live window — the embedded task detail, including an open terminal WebSocket, stays mounted. The `isTaskPopupVisibleForView` predicate and per-origin-view addressability are unchanged; `visiblePoppedOutTaskEntries` still feeds the Escape/nav-shortcut consumer. While hidden, `active={false}` closes the detail's SSE/EventSource channels (R8). Each FloatingWindow key includes its origin so identical task ids never collide across views.
|
||
*/}
|
||
{poppedOutTaskEntries.map(({ task: snapshot, originTaskView, initialTab }) => {
|
||
const boardTask = tasks.find((candidate) => candidate.id === snapshot.id);
|
||
const liveTask = boardTask ? mergeTaskSnapshot(snapshot, boardTask) : snapshot;
|
||
const popupKey = taskPopupIdentityKey(snapshot.id, originTaskView);
|
||
const close = () => closePoppedOutTaskWithNav(snapshot.id, originTaskView);
|
||
const popupVisible = taskPopupsVisibleOnCurrentView(originTaskView);
|
||
return (
|
||
<FloatingWindow
|
||
key={popupKey}
|
||
windowKey={`task-detail-${snapshot.id}-${originTaskView ?? "global"}`}
|
||
title={liveTask.id}
|
||
hidden={!popupVisible}
|
||
onClose={close}
|
||
hideHeader
|
||
dragHandleSelector=".task-detail-content--embedded > .modal-header"
|
||
className="floating-window--task-detail"
|
||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Task pop-outs are full-screen sheets at ≤768px; preserve the shared desktop geometry record for their next movable reopen. */
|
||
suspendGeometryPersistenceOnMobile
|
||
persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY}
|
||
layer="task-detail"
|
||
>
|
||
<TaskDetailContent
|
||
task={liveTask}
|
||
initialTab={initialTab}
|
||
projectId={currentProject?.id}
|
||
tasks={tasks}
|
||
globalPaused={globalPaused}
|
||
active={popupVisible}
|
||
embedded
|
||
onOpenDetail={popOutTaskDetailForCurrentView}
|
||
onMoveTask={moveTask}
|
||
/* FNXC:TaskRevert 2026-08-01-20:27: Popped-out detail preserves Delete-or-Revise recovery for reverted tasks. */
|
||
onReviseTask={(task) => modalManager.openNewTaskWithDescription(task.description)}
|
||
onDeleteTask={deleteTask}
|
||
onMergeTask={mergeTask}
|
||
onRetryTask={retryTask}
|
||
onPauseTask={pauseTask}
|
||
onUnpauseTask={unpauseTask}
|
||
onBypassReview={bypassReview}
|
||
onResetTask={resetTask}
|
||
onDuplicateTask={duplicateTask}
|
||
onRequestClose={close}
|
||
addToast={addToast}
|
||
prAuthAvailable={prAuthAvailable}
|
||
autoMergeEnabled={autoMerge}
|
||
taskDetailChatFirst={taskDetailChatFirst}
|
||
/>
|
||
</FloatingWindow>
|
||
);
|
||
})}
|
||
<AppModals
|
||
projectId={currentProject?.id}
|
||
tasks={tasks}
|
||
columnFlagsByTaskId={footerColumnFlagsByTaskId}
|
||
globalPaused={globalPaused}
|
||
projects={projects}
|
||
currentProject={currentProject}
|
||
addToast={addToast}
|
||
toasts={toasts}
|
||
removeToast={removeToast}
|
||
modalManager={modalManager}
|
||
projectActions={{ handleAddProject, handleSetupComplete, handleModelOnboardingComplete }}
|
||
taskHandlers={{
|
||
handleModalCreate,
|
||
handlePlanningTaskCreated,
|
||
handlePlanningTasksCreated,
|
||
handleSubtaskTasksCreated,
|
||
handleGitHubImport,
|
||
}}
|
||
onPlanningMode={openPlanningWithInitialPlanWithNav}
|
||
onOpenChatWithPrefill={openChatWithPrefill}
|
||
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
|
||
taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, revertTask, retryTask, pauseTask, unpauseTask, bypassReview, resetTask, duplicateTask }}
|
||
deepLink={{ handleDetailClose }}
|
||
settings={{ prAuthAvailable, autoMerge, taskDetailChatFirst, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate, setMobileNavPrimaryItemsImmediate }}
|
||
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)}
|
||
/>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</CostBadgeProvider>
|
||
</RetryWarningProvider>
|
||
</FileBrowserProvider>
|
||
</NavigationHistoryProvider>
|
||
</QuickAddSubmitOnEnterProvider>
|
||
</ModalDismissPreferenceProvider>
|
||
</ConfirmDialogProvider>
|
||
);
|
||
}
|
||
|
||
export function App() {
|
||
return (
|
||
<I18nextProvider i18n={i18n}>
|
||
<ToastProvider>
|
||
<ShellHostProvider>
|
||
<ShellProvider>
|
||
<NodeProvider>
|
||
<AppInner />
|
||
</NodeProvider>
|
||
</ShellProvider>
|
||
</ShellHostProvider>
|
||
</ToastProvider>
|
||
</I18nextProvider>
|
||
);
|
||
}
|