Files
fusion/packages/dashboard/app/hooks/useModalManager.ts
gsxdsm 4f929acc10 fix(dashboard): stop over-aggressive component unmounts (keep-alive for planning, terminals, popups) (#2420)
Implements
docs/plans/2026-07-22-001-fix-dashboard-remount-churn-plan.md: every
confirmed source of unnecessary unmount/remount churn in the dashboard,
plus a keep-alive layer for conversation- and terminal-bearing surfaces.

## What changed

**Keying / component identity (U1–U3)**
- Streaming chat segment key no longer embeds `entries.length` — an
expanded thinking block stays expanded while entries stream into it
(R1).
- Dock task list keys `TaskCard` rows by `task.id` (occurrence suffix
only for the duplicate-id anomaly) instead of `id-index` — no remount on
reorder/filter/status change (R2).
- `ProviderStatusBadge` / `GitHubStatusBadge` hoisted out of
ModelOnboardingModal's render body (R3); MCP server rows key by
`server.name` alone (R4).

**Keep-alive layer (U4–U6)**
- New shared `KeepAliveView` wrapper: visible = in-flow flex child;
hidden = out-of-flow `position:absolute; inset:0` with
`visibility:hidden; pointer-events:none` + `aria-hidden` (never
`display:none`, so xterm geometry never collapses).
- Planning Mode renders as a kept-alive sibling of the MainContent
switch after first open (per-project latch mirroring Quick Chat). While
hidden, the session-list SSE, recovery poll, and elapsed ticker suspend
via a new `active` prop; reveal re-subscribes and refreshes the sessions
list once. Payload-carrying entry points (initial-plan handoff, resume)
and project switches remount via a new
`modalManager.planningEntryGeneration` key, preserving pre-keep-alive
fresh-open semantics. `recordResumeEvent` instrumentation records
`remount` on first activation and `route-active` on reveal.
- Task-detail Terminal / Worktree-terminal / Planner-chat tabs stay
mounted-but-hidden after first open (per-task latches; task switch/close
still disposes fully). `SessionTerminal` gains `active`: reveal refits +
forces a font remeasure, and if the WS died while hidden it re-runs the
full attach lifecycle (dead-socket recovery).
- Popped-out task windows hide via FloatingWindow `hidden` instead of
leaving the render array; `TaskDetailContent` gains `active` so hidden
popups close their SSE/EventSource channels while the terminal WS stays
open. `visiblePoppedOutTaskEntries` remains the Escape-shortcut
consumer.

**Planning Mode internal-transition audit (U7)**
- Audit findings: session-list mode and mobile list/detail flips are
CSS-class transitions over one always-mounted detail pane (no
state-discarding unmounts); re-selecting the active session is an
early-return visibility restore; session switching intentionally reloads
from the session row (stream re-attach for generating sessions);
remaining index keys are on stateless lists. No product-code defects
found; regression tests now lock the always-mounted invariant on desktop
+ mobile.

**Cheap-view state (U8)**
- CommandCenter (active sub-tab + date range) and DevServerView
(selected script/task + typed-but-unsent command) persist per project
via `modalPersistence` and restore after their (intentional) unmount
round-trips. Also fixed the candidate auto-fill effect clobbering a
customized non-empty command.

## Symptom Verification
- **Original symptom:** streaming thinking blocks collapsed mid-stream;
terminals reconnected and lost scroll/input on tab flips; Planning Mode
lost in-flight interviews on navigation; popped-out windows vanished
off-view; dock cards remounted on reorder.
- **Exact reproduction:** (1) expand a thinking block during a stream;
(2) run a command in the Terminal tab, flip to Plan and back; (3) start
a planning interview, navigate Board and back; (4) pop out a task with
board/list-only scoping and switch views; (5) change a dock task's
status.
- **Assertion it is gone:** component-identity/instrumentation tests in
TaskChatTab, SessionTerminal, TaskDetailModal
(worktree/planner-chat/tabs), PlanningModeModal keep-alive +
internal-transitions, App keep-alive round-trip, and
App.taskPopupViewGating assert no remount and preserved state for each
repro, across desktop and mobile breakpoints.

## Verification
- File-scoped vitest: 23 files / 1091 tests green (all touched suites
plus FloatingWindow, TerminalModal, TaskPlannerChatTab,
lazy-loaded-views guard, App suites).
- `pnpm verify:fast`: PASS (13 steps — scoped typecheck/build, CLI
build, boot smoke).
- `pnpm check:changesets`: passes; changeset
`fix-dashboard-remount-churn` (`@runfusion/fusion` patch, labeled
format).
- Known pre-existing failures NOT caused by this branch (verified
failing at base a224c1111 in a clean worktree): 7 tests in
`TaskDetailModal.oversight-controls/oversight-mobile/models-progress-workflow`.
- jsdom cannot prove rendered-grid correctness for xterm reveal; per the
plan's risk note, manual browser verification of terminal reveal remains
recommended.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Switching views or tabs no longer resets Planning Mode, task details,
terminals, planner chats, or popped-out task windows.
  * Streaming content remains expanded and stable as new entries arrive.
* Hidden views suspend background activity and resume correctly when
shown.
  * Terminal sessions reconnect automatically when needed.

* **Improvements**
  * Command Center and Dev Server selections persist per project.
  * Custom Dev Server commands are preserved while browsing suggestions.
* Improved stability when reordering task lists and updating server
states.

* **Documentation**
* Updated dashboard guidance for hidden, retained task pop-ups and view
transitions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:50:47 -07:00

662 lines
26 KiB
TypeScript

import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Task, TaskDetail } from "@fusion/core";
import type { SectionId } from "../components/SettingsModal";
import type { ToastType } from "./useToast";
import { removeScopedItem } from "../utils/projectStorage";
/*
FNXC:TaskDetailActivity 2026-06-30-22:15:
Keep `chat` as the public initial-tab id for the renamed Activity task-detail tab so existing dashboard callers and deep links remain compatible until the future planner Chat tab ships under its own contract. Legacy `logs` callers are also preserved by TaskDetailModal, which opens Activity → Feed instead of rendering a top-level Logs tab.
*/
export type DetailTaskTab =
| "summary"
| "chat"
| "definition"
| "logs"
| "changes"
| "comments"
| "model"
| "workflow"
| "pr"
| "retries";
export type DetailTaskOrigin = "list-mobile";
export type DetailTaskInitialAction = "refine";
export interface DetailTaskOpenOptions {
origin?: DetailTaskOrigin;
initialAction?: DetailTaskInitialAction;
}
export interface DetailTaskInitialActionRequest {
action: DetailTaskInitialAction;
requestId: number;
}
interface UseModalManagerOptions {
projectId?: string;
planningSessions: Array<{ id: string }>;
}
/**
* State and handler contract for App-level modal/overlay orchestration.
*/
export interface ModalManager {
// State
newTaskModalOpen: boolean;
newTaskInitialDescription: string | null;
newTaskInitialWorkflowId: string | null | undefined;
isPlanningOpen: boolean;
planningInitialPlan: string | null;
planningResumeSessionId: string | undefined;
planningWorkflowId: string | null | undefined;
/*
FNXC:PlanningKeepAlive 2026-07-22-12:20:
Monotonic counter bumped by every payload-carrying planning entry point (initial plan handoff, resume/session open). The kept-alive embedded Planning instance keys on it so explicit handoffs remount with the pre-keep-alive fresh-open semantics (auto-start, session load), while plain sidebar navigation (openPlanning/closePlanning) leaves it untouched and restores the live instance.
*/
planningEntryGeneration: number;
isSubtaskOpen: boolean;
subtaskInitialDescription: string | null;
subtaskResumeSessionId: string | undefined;
subtaskWorkflowId: string | null | undefined;
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
detailTask: (Task | TaskDetail) | null;
detailTaskInitialTab: DetailTaskTab | undefined;
detailTaskInitialAction: DetailTaskInitialActionRequest | null;
detailTaskOrigin: DetailTaskOrigin | null;
groupModalGroupId: string | null;
settingsOpen: boolean;
settingsInitialSection: SectionId | undefined;
schedulesOpen: boolean;
githubImportOpen: boolean;
usageOpen: boolean;
usageAnchorRect: DOMRect | null;
terminalOpen: boolean;
terminalInitialCommand: string | undefined;
terminalInitialCommandGeneration: number;
filesOpen: boolean;
fileBrowserWorkspace: string;
fileBrowserInitialFile: string | null;
activityLogOpen: boolean;
gitManagerOpen: boolean;
workflowEditorOpen: boolean;
/** When the workflow editor opens, which internal panel to pre-select (U9 redirect stubs). */
workflowEditorInitialPanel?: "settings";
/** When the workflow editor opens, which modal action to start. */
workflowEditorInitialAction?: "create";
/** When the workflow editor opens for editing, which workflow id to pre-select. */
workflowEditorInitialWorkflowId?: string;
agentsOpen: boolean;
scriptsOpen: boolean;
setupWizardOpen: boolean;
modelOnboardingOpen: boolean;
anyModalOpen: boolean;
// Handlers
openNewTask: (workflowId?: string | null) => void;
openNewTaskWithDescription: (description: string) => void;
closeNewTask: () => void;
openPlanning: () => void;
openPlanningWithInitialPlan: (initialPlan: string, workflowId?: string | null) => void;
resumePlanning: () => void;
openPlanningWithSession: (sessionId: string) => void;
/**
FNXC:PlanningModals 2026-07-23-00:00:
One-shot consumption of the seeded initial plan. Embedded Planning calls this the moment its
auto-start fires; the payload must not survive that start, because Planning unmounts on
main-content navigation and a still-set planningInitialPlan re-auto-started a duplicate
planning session on every navigate-back remount.
*/
clearPlanningInitialPlan: () => void;
closePlanning: () => void;
openSubtaskBreakdown: (description: string, workflowId?: string | null) => void;
openSubtaskWithSession: (sessionId: string) => void;
closeSubtask: () => void;
openDetailTask: (
task: Task | TaskDetail,
initialTab?: DetailTaskTab,
options?: DetailTaskOpenOptions,
) => void;
openDetailWithChangesTab: (task: Task | TaskDetail) => void;
updateDetailTask: (updated: Partial<TaskDetail>) => void;
closeDetailTask: () => void;
openGroupModal: (groupId: string) => void;
closeGroupModal: () => void;
openSettings: (section?: SectionId) => void;
/*
FNXC:Settings 2026-06-22-00:00:
Sets the Settings initial/active section WITHOUT opening the modal overlay. Used by the embedded main-content Settings view so header/sidebar/deep-link entry points can carry a requested section while navigating to taskView === "settings" instead of mounting the dialog.
*/
setSettingsSection: (section?: SectionId) => void;
closeSettings: () => void;
openSchedules: () => void;
closeSchedules: () => void;
openGitHubImport: () => void;
closeGitHubImport: () => void;
openUsage: (anchorRect?: DOMRect | null) => void;
closeUsage: () => void;
toggleTerminal: () => void;
closeTerminal: () => void;
openFiles: (workspace?: string, initialFile?: string | null) => void;
closeFiles: () => void;
setFileWorkspace: (workspace: string) => void;
openActivityLog: () => void;
closeActivityLog: () => void;
openGitManager: () => void;
closeGitManager: () => void;
openWorkflowEditor: (initialPanelOrAction?: "settings" | "create", initialWorkflowId?: string) => void;
closeWorkflowEditor: () => void;
openAgents: () => void;
closeAgents: () => void;
openScripts: () => void;
closeScripts: () => void;
runScript: (name: string, command: string) => Promise<void>;
openSetupWizard: () => void;
closeSetupWizard: () => void;
openModelOnboarding: () => void;
closeModelOnboarding: () => void;
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Switching the active project must dismiss modals that show the previous project's data
(task detail, group, new task, subtask breakdown, GitHub import, files, git manager,
activity log, workflow editor, scripts, terminal) and drop pending planning payloads so
Planning does not reopen the old project's plan. Cross-project modals (settings,
schedules, usage, agents, setup wizard, model onboarding) stay open.
*/
closeProjectScopedModals: () => void;
onPlanningTaskCreated: (task: Task, addToast: (message: string, type?: ToastType) => void) => void;
onPlanningTasksCreated: (tasks: Task[], addToast: (message: string, type?: ToastType) => void) => void;
onSubtaskTasksCreated: (tasks: Task[], addToast: (message: string, type?: ToastType) => void) => void;
}
/**
* Centralized modal manager for dashboard App-level UI state.
*
* Encapsulates all modal open/close booleans, related resume/initial payloads,
* and cross-modal transitions (for example, script runner -> terminal handoff).
*/
export function useModalManager(options: UseModalManagerOptions): ModalManager {
const { t } = useTranslation("app");
const { planningSessions } = options;
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
const [newTaskInitialDescription, setNewTaskInitialDescription] = useState<string | null>(null);
const [newTaskInitialWorkflowId, setNewTaskInitialWorkflowId] = useState<string | null | undefined>(undefined);
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
const [planningWorkflowId, setPlanningWorkflowId] = useState<string | null | undefined>(undefined);
// FNXC:PlanningKeepAlive 2026-07-22-12:20: see ModalManager.planningEntryGeneration.
const [planningEntryGeneration, setPlanningEntryGeneration] = useState(0);
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
const [subtaskWorkflowId, setSubtaskWorkflowId] = useState<string | null | undefined>(undefined);
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
const [detailTask, setDetailTask] = useState<(Task | TaskDetail) | null>(null);
/**
* FNXC:TaskDetailTabs 2026-06-17-00:00:
* FN-6532 makes Chat the default task-detail view whenever a task opens without an explicit tab request.
*
* FNXC:TaskDetailSummaryTab 2026-06-27-00:00:
* Store omitted task-detail tabs as `undefined` so done tasks can resolve the implicit landing tab to Summary without stealing explicit Chat requests.
*/
const [detailTaskInitialTab, setDetailTaskInitialTab] = useState<DetailTaskTab | undefined>(undefined);
/*
FNXC:DoneTaskRefine 2026-07-01-00:00:
Done-task card/list context menus must open the existing Task Detail refinement modal after right-click or long-press. Store refinement as a one-shot action request with a monotonically increasing id so selecting Refine again for an already-open task reopens the composer without duplicating API/form logic outside TaskDetailContent.
*/
const [detailTaskInitialAction, setDetailTaskInitialAction] = useState<DetailTaskInitialActionRequest | null>(null);
const detailTaskInitialActionRequestIdRef = useRef(0);
const [detailTaskOrigin, setDetailTaskOrigin] = useState<DetailTaskOrigin | null>(null);
const [groupModalGroupId, setGroupModalGroupId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [schedulesOpen, setSchedulesOpen] = useState(false);
const [githubImportOpen, setGitHubImportOpen] = useState(false);
const [usageOpen, setUsageOpen] = useState(false);
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [terminalInitialCommandGeneration, setTerminalInitialCommandGeneration] = useState(0);
const [filesOpen, setFilesOpen] = useState(false);
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
const [fileBrowserInitialFile, setFileBrowserInitialFile] = useState<string | null>(null);
const [activityLogOpen, setActivityLogOpen] = useState(false);
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowEditorOpen, setWorkflowEditorOpen] = useState(false);
const [workflowEditorInitialPanel, setWorkflowEditorInitialPanel] = useState<"settings" | undefined>(undefined);
const [workflowEditorInitialAction, setWorkflowEditorInitialAction] = useState<"create" | undefined>(undefined);
const [workflowEditorInitialWorkflowId, setWorkflowEditorInitialWorkflowId] = useState<string | undefined>(undefined);
const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
const [modelOnboardingOpen, setModelOnboardingOpen] = useState(false);
const anyModalOpen = Boolean(
detailTask ||
groupModalGroupId ||
settingsOpen ||
newTaskModalOpen ||
/*
FNXC:Navigation 2026-06-21-00:00:
FN-6886 reuses Planning Mode state only as docked-view payload storage, so it must not make the app behave as though a blocking modal overlay is open.
*/
isSubtaskOpen ||
terminalOpen ||
filesOpen ||
activityLogOpen ||
gitManagerOpen ||
workflowEditorOpen ||
scriptsOpen ||
agentsOpen ||
usageOpen ||
schedulesOpen ||
githubImportOpen ||
setupWizardOpen ||
modelOnboardingOpen,
);
const openNewTask = useCallback((workflowId?: string | null) => {
setNewTaskInitialDescription(null);
setNewTaskInitialWorkflowId(workflowId);
setNewTaskModalOpen(true);
}, []);
const openNewTaskWithDescription = useCallback((description: string) => {
setNewTaskInitialDescription(description);
setNewTaskInitialWorkflowId(undefined);
setNewTaskModalOpen(true);
}, []);
const closeNewTask = useCallback(() => {
setNewTaskModalOpen(false);
setNewTaskInitialDescription(null);
setNewTaskInitialWorkflowId(undefined);
}, []);
const openPlanning = useCallback(() => {
// FNXC:PlanningModals 2026-06-20-20:10:
// A fresh planning open must clear any resume-session id / initial plan left
// by a prior resumePlanning/openPlanningWith* flow; otherwise the modal reopens
// into the stale session or pre-fills an old plan instead of starting blank.
setPlanningResumeSessionId(undefined);
setPlanningInitialPlan(null);
setPlanningWorkflowId(undefined);
setIsPlanningOpen(true);
}, []);
const openPlanningWithInitialPlan = useCallback((initialPlan: string, workflowId?: string | null) => {
// FNXC:PlanningModals 2026-06-20-20:10: clear a stale resume-session id so the
// supplied initial plan is honored rather than being overridden by an old session.
setPlanningResumeSessionId(undefined);
setPlanningInitialPlan(initialPlan);
setPlanningWorkflowId(workflowId);
// FNXC:PlanningKeepAlive 2026-07-22-12:20: payload-carrying entries bump the generation so the kept-alive instance remounts with fresh-open semantics.
setPlanningEntryGeneration((generation) => generation + 1);
setIsPlanningOpen(true);
}, []);
const resumePlanning = useCallback(() => {
const session = planningSessions[0];
if (!session) return;
setPlanningWorkflowId(undefined);
setPlanningResumeSessionId(session.id);
setPlanningEntryGeneration((generation) => generation + 1);
setIsPlanningOpen(true);
}, [planningSessions]);
const openPlanningWithSession = useCallback((sessionId: string) => {
setPlanningWorkflowId(undefined);
setPlanningResumeSessionId(sessionId);
setPlanningEntryGeneration((generation) => generation + 1);
setIsPlanningOpen(true);
}, []);
const clearPlanningInitialPlan = useCallback(() => {
setPlanningInitialPlan(null);
}, []);
const closePlanning = useCallback(() => {
setIsPlanningOpen(false);
setPlanningInitialPlan(null);
setPlanningResumeSessionId(undefined);
setPlanningWorkflowId(undefined);
}, []);
const openSubtaskBreakdown = useCallback((description: string, workflowId?: string | null) => {
// FNXC:PlanningModals 2026-06-20-20:10: clear a stale subtask resume-session id
// so a new breakdown starts fresh rather than reopening a prior session.
setSubtaskResumeSessionId(undefined);
setSubtaskInitialDescription(description);
setSubtaskWorkflowId(workflowId);
setIsSubtaskOpen(true);
}, []);
const openSubtaskWithSession = useCallback((sessionId: string) => {
setSubtaskWorkflowId(undefined);
setSubtaskResumeSessionId(sessionId);
setIsSubtaskOpen(true);
}, []);
const closeSubtask = useCallback(() => {
setIsSubtaskOpen(false);
setSubtaskInitialDescription(null);
setSubtaskResumeSessionId(undefined);
setSubtaskWorkflowId(undefined);
}, []);
/**
* FNXC:TaskDetailTabs 2026-06-17-00:00:
* Open-detail callers that omit initialTab should land on the task-detail default; explicit tab requests preserve caller intent.
*/
const openDetailTask = useCallback((
task: Task | TaskDetail,
initialTab?: DetailTaskTab,
options?: DetailTaskOpenOptions,
) => {
setDetailTask(task);
setDetailTaskInitialTab(initialTab);
setDetailTaskInitialAction(options?.initialAction ? { action: options.initialAction, requestId: detailTaskInitialActionRequestIdRef.current += 1 } : null);
setDetailTaskOrigin(options?.origin ?? null);
}, []);
const openDetailWithChangesTab = useCallback((task: Task | TaskDetail) => {
setDetailTask(task);
setDetailTaskInitialTab("changes");
setDetailTaskInitialAction(null);
setDetailTaskOrigin(null);
}, []);
const updateDetailTask = useCallback((updated: Partial<TaskDetail>) => {
setDetailTask((prev) => {
if (!prev) return prev;
if (updated.id !== undefined && updated.id !== prev.id) {
return prev;
}
return { ...prev, ...updated };
});
}, []);
const closeDetailTask = useCallback(() => {
setDetailTask(null);
setDetailTaskInitialAction(null);
setDetailTaskOrigin(null);
}, []);
const openGroupModal = useCallback((groupId: string) => {
setGroupModalGroupId(groupId);
}, []);
const closeGroupModal = useCallback(() => {
setGroupModalGroupId(null);
}, []);
const openSettings = useCallback((section?: SectionId) => {
setSettingsInitialSection(section);
setSettingsOpen(true);
}, []);
const setSettingsSection = useCallback((section?: SectionId) => {
setSettingsInitialSection(section);
}, []);
const closeSettings = useCallback(() => {
setSettingsOpen(false);
setSettingsInitialSection(undefined);
}, []);
const openSchedules = useCallback(() => setSchedulesOpen(true), []);
const closeSchedules = useCallback(() => setSchedulesOpen(false), []);
const openGitHubImport = useCallback(() => setGitHubImportOpen(true), []);
const closeGitHubImport = useCallback(() => setGitHubImportOpen(false), []);
const openUsage = useCallback((anchorRect?: DOMRect | null) => {
setUsageAnchorRect(anchorRect ?? null);
setUsageOpen(true);
}, []);
const closeUsage = useCallback(() => {
setUsageOpen(false);
setUsageAnchorRect(null);
}, []);
const toggleTerminal = useCallback(() => {
setTerminalOpen((prev) => !prev);
}, []);
const closeTerminal = useCallback(() => {
setTerminalOpen(false);
setTerminalInitialCommand(undefined);
}, []);
const openFiles = useCallback((workspace?: string, initialFile?: string | null) => {
if (typeof workspace === "string" && workspace) {
setFileBrowserWorkspace(workspace);
}
if (typeof initialFile === "string" || initialFile === null) {
setFileBrowserInitialFile(initialFile);
} else {
setFileBrowserInitialFile(null);
}
setFilesOpen(true);
}, []);
const closeFiles = useCallback(() => {
setFilesOpen(false);
setFileBrowserInitialFile(null);
}, []);
const setFileWorkspace = useCallback((workspace: string) => {
if (typeof workspace === "string" && workspace) {
setFileBrowserWorkspace(workspace);
}
}, []);
const openActivityLog = useCallback(() => setActivityLogOpen(true), []);
const closeActivityLog = useCallback(() => setActivityLogOpen(false), []);
const openGitManager = useCallback(() => setGitManagerOpen(true), []);
const closeGitManager = useCallback(() => setGitManagerOpen(false), []);
const openWorkflowEditor = useCallback((initialPanelOrAction?: "settings" | "create", initialWorkflowId?: string) => {
const isSettingsOpen = initialPanelOrAction === "settings";
const isCreateOpen = initialPanelOrAction === "create";
setWorkflowEditorInitialPanel(isSettingsOpen ? "settings" : undefined);
setWorkflowEditorInitialAction(isCreateOpen ? "create" : undefined);
setWorkflowEditorInitialWorkflowId(!isSettingsOpen && !isCreateOpen ? initialWorkflowId : undefined);
setWorkflowEditorOpen(true);
}, []);
const closeWorkflowEditor = useCallback(() => {
setWorkflowEditorOpen(false);
setWorkflowEditorInitialPanel(undefined);
setWorkflowEditorInitialAction(undefined);
setWorkflowEditorInitialWorkflowId(undefined);
}, []);
const openAgents = useCallback(() => setAgentsOpen(true), []);
const closeAgents = useCallback(() => setAgentsOpen(false), []);
const openScripts = useCallback(() => setScriptsOpen(true), []);
const closeScripts = useCallback(() => setScriptsOpen(false), []);
const runScript = useCallback(async (_name: string, command: string) => {
setScriptsOpen(false);
setTerminalInitialCommand(command);
setTerminalInitialCommandGeneration((generation) => generation + 1);
setTerminalOpen(true);
}, []);
const openSetupWizard = useCallback(() => setSetupWizardOpen(true), []);
const closeSetupWizard = useCallback(() => setSetupWizardOpen(false), []);
const openModelOnboarding = useCallback(() => setModelOnboardingOpen(true), []);
const closeModelOnboarding = useCallback(() => setModelOnboardingOpen(false), []);
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Project swap left the previous project's modals open (a task-detail modal for project A
kept rendering over project B's board) and kept planning resume/initial-plan payloads,
so the docked Planning view re-entered project A's session. Close every project-scoped
modal and clear their payloads in one transition; deliberately leave settings,
schedules, usage, agents, setup wizard, and model onboarding alone — they are not
project-scoped surfaces.
*/
const closeProjectScopedModals = useCallback(() => {
setDetailTask(null);
setDetailTaskInitialTab(undefined);
setDetailTaskInitialAction(null);
setDetailTaskOrigin(null);
setGroupModalGroupId(null);
setNewTaskModalOpen(false);
setNewTaskInitialDescription(null);
setNewTaskInitialWorkflowId(undefined);
setIsSubtaskOpen(false);
setSubtaskInitialDescription(null);
setSubtaskResumeSessionId(undefined);
setSubtaskWorkflowId(undefined);
setIsPlanningOpen(false);
setPlanningInitialPlan(null);
setPlanningResumeSessionId(undefined);
setPlanningWorkflowId(undefined);
setGitHubImportOpen(false);
setFilesOpen(false);
setFileBrowserInitialFile(null);
setActivityLogOpen(false);
setGitManagerOpen(false);
setWorkflowEditorOpen(false);
setWorkflowEditorInitialPanel(undefined);
setWorkflowEditorInitialAction(undefined);
setWorkflowEditorInitialWorkflowId(undefined);
setScriptsOpen(false);
setTerminalOpen(false);
setTerminalInitialCommand(undefined);
}, []);
const clearQuickAddPlanningDrafts = useCallback(() => {
/*
FNXC:QuickAddPlanningPreserve 2026-06-22-00:00:
Planning completion, not planning exit, is the only modal-manager transition that clears preserved quick-add drafts. Use the active project id so scoped drafts are removed from the correct workspace.
*/
removeScopedItem("kb-quick-entry-text", options.projectId);
removeScopedItem("kb-inline-create-text", options.projectId);
}, [options.projectId]);
const onPlanningTaskCreated = useCallback((task: Task, addToast: (message: string, type?: ToastType) => void) => {
addToast(t("modalManager.createdFromPlanning", "Created {{id}} from planning mode", { id: task.id }), "success");
clearQuickAddPlanningDrafts();
setIsPlanningOpen(false);
setPlanningInitialPlan(null);
}, [clearQuickAddPlanningDrafts, t]);
const onPlanningTasksCreated = useCallback((tasks: Task[], addToast: (message: string, type?: ToastType) => void) => {
const ids = tasks.map((task) => task.id).join(", ");
addToast(t("modalManager.createdMultipleFromPlanning", "Created {{ids}} from planning mode", { ids }), "success");
clearQuickAddPlanningDrafts();
setIsPlanningOpen(false);
setPlanningInitialPlan(null);
}, [clearQuickAddPlanningDrafts, t]);
const onSubtaskTasksCreated = useCallback((tasks: Task[], addToast: (message: string, type?: ToastType) => void) => {
const ids = tasks.map((task) => task.id).join(", ");
addToast(t("modalManager.createdFromSubtask", "Created {{ids}} from subtask breakdown", { ids }), "success");
setIsSubtaskOpen(false);
setSubtaskInitialDescription(null);
}, [t]);
return {
newTaskModalOpen,
newTaskInitialDescription,
newTaskInitialWorkflowId,
isPlanningOpen,
planningInitialPlan,
planningResumeSessionId,
planningWorkflowId,
planningEntryGeneration,
isSubtaskOpen,
subtaskInitialDescription,
subtaskResumeSessionId,
subtaskWorkflowId,
detailTask,
detailTaskInitialTab,
detailTaskInitialAction,
detailTaskOrigin,
groupModalGroupId,
settingsOpen,
settingsInitialSection,
schedulesOpen,
githubImportOpen,
usageOpen,
usageAnchorRect,
terminalOpen,
terminalInitialCommand,
terminalInitialCommandGeneration,
filesOpen,
fileBrowserWorkspace,
fileBrowserInitialFile,
activityLogOpen,
gitManagerOpen,
workflowEditorOpen,
workflowEditorInitialPanel,
workflowEditorInitialAction,
workflowEditorInitialWorkflowId,
agentsOpen,
scriptsOpen,
setupWizardOpen,
modelOnboardingOpen,
anyModalOpen,
openNewTask,
openNewTaskWithDescription,
closeNewTask,
openPlanning,
openPlanningWithInitialPlan,
resumePlanning,
openPlanningWithSession,
clearPlanningInitialPlan,
closePlanning,
openSubtaskBreakdown,
openSubtaskWithSession,
closeSubtask,
openDetailTask,
openDetailWithChangesTab,
updateDetailTask,
closeDetailTask,
openGroupModal,
closeGroupModal,
openSettings,
setSettingsSection,
closeSettings,
openSchedules,
closeSchedules,
openGitHubImport,
closeGitHubImport,
openUsage,
closeUsage,
toggleTerminal,
closeTerminal,
openFiles,
closeFiles,
setFileWorkspace,
openActivityLog,
closeActivityLog,
openGitManager,
closeGitManager,
openWorkflowEditor,
closeWorkflowEditor,
openAgents,
closeAgents,
openScripts,
closeScripts,
runScript,
openSetupWizard,
closeSetupWizard,
openModelOnboarding,
closeModelOnboarding,
closeProjectScopedModals,
onPlanningTaskCreated,
onPlanningTasksCreated,
onSubtaskTasksCreated,
};
}