feat(FN-1203): extract App orchestration into focused hooks
- Extract modal manager, app settings, deep-link handling, favorites, and auth onboarding into dedicated hooks - Rewire App.tsx to consume the new hooks and reduce component-level state/effect complexity - Preserve favorite-toggle error toast behavior in the updated favorites wiring - Add hook-level test coverage for modal manager, app settings, deep links, favorites, and auth onboarding flows
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, fetchGlobalSettings, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject, fetchUnreadCount, fetchAgents } from "./api";
|
||||
import type { ModelInfo, ProjectInfo, Agent } from "./api";
|
||||
import { updateProject, unregisterProject } from "./api";
|
||||
import type { ProjectInfo } from "./api";
|
||||
import { Header, useViewportMode } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
import { ListView } from "./components/ListView";
|
||||
@@ -14,7 +14,6 @@ import { SettingsModal } from "./components/SettingsModal";
|
||||
import { ModelOnboardingModal } from "./components/ModelOnboardingModal";
|
||||
import { PlanningModeModal } from "./components/PlanningModeModal";
|
||||
import { SubtaskBreakdownModal } from "./components/SubtaskBreakdownModal";
|
||||
import type { SectionId } from "./components/SettingsModal";
|
||||
import { ToastContainer } from "./components/ToastContainer";
|
||||
import { GitHubImportModal } from "./components/GitHubImportModal";
|
||||
import { GitManagerModal } from "./components/GitManagerModal";
|
||||
@@ -39,6 +38,11 @@ import { useNodes } from "./hooks/useNodes";
|
||||
import { useCurrentProject } from "./hooks/useCurrentProject";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
import { useModalManager } from "./hooks/useModalManager";
|
||||
import { useAppSettings } from "./hooks/useAppSettings";
|
||||
import { useDeepLink } from "./hooks/useDeepLink";
|
||||
import { useFavorites } from "./hooks/useFavorites";
|
||||
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
|
||||
|
||||
type ViewMode = "overview" | "project";
|
||||
type TaskView = "board" | "list" | "agents";
|
||||
@@ -83,74 +87,119 @@ function AppInner() {
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
|
||||
// Modal states
|
||||
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
|
||||
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
|
||||
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
|
||||
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
|
||||
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
const [detailTaskInitialTab, setDetailTaskInitialTab] = useState<"definition" | "logs" | "changes" | "commits" | "comments" | "model" | "workflow">("definition");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [schedulesOpen, setSchedulesOpen] = useState(false);
|
||||
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
||||
const [usageOpen, setUsageOpen] = useState(false);
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||
const [mailboxOpen, setMailboxOpen] = useState(false);
|
||||
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
||||
const [mailboxAgents, setMailboxAgents] = useState<Agent[]>([]);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||
const [missionsOpen, setMissionsOpen] = useState(false);
|
||||
const [agentsOpen, setAgentsOpen] = useState(false);
|
||||
const [nodesOpen, setNodesOpen] = useState(false);
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false);
|
||||
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
|
||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
|
||||
const [modelOnboardingOpen, setModelOnboardingOpen] = useState(false);
|
||||
// Modal state/handlers extracted to a dedicated manager hook.
|
||||
const {
|
||||
newTaskModalOpen,
|
||||
isPlanningOpen,
|
||||
planningInitialPlan,
|
||||
planningResumeSessionId,
|
||||
isSubtaskOpen,
|
||||
subtaskInitialDescription,
|
||||
subtaskResumeSessionId,
|
||||
detailTask,
|
||||
detailTaskInitialTab,
|
||||
settingsOpen,
|
||||
settingsInitialSection,
|
||||
schedulesOpen,
|
||||
githubImportOpen,
|
||||
usageOpen,
|
||||
terminalOpen,
|
||||
terminalInitialCommand,
|
||||
filesOpen,
|
||||
fileBrowserWorkspace,
|
||||
activityLogOpen,
|
||||
mailboxOpen,
|
||||
mailboxUnreadCount,
|
||||
mailboxAgents,
|
||||
gitManagerOpen,
|
||||
workflowStepsOpen,
|
||||
missionsOpen,
|
||||
missionResumeSessionId,
|
||||
missionTargetId,
|
||||
agentsOpen,
|
||||
scriptsOpen,
|
||||
setupWizardOpen,
|
||||
modelOnboardingOpen,
|
||||
anyModalOpen,
|
||||
openNewTask,
|
||||
closeNewTask,
|
||||
openPlanning,
|
||||
openPlanningWithInitialPlan,
|
||||
resumePlanning,
|
||||
openPlanningWithSession,
|
||||
closePlanning,
|
||||
openSubtaskBreakdown,
|
||||
openSubtaskWithSession,
|
||||
closeSubtask,
|
||||
openDetailTask,
|
||||
openDetailWithChangesTab,
|
||||
updateDetailTask,
|
||||
closeDetailTask,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
openSchedules,
|
||||
closeSchedules,
|
||||
openGitHubImport,
|
||||
closeGitHubImport,
|
||||
openUsage,
|
||||
closeUsage,
|
||||
toggleTerminal,
|
||||
closeTerminal,
|
||||
openFiles,
|
||||
closeFiles,
|
||||
setFileWorkspace,
|
||||
openActivityLog,
|
||||
closeActivityLog,
|
||||
openMailbox,
|
||||
closeMailbox,
|
||||
openGitManager,
|
||||
closeGitManager,
|
||||
openWorkflowSteps,
|
||||
closeWorkflowSteps,
|
||||
openMissions,
|
||||
openMissionById,
|
||||
openMissionWithSession,
|
||||
closeMissions,
|
||||
closeAgents,
|
||||
openScripts,
|
||||
closeScripts,
|
||||
runScript,
|
||||
openSetupWizard,
|
||||
closeSetupWizard,
|
||||
openModelOnboarding,
|
||||
closeModelOnboarding,
|
||||
onPlanningTaskCreated,
|
||||
onPlanningTasksCreated,
|
||||
onSubtaskTasksCreated,
|
||||
} = useModalManager({
|
||||
projectId: currentProject?.id,
|
||||
planningSessions: bgPlanningSessions,
|
||||
});
|
||||
|
||||
const anyModalOpen = !!(
|
||||
detailTask ||
|
||||
settingsOpen ||
|
||||
newTaskModalOpen ||
|
||||
isPlanningOpen ||
|
||||
isSubtaskOpen ||
|
||||
terminalOpen ||
|
||||
filesOpen ||
|
||||
activityLogOpen ||
|
||||
mailboxOpen ||
|
||||
gitManagerOpen ||
|
||||
workflowStepsOpen ||
|
||||
missionsOpen ||
|
||||
scriptsOpen ||
|
||||
agentsOpen ||
|
||||
usageOpen ||
|
||||
schedulesOpen ||
|
||||
githubImportOpen ||
|
||||
setupWizardOpen ||
|
||||
modelOnboardingOpen
|
||||
);
|
||||
// Nodes management is an overlay view (not a modal), so it stays local to App.
|
||||
const [nodesOpen, setNodesOpen] = useState(false);
|
||||
|
||||
// Settings state
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const [rootDir, setRootDir] = useState<string>(".");
|
||||
const [autoMerge, setAutoMerge] = useState(true);
|
||||
const [globalPaused, setGlobalPaused] = useState(false);
|
||||
const [enginePaused, setEnginePaused] = useState(false);
|
||||
const {
|
||||
maxConcurrent,
|
||||
rootDir,
|
||||
autoMerge,
|
||||
globalPaused,
|
||||
enginePaused,
|
||||
taskStuckTimeoutMs,
|
||||
githubTokenConfigured,
|
||||
toggleAutoMerge,
|
||||
toggleGlobalPause,
|
||||
toggleEnginePause,
|
||||
} = useAppSettings(currentProject?.id);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
|
||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const {
|
||||
availableModels,
|
||||
favoriteProviders,
|
||||
favoriteModels,
|
||||
toggleFavoriteProvider,
|
||||
toggleFavoriteModel,
|
||||
} = useFavorites();
|
||||
|
||||
// Persist view mode
|
||||
useEffect(() => {
|
||||
@@ -186,10 +235,10 @@ function AppInner() {
|
||||
|
||||
// Only open when truly no projects exist and no project is being restored
|
||||
const timer = setTimeout(() => {
|
||||
setSetupWizardOpen(true);
|
||||
openSetupWizard();
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [projectsLoading, projects.length, currentProjectLoading, currentProject, setupWizardOpen]);
|
||||
}, [projectsLoading, projects.length, currentProjectLoading, currentProject, setupWizardOpen, openSetupWizard]);
|
||||
|
||||
// Theme toggle handler: cycles Dark → Light → System → Dark
|
||||
const handleToggleTheme = useCallback(() => {
|
||||
@@ -199,151 +248,39 @@ function AppInner() {
|
||||
setThemeMode(nextMode);
|
||||
}, [themeMode, setThemeMode]);
|
||||
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
fetchConfig(currentProject?.id)
|
||||
.then((cfg) => {
|
||||
setMaxConcurrent(cfg.maxConcurrent);
|
||||
setRootDir(cfg.rootDir);
|
||||
})
|
||||
.catch(() => {/* keep default */});
|
||||
fetchSettings(currentProject?.id)
|
||||
.then((s) => {
|
||||
setAutoMerge(!!s.autoMerge);
|
||||
setGlobalPaused(!!s.globalPause);
|
||||
setEnginePaused(!!s.enginePaused);
|
||||
setGithubTokenConfigured(!!s.githubTokenConfigured);
|
||||
setTaskStuckTimeoutMs(s.taskStuckTimeoutMs);
|
||||
})
|
||||
.catch(() => {/* keep default */});
|
||||
fetchAuthStatus()
|
||||
.then(({ providers }) => {
|
||||
const hasAuthenticatedProvider = providers.some((p) => p.authenticated);
|
||||
// Check if onboarding is needed: either no authenticated providers,
|
||||
// or providers are authenticated but no default model is configured
|
||||
const needsSetup = providers.length > 0 && !hasAuthenticatedProvider;
|
||||
if (needsSetup || (providers.length > 0 && hasAuthenticatedProvider)) {
|
||||
fetchGlobalSettings()
|
||||
.then((globalSettings) => {
|
||||
const hasDefaultModel = !!(globalSettings.defaultProvider && globalSettings.defaultModelId);
|
||||
const setupIncomplete = !hasAuthenticatedProvider || !hasDefaultModel;
|
||||
if (!globalSettings.modelOnboardingComplete && setupIncomplete) {
|
||||
// First-run: show onboarding modal
|
||||
setModelOnboardingOpen(true);
|
||||
} else if (!hasAuthenticatedProvider) {
|
||||
// Already onboarded but no auth: open settings to authentication
|
||||
setSettingsOpen(true);
|
||||
setSettingsInitialSection("authentication");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// If we can't fetch global settings, fall back to onboarding
|
||||
if (!hasAuthenticatedProvider) {
|
||||
setModelOnboardingOpen(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {/* fail silently */});
|
||||
}, [currentProject?.id]);
|
||||
// Auth and onboarding bootstrap logic extracted to a dedicated hook.
|
||||
useAuthOnboarding({
|
||||
projectId: currentProject?.id,
|
||||
openModelOnboarding,
|
||||
openSettings,
|
||||
});
|
||||
|
||||
// Fetch available models
|
||||
useEffect(() => {
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
setFavoriteModels(response.favoriteModels);
|
||||
})
|
||||
.catch(() => {/* keep empty array on failure */});
|
||||
}, []);
|
||||
|
||||
// Favorite provider/model toggle handlers (shared by ListView bulk edit)
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
|
||||
await toggleFavoriteProvider(provider);
|
||||
} catch {
|
||||
setFavoriteProviders(currentFavorites);
|
||||
addToast("Failed to update favorites", "error");
|
||||
}
|
||||
}, [favoriteProviders, favoriteModels, addToast]);
|
||||
}, [toggleFavoriteProvider, addToast]);
|
||||
|
||||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||||
const currentFavorites = favoriteModels;
|
||||
const isFavorite = currentFavorites.includes(modelId);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((m) => m !== modelId)
|
||||
: [modelId, ...currentFavorites];
|
||||
|
||||
setFavoriteModels(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites });
|
||||
await toggleFavoriteModel(modelId);
|
||||
} catch {
|
||||
setFavoriteModels(currentFavorites);
|
||||
addToast("Failed to update model favorites", "error");
|
||||
}
|
||||
}, [favoriteModels, favoriteProviders, addToast]);
|
||||
}, [toggleFavoriteModel, addToast]);
|
||||
|
||||
// Handle deep link to task on mount (with optional project context)
|
||||
// Uses a ref to prevent duplicate fetches when setCurrentProject triggers
|
||||
// a re-run of this effect during project switching.
|
||||
const deepLinkFetchedRef = useRef(false);
|
||||
// Tracks the task ID currently open from a deep link so that dismissing
|
||||
// the modal can clean the URL (one-time open behaviour).
|
||||
const deepLinkTaskIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const projectParam = params.get("project");
|
||||
const taskId = params.get("task");
|
||||
|
||||
// If no task to load, nothing to do
|
||||
if (!taskId) return;
|
||||
|
||||
// Wait for projects to finish loading before resolving deep links.
|
||||
// Without this guard, an empty projects list during loading would
|
||||
// produce a false "project not found" error toast.
|
||||
if (projectsLoading) return;
|
||||
|
||||
// If project param is present, validate it and switch project if needed
|
||||
if (projectParam) {
|
||||
const matchingProject = projects.find((p) => p.id === projectParam);
|
||||
if (!matchingProject) {
|
||||
addToast(`Project '${projectParam}' not found`, "error");
|
||||
return;
|
||||
}
|
||||
// Switch to the project if it's different from the current one
|
||||
if (currentProject?.id !== matchingProject.id) {
|
||||
setCurrentProject(matchingProject);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if we've already fetched this task (prevents double-fetch when
|
||||
// setCurrentProject causes this effect to re-run).
|
||||
if (deepLinkFetchedRef.current) return;
|
||||
deepLinkFetchedRef.current = true;
|
||||
|
||||
// Use project param as the authoritative project context for the fetch
|
||||
// when present; otherwise fall back to the current/default project.
|
||||
const taskProjectId = projectParam ?? currentProject?.id;
|
||||
fetchTaskDetail(taskId, taskProjectId)
|
||||
.then((detail) => {
|
||||
setDetailTask(detail);
|
||||
// Mark this as a deep-linked open so dismissal can clean the URL
|
||||
deepLinkTaskIdRef.current = taskId;
|
||||
})
|
||||
.catch(() => {
|
||||
addToast(`Task ${taskId} not found`, "error");
|
||||
});
|
||||
}, [addToast, projects, projectsLoading, currentProject, setCurrentProject]);
|
||||
const { handleDetailClose } = useDeepLink({
|
||||
projectId: currentProject?.id,
|
||||
projects,
|
||||
projectsLoading,
|
||||
currentProject,
|
||||
setCurrentProject,
|
||||
addToast,
|
||||
openTaskDetail: openDetailTask,
|
||||
closeTaskDetail: closeDetailTask,
|
||||
});
|
||||
|
||||
// View change handlers
|
||||
const handleChangeTaskView = useCallback((newView: TaskView) => {
|
||||
@@ -361,21 +298,25 @@ function AppInner() {
|
||||
setViewMode("overview");
|
||||
}, [clearCurrentProject]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
openSettings();
|
||||
}, [openSettings]);
|
||||
|
||||
const handleAddProject = useCallback(() => {
|
||||
setSetupWizardOpen(true);
|
||||
}, []);
|
||||
openSetupWizard();
|
||||
}, [openSetupWizard]);
|
||||
|
||||
const handleSetupComplete = useCallback((project: ProjectInfo) => {
|
||||
setSetupWizardOpen(false);
|
||||
closeSetupWizard();
|
||||
setCurrentProject(project);
|
||||
setViewMode("project");
|
||||
addToast(`Project ${project.name} registered successfully`, "success");
|
||||
refreshProjects();
|
||||
}, [setCurrentProject, addToast, refreshProjects]);
|
||||
}, [closeSetupWizard, setCurrentProject, addToast, refreshProjects]);
|
||||
|
||||
const handleModelOnboardingComplete = useCallback(() => {
|
||||
setModelOnboardingOpen(false);
|
||||
}, []);
|
||||
closeModelOnboarding();
|
||||
}, [closeModelOnboarding]);
|
||||
|
||||
const handlePauseProject = useCallback(async (project: ProjectInfo) => {
|
||||
try {
|
||||
@@ -413,8 +354,8 @@ function AppInner() {
|
||||
}, [unregisterProject, currentProject, clearCurrentProject, addToast, refreshProjects]);
|
||||
|
||||
// Task handlers
|
||||
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
|
||||
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
|
||||
const handleNewTaskOpen = openNewTask;
|
||||
const handleNewTaskClose = closeNewTask;
|
||||
|
||||
const handleBoardQuickCreate = useCallback(
|
||||
async (input: TaskCreateInput): Promise<void> => {
|
||||
@@ -432,167 +373,75 @@ function AppInner() {
|
||||
);
|
||||
|
||||
// Planning mode handlers
|
||||
const handlePlanningOpen = useCallback(() => setIsPlanningOpen(true), []);
|
||||
const handleResumePlanning = useCallback(() => {
|
||||
const session = bgPlanningSessions[0];
|
||||
if (session) {
|
||||
setPlanningResumeSessionId(session.id);
|
||||
setIsPlanningOpen(true);
|
||||
}
|
||||
}, [bgPlanningSessions]);
|
||||
const handlePlanningClose = useCallback(() => {
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
setPlanningResumeSessionId(undefined);
|
||||
}, []);
|
||||
const handlePlanningOpen = openPlanning;
|
||||
const handleResumePlanning = resumePlanning;
|
||||
const handlePlanningClose = closePlanning;
|
||||
const handlePlanningTaskCreated = useCallback((task: Task) => {
|
||||
addToast(`Created ${task.id} from planning mode`, "success");
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
}, [addToast]);
|
||||
onPlanningTaskCreated(task, addToast);
|
||||
}, [onPlanningTaskCreated, addToast]);
|
||||
|
||||
const handlePlanningTasksCreated = useCallback((createdTasks: Task[]) => {
|
||||
const ids = createdTasks.map((task) => task.id).join(", ");
|
||||
addToast(`Created ${ids} from planning mode`, "success");
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
}, [addToast]);
|
||||
onPlanningTasksCreated(createdTasks, addToast);
|
||||
}, [onPlanningTasksCreated, addToast]);
|
||||
|
||||
// Handle planning mode from new task dialog
|
||||
const handleNewTaskPlanningMode = useCallback((initialPlan: string) => {
|
||||
setPlanningInitialPlan(initialPlan);
|
||||
setIsPlanningOpen(true);
|
||||
}, []);
|
||||
const handleNewTaskPlanningMode = openPlanningWithInitialPlan;
|
||||
|
||||
// Handle subtask breakdown from inline/quick create
|
||||
const handleSubtaskBreakdown = useCallback((description: string) => {
|
||||
setSubtaskInitialDescription(description);
|
||||
setIsSubtaskOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSubtaskClose = useCallback(() => {
|
||||
setIsSubtaskOpen(false);
|
||||
setSubtaskInitialDescription(null);
|
||||
setSubtaskResumeSessionId(undefined);
|
||||
}, []);
|
||||
const handleSubtaskBreakdown = openSubtaskBreakdown;
|
||||
const handleSubtaskClose = closeSubtask;
|
||||
|
||||
const handleSubtaskTasksCreated = useCallback((createdTasks: Task[]) => {
|
||||
const ids = createdTasks.map((task) => task.id).join(", ");
|
||||
addToast(`Created ${ids} from subtask breakdown`, "success");
|
||||
setIsSubtaskOpen(false);
|
||||
setSubtaskInitialDescription(null);
|
||||
}, [addToast]);
|
||||
onSubtaskTasksCreated(createdTasks, addToast);
|
||||
}, [onSubtaskTasksCreated, addToast]);
|
||||
|
||||
// Usage indicator handlers
|
||||
const handleOpenUsage = useCallback(() => setUsageOpen(true), []);
|
||||
const handleCloseUsage = useCallback(() => setUsageOpen(false), []);
|
||||
const handleOpenUsage = openUsage;
|
||||
const handleCloseUsage = closeUsage;
|
||||
|
||||
// Schedules modal handlers
|
||||
const handleOpenSchedules = useCallback(() => setSchedulesOpen(true), []);
|
||||
const handleCloseSchedules = useCallback(() => setSchedulesOpen(false), []);
|
||||
const handleOpenSchedules = openSchedules;
|
||||
const handleCloseSchedules = closeSchedules;
|
||||
|
||||
const handleToggleAutoMerge = useCallback(async () => {
|
||||
const next = !autoMerge;
|
||||
setAutoMerge(next);
|
||||
try {
|
||||
await updateSettings({ autoMerge: next }, currentProject?.id);
|
||||
} catch {
|
||||
setAutoMerge(!next); // revert on failure
|
||||
}
|
||||
}, [autoMerge, currentProject?.id]);
|
||||
const handleToggleAutoMerge = toggleAutoMerge;
|
||||
const handleToggleGlobalPause = toggleGlobalPause;
|
||||
const handleToggleEnginePause = toggleEnginePause;
|
||||
|
||||
const handleToggleGlobalPause = useCallback(async () => {
|
||||
const next = !globalPaused;
|
||||
setGlobalPaused(next);
|
||||
try {
|
||||
await updateSettings({ globalPause: next }, currentProject?.id);
|
||||
} catch {
|
||||
setGlobalPaused(!next); // revert on failure
|
||||
}
|
||||
}, [globalPaused, currentProject?.id]);
|
||||
|
||||
const handleToggleEnginePause = useCallback(async () => {
|
||||
const next = !enginePaused;
|
||||
setEnginePaused(next);
|
||||
try {
|
||||
await updateSettings({ enginePaused: next }, currentProject?.id);
|
||||
} catch {
|
||||
setEnginePaused(!next); // revert on failure
|
||||
}
|
||||
}, [enginePaused, currentProject?.id]);
|
||||
|
||||
const handleDetailOpen = useCallback((task: TaskDetail) => {
|
||||
setDetailTask(task);
|
||||
setDetailTaskInitialTab("definition");
|
||||
}, []);
|
||||
const handleDetailOpen = openDetailTask;
|
||||
|
||||
const handleOpenDetailWithTab = useCallback((task: TaskDetail, initialTab: "changes") => {
|
||||
setDetailTask(task);
|
||||
setDetailTaskInitialTab(initialTab);
|
||||
}, []);
|
||||
|
||||
const handleDetailClose = useCallback(() => {
|
||||
// If the modal was opened from a deep link (?task=...), remove the task
|
||||
// param from the URL so refreshing does not reopen it. Preserve any other
|
||||
// query parameters (e.g. ?project=...).
|
||||
if (deepLinkTaskIdRef.current) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("task");
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
qs ? `${window.location.pathname}?${qs}` : window.location.pathname,
|
||||
);
|
||||
deepLinkTaskIdRef.current = null;
|
||||
if (initialTab === "changes") {
|
||||
openDetailWithChangesTab(task);
|
||||
return;
|
||||
}
|
||||
setDetailTask(null);
|
||||
}, []);
|
||||
openDetailTask(task, initialTab);
|
||||
}, [openDetailTask, openDetailWithChangesTab]);
|
||||
|
||||
|
||||
const handleGitHubImport = useCallback((task: Task) => {
|
||||
addToast(`Imported ${task.id} from GitHub`, "success");
|
||||
}, [addToast]);
|
||||
|
||||
const handleToggleTerminal = useCallback(() => {
|
||||
setTerminalOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleOpenFiles = useCallback(() => {
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleWorkspaceChange = useCallback((workspace: string) => {
|
||||
setFileBrowserWorkspace(workspace);
|
||||
}, []);
|
||||
const handleToggleTerminal = toggleTerminal;
|
||||
const handleOpenFiles = openFiles;
|
||||
const handleWorkspaceChange = setFileWorkspace;
|
||||
|
||||
// Activity log handlers
|
||||
const handleOpenActivityLog = useCallback(() => setActivityLogOpen(true), []);
|
||||
const handleCloseActivityLog = useCallback(() => setActivityLogOpen(false), []);
|
||||
const handleOpenActivityLog = openActivityLog;
|
||||
const handleCloseActivityLog = closeActivityLog;
|
||||
|
||||
const handleOpenMailbox = useCallback(() => {
|
||||
setMailboxOpen(true);
|
||||
// Refresh unread count and agents when opening mailbox
|
||||
fetchUnreadCount(currentProject?.id).then((data) => {
|
||||
setMailboxUnreadCount(data.unreadCount);
|
||||
}).catch(() => {});
|
||||
fetchAgents(undefined, currentProject?.id).then((agents) => {
|
||||
setMailboxAgents(agents);
|
||||
}).catch(() => {});
|
||||
}, [currentProject?.id]);
|
||||
const handleCloseMailbox = useCallback(() => setMailboxOpen(false), []);
|
||||
const handleOpenMailbox = openMailbox;
|
||||
const handleCloseMailbox = closeMailbox;
|
||||
|
||||
// Mission link handler from TaskCard
|
||||
const handleOpenMission = useCallback((missionId: string) => {
|
||||
setMissionTargetId(missionId);
|
||||
setMissionsOpen(true);
|
||||
}, []);
|
||||
const handleOpenMission = openMissionById;
|
||||
|
||||
// Git Manager handlers
|
||||
const handleOpenGitManager = useCallback(() => setGitManagerOpen(true), []);
|
||||
const handleCloseGitManager = useCallback(() => setGitManagerOpen(false), []);
|
||||
const handleOpenGitManager = openGitManager;
|
||||
const handleCloseGitManager = closeGitManager;
|
||||
|
||||
// Agent handlers
|
||||
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
|
||||
const handleCloseAgents = closeAgents;
|
||||
|
||||
// Node management view handlers
|
||||
const handleOpenNodes = useCallback(() => {
|
||||
@@ -603,25 +452,12 @@ function AppInner() {
|
||||
}, []);
|
||||
|
||||
// Scripts handlers
|
||||
const handleOpenScripts = useCallback(() => setScriptsOpen(true), []);
|
||||
const handleCloseScripts = useCallback(() => setScriptsOpen(false), []);
|
||||
const handleRunScript = useCallback(async (name: string, command: string) => {
|
||||
// Close the scripts modal immediately so the terminal becomes the
|
||||
// topmost surface once it opens.
|
||||
setScriptsOpen(false);
|
||||
|
||||
// Launch the script command in the interactive terminal modal.
|
||||
// Reset the initial command ref so the terminal knows to run the
|
||||
// new command even if it is already open.
|
||||
setTerminalInitialCommand(command);
|
||||
setTerminalOpen(true);
|
||||
}, []);
|
||||
const handleOpenScripts = openScripts;
|
||||
const handleCloseScripts = closeScripts;
|
||||
const handleRunScript = runScript;
|
||||
|
||||
// Terminal close handler
|
||||
const handleTerminalClose = useCallback(() => {
|
||||
setTerminalOpen(false);
|
||||
setTerminalInitialCommand(undefined);
|
||||
}, []);
|
||||
const handleTerminalClose = closeTerminal;
|
||||
|
||||
// Render main content based on view mode
|
||||
const renderMainContent = () => {
|
||||
@@ -716,8 +552,8 @@ function AppInner() {
|
||||
<>
|
||||
<Header
|
||||
isElectron={isElectron}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenGitHubImport={() => setGitHubImportOpen(true)}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenGitHubImport={openGitHubImport}
|
||||
onOpenPlanning={handlePlanningOpen}
|
||||
onResumePlanning={handleResumePlanning}
|
||||
activePlanningSessionCount={bgPlanningSessions.length}
|
||||
@@ -728,8 +564,8 @@ function AppInner() {
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
onOpenNodes={handleOpenNodes}
|
||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||
onOpenMissions={viewMode === "project" && currentProject ? () => setMissionsOpen(true) : undefined}
|
||||
onOpenWorkflowSteps={openWorkflowSteps}
|
||||
onOpenMissions={viewMode === "project" && currentProject ? openMissions : undefined}
|
||||
onOpenScripts={handleOpenScripts}
|
||||
onRunScript={handleRunScript}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
@@ -765,14 +601,11 @@ function AppInner() {
|
||||
backgroundNeedsInput={bgNeedsInput}
|
||||
onOpenBackgroundSession={(session) => {
|
||||
if (session.type === "planning") {
|
||||
setPlanningResumeSessionId(session.id);
|
||||
setIsPlanningOpen(true);
|
||||
openPlanningWithSession(session.id);
|
||||
} else if (session.type === "subtask") {
|
||||
setSubtaskResumeSessionId(session.id);
|
||||
setIsSubtaskOpen(true);
|
||||
openSubtaskWithSession(session.id);
|
||||
} else if (session.type === "mission_interview") {
|
||||
setMissionResumeSessionId(session.id);
|
||||
setMissionsOpen(true);
|
||||
openMissionWithSession(session.id);
|
||||
}
|
||||
}}
|
||||
onDismissBackgroundSession={bgDismiss}
|
||||
@@ -783,18 +616,18 @@ function AppInner() {
|
||||
onChangeView={viewMode === "project" && currentProject ? handleChangeTaskView : () => {}}
|
||||
footerVisible={viewMode === "project" && !!currentProject}
|
||||
modalOpen={anyModalOpen}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenActivityLog={handleOpenActivityLog}
|
||||
onOpenMailbox={handleOpenMailbox}
|
||||
mailboxUnreadCount={mailboxUnreadCount}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||
onOpenMissions={viewMode === "project" && currentProject ? () => setMissionsOpen(true) : undefined}
|
||||
onOpenWorkflowSteps={openWorkflowSteps}
|
||||
onOpenMissions={viewMode === "project" && currentProject ? openMissions : undefined}
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onOpenScripts={handleOpenScripts}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
onOpenGitHubImport={() => setGitHubImportOpen(true)}
|
||||
onOpenGitHubImport={openGitHubImport}
|
||||
onOpenPlanning={handlePlanningOpen}
|
||||
onResumePlanning={handleResumePlanning}
|
||||
activePlanningSessionCount={bgPlanningSessions.length}
|
||||
@@ -817,7 +650,7 @@ function AppInner() {
|
||||
onMergeTask={mergeTask}
|
||||
onRetryTask={retryTask}
|
||||
onDuplicateTask={duplicateTask}
|
||||
onTaskUpdated={(updated) => setDetailTask(prev => prev ? { ...prev, ...updated } : prev)}
|
||||
onTaskUpdated={updateDetailTask}
|
||||
addToast={addToast}
|
||||
githubTokenConfigured={githubTokenConfigured}
|
||||
initialTab={detailTaskInitialTab}
|
||||
@@ -825,10 +658,7 @@ function AppInner() {
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<SettingsModal
|
||||
onClose={() => {
|
||||
setSettingsOpen(false);
|
||||
setSettingsInitialSection(undefined);
|
||||
}}
|
||||
onClose={closeSettings}
|
||||
addToast={addToast}
|
||||
initialSection={settingsInitialSection}
|
||||
projectId={currentProject?.id}
|
||||
@@ -840,7 +670,7 @@ function AppInner() {
|
||||
)}
|
||||
<GitHubImportModal
|
||||
isOpen={githubImportOpen}
|
||||
onClose={() => setGitHubImportOpen(false)}
|
||||
onClose={closeGitHubImport}
|
||||
onImport={handleGitHubImport}
|
||||
tasks={tasks}
|
||||
/>
|
||||
@@ -878,7 +708,7 @@ function AppInner() {
|
||||
<FileBrowserModal
|
||||
initialWorkspace={fileBrowserWorkspace}
|
||||
isOpen={true}
|
||||
onClose={() => setFilesOpen(false)}
|
||||
onClose={closeFiles}
|
||||
onWorkspaceChange={handleWorkspaceChange}
|
||||
/>
|
||||
)}
|
||||
@@ -924,13 +754,13 @@ function AppInner() {
|
||||
/>
|
||||
<WorkflowStepManager
|
||||
isOpen={workflowStepsOpen}
|
||||
onClose={() => setWorkflowStepsOpen(false)}
|
||||
onClose={closeWorkflowSteps}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
/>
|
||||
<MissionManager
|
||||
isOpen={missionsOpen}
|
||||
onClose={() => { setMissionsOpen(false); setMissionResumeSessionId(undefined); setMissionTargetId(undefined); }}
|
||||
onClose={closeMissions}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
resumeSessionId={missionResumeSessionId}
|
||||
@@ -939,7 +769,7 @@ function AppInner() {
|
||||
onSelectTask={(taskId) => {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
setDetailTask(task as TaskDetail);
|
||||
openDetailTask(task as TaskDetail);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -959,7 +789,7 @@ function AppInner() {
|
||||
{setupWizardOpen && (
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={handleSetupComplete}
|
||||
onClose={() => setSetupWizardOpen(false)}
|
||||
onClose={closeSetupWizard}
|
||||
/>
|
||||
)}
|
||||
{modelOnboardingOpen && (
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useAppSettings } from "../useAppSettings";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchConfig: vi.fn(),
|
||||
fetchSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchConfig = vi.mocked(api.fetchConfig);
|
||||
const mockFetchSettings = vi.mocked(api.fetchSettings);
|
||||
const mockUpdateSettings = vi.mocked(api.updateSettings);
|
||||
|
||||
describe("useAppSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockFetchConfig.mockResolvedValue({
|
||||
maxConcurrent: 4,
|
||||
rootDir: "/workspace/project",
|
||||
});
|
||||
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
autoMerge: false,
|
||||
globalPause: true,
|
||||
enginePaused: false,
|
||||
githubTokenConfigured: true,
|
||||
taskStuckTimeoutMs: 600000,
|
||||
} as never);
|
||||
|
||||
mockUpdateSettings.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
it("loads settings state from API", async () => {
|
||||
const { result } = renderHook(() => useAppSettings("proj_123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.maxConcurrent).toBe(4);
|
||||
expect(result.current.rootDir).toBe("/workspace/project");
|
||||
expect(result.current.autoMerge).toBe(false);
|
||||
expect(result.current.globalPaused).toBe(true);
|
||||
expect(result.current.enginePaused).toBe(false);
|
||||
expect(result.current.githubTokenConfigured).toBe(true);
|
||||
expect(result.current.taskStuckTimeoutMs).toBe(600000);
|
||||
});
|
||||
|
||||
expect(mockFetchConfig).toHaveBeenCalledWith("proj_123");
|
||||
expect(mockFetchSettings).toHaveBeenCalledWith("proj_123");
|
||||
});
|
||||
|
||||
it("optimistically toggles autoMerge and persists to API", async () => {
|
||||
const { result } = renderHook(() => useAppSettings("proj_123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.autoMerge).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleAutoMerge();
|
||||
});
|
||||
|
||||
expect(result.current.autoMerge).toBe(true);
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123");
|
||||
});
|
||||
|
||||
it("rolls back optimistic state when toggle update fails", async () => {
|
||||
mockUpdateSettings.mockRejectedValueOnce(new Error("network"));
|
||||
|
||||
const { result } = renderHook(() => useAppSettings("proj_123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.globalPaused).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleGlobalPause();
|
||||
});
|
||||
|
||||
expect(result.current.globalPaused).toBe(true);
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ globalPause: false }, "proj_123");
|
||||
});
|
||||
});
|
||||
116
packages/dashboard/app/hooks/__tests__/useAuthOnboarding.test.ts
Normal file
116
packages/dashboard/app/hooks/__tests__/useAuthOnboarding.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useAuthOnboarding } from "../useAuthOnboarding";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: vi.fn(),
|
||||
fetchGlobalSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus);
|
||||
const mockFetchGlobalSettings = vi.mocked(api.fetchGlobalSettings);
|
||||
|
||||
describe("useAuthOnboarding", () => {
|
||||
const openModelOnboarding = vi.fn();
|
||||
const openSettings = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens onboarding when no providers are authenticated and onboarding is incomplete", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "openai", name: "OpenAI", authenticated: false }],
|
||||
});
|
||||
mockFetchGlobalSettings.mockResolvedValue({
|
||||
modelOnboardingComplete: false,
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
} as never);
|
||||
|
||||
renderHook(() =>
|
||||
useAuthOnboarding({
|
||||
projectId: "proj_123",
|
||||
openModelOnboarding,
|
||||
openSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(openSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens authentication settings when onboarding is complete but no providers are authenticated", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }],
|
||||
});
|
||||
mockFetchGlobalSettings.mockResolvedValue({
|
||||
modelOnboardingComplete: true,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
} as never);
|
||||
|
||||
renderHook(() =>
|
||||
useAuthOnboarding({
|
||||
projectId: "proj_123",
|
||||
openModelOnboarding,
|
||||
openSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openSettings).toHaveBeenCalledWith("authentication");
|
||||
});
|
||||
|
||||
expect(openModelOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens onboarding when providers are authenticated but default model is missing", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "anthropic", name: "Anthropic", authenticated: true }],
|
||||
});
|
||||
mockFetchGlobalSettings.mockResolvedValue({
|
||||
modelOnboardingComplete: false,
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
} as never);
|
||||
|
||||
renderHook(() =>
|
||||
useAuthOnboarding({
|
||||
projectId: "proj_123",
|
||||
openModelOnboarding,
|
||||
openSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openModelOnboarding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(openSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when auth status fetch fails", async () => {
|
||||
mockFetchAuthStatus.mockRejectedValueOnce(new Error("network"));
|
||||
|
||||
renderHook(() =>
|
||||
useAuthOnboarding({
|
||||
projectId: "proj_123",
|
||||
openModelOnboarding,
|
||||
openSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(openModelOnboarding).not.toHaveBeenCalled();
|
||||
expect(openSettings).not.toHaveBeenCalled();
|
||||
expect(mockFetchGlobalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
213
packages/dashboard/app/hooks/__tests__/useDeepLink.test.ts
Normal file
213
packages/dashboard/app/hooks/__tests__/useDeepLink.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useDeepLink } from "../useDeepLink";
|
||||
import * as api from "../../api";
|
||||
import type { ProjectInfo } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchTaskDetail = vi.mocked(api.fetchTaskDetail);
|
||||
|
||||
describe("useDeepLink", () => {
|
||||
const originalLocation = window.location;
|
||||
const originalReplaceState = window.history.replaceState;
|
||||
|
||||
const defaultProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Project 123",
|
||||
path: "/repo-123",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
const otherProject: ProjectInfo = {
|
||||
id: "proj_456",
|
||||
name: "Project 456",
|
||||
path: "/repo-456",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/"),
|
||||
});
|
||||
|
||||
mockFetchTaskDetail.mockResolvedValue({ id: "FN-123" } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
window.history.replaceState = originalReplaceState;
|
||||
});
|
||||
|
||||
function renderUseDeepLink(overrides: Partial<Parameters<typeof useDeepLink>[0]> = {}) {
|
||||
const openTaskDetail = vi.fn();
|
||||
const closeTaskDetail = vi.fn();
|
||||
const setCurrentProject = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
|
||||
const options: Parameters<typeof useDeepLink>[0] = {
|
||||
projectId: defaultProject.id,
|
||||
projects: [defaultProject, otherProject],
|
||||
projectsLoading: false,
|
||||
currentProject: defaultProject,
|
||||
setCurrentProject,
|
||||
addToast,
|
||||
openTaskDetail,
|
||||
closeTaskDetail,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const hook = renderHook(() => useDeepLink(options));
|
||||
return { ...hook, options, openTaskDetail, closeTaskDetail, setCurrentProject, addToast };
|
||||
}
|
||||
|
||||
it("does nothing when no task param is present", async () => {
|
||||
renderUseDeepLink();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches and opens task detail for task deep-link", async () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?task=FN-123"),
|
||||
});
|
||||
|
||||
const { openTaskDetail } = renderUseDeepLink();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
|
||||
expect(openTaskDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("switches project and uses project param for task fetch", async () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?project=proj_456&task=FN-999"),
|
||||
});
|
||||
|
||||
const { setCurrentProject } = renderUseDeepLink();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
||||
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-999", "proj_456");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows toast and skips fetch for unknown project param", async () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?project=missing&task=FN-123"),
|
||||
});
|
||||
|
||||
const { addToast } = renderUseDeepLink();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Project 'missing' not found", "error");
|
||||
});
|
||||
|
||||
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for projects to load before resolving deep links", async () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?project=proj_456&task=FN-123"),
|
||||
});
|
||||
|
||||
renderUseDeepLink({
|
||||
projectsLoading: true,
|
||||
currentProject: null,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans task query param when deep-linked modal closes", async () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?project=proj_123&task=FN-123"),
|
||||
});
|
||||
|
||||
const { result, closeTaskDetail } = renderUseDeepLink();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
|
||||
});
|
||||
|
||||
result.current.handleDetailClose();
|
||||
|
||||
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||
null,
|
||||
"",
|
||||
"/?project=proj_123",
|
||||
);
|
||||
expect(closeTaskDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prevents duplicate fetches when rerendering after project switch", async () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?project=proj_456&task=FN-777"),
|
||||
});
|
||||
|
||||
const addToast = vi.fn();
|
||||
const setCurrentProject = vi.fn();
|
||||
const openTaskDetail = vi.fn();
|
||||
const closeTaskDetail = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: Parameters<typeof useDeepLink>[0]) => useDeepLink(props),
|
||||
{
|
||||
initialProps: {
|
||||
projectId: defaultProject.id,
|
||||
projects: [defaultProject, otherProject],
|
||||
projectsLoading: false,
|
||||
currentProject: defaultProject,
|
||||
setCurrentProject,
|
||||
addToast,
|
||||
openTaskDetail,
|
||||
closeTaskDetail,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTaskDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender({
|
||||
projectId: otherProject.id,
|
||||
projects: [defaultProject, otherProject],
|
||||
projectsLoading: false,
|
||||
currentProject: otherProject,
|
||||
setCurrentProject,
|
||||
addToast,
|
||||
openTaskDetail,
|
||||
closeTaskDetail,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTaskDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
packages/dashboard/app/hooks/__tests__/useFavorites.test.ts
Normal file
96
packages/dashboard/app/hooks/__tests__/useFavorites.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useFavorites } from "../useFavorites";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchModels = vi.mocked(api.fetchModels);
|
||||
const mockUpdateGlobalSettings = vi.mocked(api.updateGlobalSettings);
|
||||
|
||||
describe("useFavorites", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
reasoning: true,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
favoriteProviders: ["openai"],
|
||||
favoriteModels: ["gpt-4o"],
|
||||
});
|
||||
|
||||
mockUpdateGlobalSettings.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
it("loads available models and favorites from API", async () => {
|
||||
const { result } = renderHook(() => useFavorites());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.availableModels).toHaveLength(1);
|
||||
expect(result.current.favoriteProviders).toEqual(["openai"]);
|
||||
expect(result.current.favoriteModels).toEqual(["gpt-4o"]);
|
||||
});
|
||||
|
||||
expect(mockFetchModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("optimistically toggles provider favorite and persists settings", async () => {
|
||||
const { result } = renderHook(() => useFavorites());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.favoriteProviders).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleFavoriteProvider("anthropic");
|
||||
});
|
||||
|
||||
expect(result.current.favoriteProviders).toEqual(["anthropic", "openai"]);
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
|
||||
favoriteProviders: ["anthropic", "openai"],
|
||||
favoriteModels: ["gpt-4o"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back provider favorite on update failure", async () => {
|
||||
mockUpdateGlobalSettings.mockRejectedValueOnce(new Error("network"));
|
||||
|
||||
const { result } = renderHook(() => useFavorites());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.favoriteProviders).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.toggleFavoriteProvider("anthropic")).rejects.toThrow("network");
|
||||
});
|
||||
|
||||
expect(result.current.favoriteProviders).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
it("rolls back model favorite on update failure", async () => {
|
||||
mockUpdateGlobalSettings.mockRejectedValueOnce(new Error("network"));
|
||||
|
||||
const { result } = renderHook(() => useFavorites());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.favoriteModels).toEqual(["gpt-4o"]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.toggleFavoriteModel("claude-sonnet-4-5")).rejects.toThrow("network");
|
||||
});
|
||||
|
||||
expect(result.current.favoriteModels).toEqual(["gpt-4o"]);
|
||||
});
|
||||
});
|
||||
182
packages/dashboard/app/hooks/__tests__/useModalManager.test.ts
Normal file
182
packages/dashboard/app/hooks/__tests__/useModalManager.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import { useModalManager } from "../useModalManager";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchUnreadCount: vi.fn(),
|
||||
fetchAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchUnreadCount = vi.mocked(api.fetchUnreadCount);
|
||||
const mockFetchAgents = vi.mocked(api.fetchAgents);
|
||||
|
||||
function createTaskDetail(id: string): TaskDetail {
|
||||
return {
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: "desc",
|
||||
column: "todo",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
columnMovedAt: new Date().toISOString(),
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
attachments: [],
|
||||
size: "M",
|
||||
reviewLevel: 1,
|
||||
steeringComments: [],
|
||||
} as TaskDetail;
|
||||
}
|
||||
|
||||
describe("useModalManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 3 });
|
||||
mockFetchAgents.mockResolvedValue([
|
||||
{
|
||||
id: "agent-1",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
},
|
||||
] as never);
|
||||
});
|
||||
|
||||
it("manages open/close state for basic modals", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
expect(result.current.newTaskModalOpen).toBe(false);
|
||||
expect(result.current.anyModalOpen).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.openNewTask();
|
||||
});
|
||||
|
||||
expect(result.current.newTaskModalOpen).toBe(true);
|
||||
expect(result.current.anyModalOpen).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.closeNewTask();
|
||||
});
|
||||
|
||||
expect(result.current.newTaskModalOpen).toBe(false);
|
||||
expect(result.current.anyModalOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("handles planning open, resume, and close lifecycle", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [{ id: "plan-1" }] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openPlanningWithInitialPlan("Build dashboard");
|
||||
});
|
||||
|
||||
expect(result.current.isPlanningOpen).toBe(true);
|
||||
expect(result.current.planningInitialPlan).toBe("Build dashboard");
|
||||
|
||||
act(() => {
|
||||
result.current.closePlanning();
|
||||
});
|
||||
|
||||
expect(result.current.isPlanningOpen).toBe(false);
|
||||
expect(result.current.planningInitialPlan).toBeNull();
|
||||
expect(result.current.planningResumeSessionId).toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
result.current.resumePlanning();
|
||||
});
|
||||
|
||||
expect(result.current.isPlanningOpen).toBe(true);
|
||||
expect(result.current.planningResumeSessionId).toBe("plan-1");
|
||||
});
|
||||
|
||||
it("keeps script-to-terminal handoff inside runScript", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openScripts();
|
||||
});
|
||||
expect(result.current.scriptsOpen).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runScript("build", "pnpm build");
|
||||
});
|
||||
|
||||
expect(result.current.scriptsOpen).toBe(false);
|
||||
expect(result.current.terminalOpen).toBe(true);
|
||||
expect(result.current.terminalInitialCommand).toBe("pnpm build");
|
||||
});
|
||||
|
||||
it("loads unread count and agents when mailbox opens", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openMailbox();
|
||||
});
|
||||
|
||||
expect(result.current.mailboxOpen).toBe(true);
|
||||
expect(mockFetchUnreadCount).toHaveBeenCalledWith("proj_1");
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "proj_1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mailboxUnreadCount).toBe(3);
|
||||
expect(result.current.mailboxAgents).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks detail task state and supports tab-specific opens", () => {
|
||||
const task = createTaskDetail("FN-123");
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openDetailTask(task);
|
||||
});
|
||||
|
||||
expect(result.current.detailTask?.id).toBe("FN-123");
|
||||
expect(result.current.detailTaskInitialTab).toBe("definition");
|
||||
|
||||
act(() => {
|
||||
result.current.openDetailWithChangesTab(task);
|
||||
});
|
||||
|
||||
expect(result.current.detailTaskInitialTab).toBe("changes");
|
||||
|
||||
act(() => {
|
||||
result.current.closeDetailTask();
|
||||
});
|
||||
|
||||
expect(result.current.detailTask).toBeNull();
|
||||
});
|
||||
|
||||
it("opens settings with an initial section and resets on close", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openSettings("authentication");
|
||||
});
|
||||
|
||||
expect(result.current.settingsOpen).toBe(true);
|
||||
expect(result.current.settingsInitialSection).toBe("authentication");
|
||||
|
||||
act(() => {
|
||||
result.current.closeSettings();
|
||||
});
|
||||
|
||||
expect(result.current.settingsOpen).toBe(false);
|
||||
expect(result.current.settingsInitialSection).toBeUndefined();
|
||||
});
|
||||
});
|
||||
100
packages/dashboard/app/hooks/useAppSettings.ts
Normal file
100
packages/dashboard/app/hooks/useAppSettings.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchConfig, fetchSettings, updateSettings } from "../api";
|
||||
|
||||
/**
|
||||
* Settings state and actions consumed by the dashboard App shell.
|
||||
*/
|
||||
export interface UseAppSettingsResult {
|
||||
maxConcurrent: number;
|
||||
rootDir: string;
|
||||
autoMerge: boolean;
|
||||
globalPaused: boolean;
|
||||
enginePaused: boolean;
|
||||
taskStuckTimeoutMs: number | undefined;
|
||||
githubTokenConfigured: boolean;
|
||||
toggleAutoMerge: () => Promise<void>;
|
||||
toggleGlobalPause: () => Promise<void>;
|
||||
toggleEnginePause: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads per-project dashboard settings and exposes optimistic toggle handlers.
|
||||
*/
|
||||
export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const [rootDir, setRootDir] = useState<string>(".");
|
||||
const [autoMerge, setAutoMerge] = useState(true);
|
||||
const [globalPaused, setGlobalPaused] = useState(false);
|
||||
const [enginePaused, setEnginePaused] = useState(false);
|
||||
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
|
||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig(projectId)
|
||||
.then((cfg) => {
|
||||
setMaxConcurrent(cfg.maxConcurrent);
|
||||
setRootDir(cfg.rootDir);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep defaults on fetch failure.
|
||||
});
|
||||
|
||||
fetchSettings(projectId)
|
||||
.then((settings) => {
|
||||
setAutoMerge(Boolean(settings.autoMerge));
|
||||
setGlobalPaused(Boolean(settings.globalPause));
|
||||
setEnginePaused(Boolean(settings.enginePaused));
|
||||
setGithubTokenConfigured(Boolean(settings.githubTokenConfigured));
|
||||
setTaskStuckTimeoutMs(settings.taskStuckTimeoutMs);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep defaults on fetch failure.
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const toggleAutoMerge = useCallback(async () => {
|
||||
const next = !autoMerge;
|
||||
setAutoMerge(next);
|
||||
|
||||
try {
|
||||
await updateSettings({ autoMerge: next }, projectId);
|
||||
} catch {
|
||||
setAutoMerge(!next);
|
||||
}
|
||||
}, [autoMerge, projectId]);
|
||||
|
||||
const toggleGlobalPause = useCallback(async () => {
|
||||
const next = !globalPaused;
|
||||
setGlobalPaused(next);
|
||||
|
||||
try {
|
||||
await updateSettings({ globalPause: next }, projectId);
|
||||
} catch {
|
||||
setGlobalPaused(!next);
|
||||
}
|
||||
}, [globalPaused, projectId]);
|
||||
|
||||
const toggleEnginePause = useCallback(async () => {
|
||||
const next = !enginePaused;
|
||||
setEnginePaused(next);
|
||||
|
||||
try {
|
||||
await updateSettings({ enginePaused: next }, projectId);
|
||||
} catch {
|
||||
setEnginePaused(!next);
|
||||
}
|
||||
}, [enginePaused, projectId]);
|
||||
|
||||
return {
|
||||
maxConcurrent,
|
||||
rootDir,
|
||||
autoMerge,
|
||||
globalPaused,
|
||||
enginePaused,
|
||||
taskStuckTimeoutMs,
|
||||
githubTokenConfigured,
|
||||
toggleAutoMerge,
|
||||
toggleGlobalPause,
|
||||
toggleEnginePause,
|
||||
};
|
||||
}
|
||||
48
packages/dashboard/app/hooks/useAuthOnboarding.ts
Normal file
48
packages/dashboard/app/hooks/useAuthOnboarding.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect } from "react";
|
||||
import { fetchAuthStatus, fetchGlobalSettings } from "../api";
|
||||
import type { SectionId } from "../components/SettingsModal";
|
||||
|
||||
export interface UseAuthOnboardingOptions {
|
||||
projectId?: string;
|
||||
openModelOnboarding: () => void;
|
||||
openSettings: (section?: SectionId) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs auth/onboarding checks and opens the appropriate setup modal.
|
||||
*/
|
||||
export function useAuthOnboarding({
|
||||
projectId,
|
||||
openModelOnboarding,
|
||||
openSettings,
|
||||
}: UseAuthOnboardingOptions): void {
|
||||
useEffect(() => {
|
||||
fetchAuthStatus()
|
||||
.then(({ providers }) => {
|
||||
const hasAuthenticatedProvider = providers.some((provider) => provider.authenticated);
|
||||
const needsSetup = providers.length > 0 && !hasAuthenticatedProvider;
|
||||
|
||||
if (needsSetup || (providers.length > 0 && hasAuthenticatedProvider)) {
|
||||
fetchGlobalSettings()
|
||||
.then((globalSettings) => {
|
||||
const hasDefaultModel = !!(globalSettings.defaultProvider && globalSettings.defaultModelId);
|
||||
const setupIncomplete = !hasAuthenticatedProvider || !hasDefaultModel;
|
||||
|
||||
if (!globalSettings.modelOnboardingComplete && setupIncomplete) {
|
||||
openModelOnboarding();
|
||||
} else if (!hasAuthenticatedProvider) {
|
||||
openSettings("authentication");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!hasAuthenticatedProvider) {
|
||||
openModelOnboarding();
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fail silently (preserves existing App behavior).
|
||||
});
|
||||
}, [projectId, openModelOnboarding, openSettings]);
|
||||
}
|
||||
105
packages/dashboard/app/hooks/useDeepLink.ts
Normal file
105
packages/dashboard/app/hooks/useDeepLink.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import { fetchTaskDetail, type ProjectInfo } from "../api";
|
||||
import type { ToastType } from "./useToast";
|
||||
|
||||
interface UseDeepLinkOptions {
|
||||
projectId?: string;
|
||||
projects: ProjectInfo[];
|
||||
projectsLoading: boolean;
|
||||
currentProject: ProjectInfo | null;
|
||||
setCurrentProject: (project: ProjectInfo) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
openTaskDetail: (task: TaskDetail) => void;
|
||||
closeTaskDetail: () => void;
|
||||
}
|
||||
|
||||
export interface UseDeepLinkResult {
|
||||
/**
|
||||
* Call when the task detail modal closes.
|
||||
* Cleans ?task=... from URL if the modal was opened via deep-link.
|
||||
*/
|
||||
handleDetailClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles task deep-link behavior (?project=...&task=...).
|
||||
*/
|
||||
export function useDeepLink(options: UseDeepLinkOptions): UseDeepLinkResult {
|
||||
const {
|
||||
projectId,
|
||||
projects,
|
||||
projectsLoading,
|
||||
currentProject,
|
||||
setCurrentProject,
|
||||
addToast,
|
||||
openTaskDetail,
|
||||
closeTaskDetail,
|
||||
} = options;
|
||||
|
||||
// Prevent duplicate fetches when project switching causes the effect to re-run.
|
||||
const deepLinkFetchedRef = useRef(false);
|
||||
|
||||
// Track whether the currently open detail modal came from a deep-link.
|
||||
const deepLinkTaskIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const projectParam = params.get("project");
|
||||
const taskId = params.get("task");
|
||||
|
||||
if (!taskId) return;
|
||||
if (projectsLoading) return;
|
||||
|
||||
if (projectParam) {
|
||||
const matchingProject = projects.find((project) => project.id === projectParam);
|
||||
if (!matchingProject) {
|
||||
addToast(`Project '${projectParam}' not found`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentProject?.id !== matchingProject.id) {
|
||||
setCurrentProject(matchingProject);
|
||||
}
|
||||
}
|
||||
|
||||
if (deepLinkFetchedRef.current) return;
|
||||
deepLinkFetchedRef.current = true;
|
||||
|
||||
const taskProjectId = projectParam ?? projectId;
|
||||
fetchTaskDetail(taskId, taskProjectId)
|
||||
.then((detail) => {
|
||||
openTaskDetail(detail);
|
||||
deepLinkTaskIdRef.current = taskId;
|
||||
})
|
||||
.catch(() => {
|
||||
addToast(`Task ${taskId} not found`, "error");
|
||||
});
|
||||
}, [
|
||||
projectId,
|
||||
projects,
|
||||
projectsLoading,
|
||||
currentProject,
|
||||
setCurrentProject,
|
||||
addToast,
|
||||
openTaskDetail,
|
||||
]);
|
||||
|
||||
const handleDetailClose = useCallback(() => {
|
||||
if (deepLinkTaskIdRef.current) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("task");
|
||||
const query = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
query ? `${window.location.pathname}?${query}` : window.location.pathname,
|
||||
);
|
||||
deepLinkTaskIdRef.current = null;
|
||||
}
|
||||
|
||||
closeTaskDetail();
|
||||
}, [closeTaskDetail]);
|
||||
|
||||
return { handleDetailClose };
|
||||
}
|
||||
82
packages/dashboard/app/hooks/useFavorites.ts
Normal file
82
packages/dashboard/app/hooks/useFavorites.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
|
||||
|
||||
/**
|
||||
* Favorite model/provider state and actions consumed by the dashboard App shell.
|
||||
*/
|
||||
export interface UseFavoritesResult {
|
||||
availableModels: ModelInfo[];
|
||||
favoriteProviders: string[];
|
||||
favoriteModels: string[];
|
||||
toggleFavoriteProvider: (provider: string) => Promise<void>;
|
||||
toggleFavoriteModel: (modelId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads model catalog + favorites and exposes optimistic favorite toggles.
|
||||
*/
|
||||
export function useFavorites(): UseFavoritesResult {
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
setFavoriteModels(response.favoriteModels);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep defaults on fetch failure.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleFavoriteProvider = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const nextFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(nextFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({
|
||||
favoriteProviders: nextFavorites,
|
||||
favoriteModels,
|
||||
});
|
||||
} catch (error) {
|
||||
setFavoriteProviders(currentFavorites);
|
||||
throw error;
|
||||
}
|
||||
}, [favoriteProviders, favoriteModels]);
|
||||
|
||||
const toggleFavoriteModel = useCallback(async (modelId: string) => {
|
||||
const currentFavorites = favoriteModels;
|
||||
const isFavorite = currentFavorites.includes(modelId);
|
||||
const nextFavorites = isFavorite
|
||||
? currentFavorites.filter((id) => id !== modelId)
|
||||
: [modelId, ...currentFavorites];
|
||||
|
||||
setFavoriteModels(nextFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({
|
||||
favoriteProviders,
|
||||
favoriteModels: nextFavorites,
|
||||
});
|
||||
} catch (error) {
|
||||
setFavoriteModels(currentFavorites);
|
||||
throw error;
|
||||
}
|
||||
}, [favoriteModels, favoriteProviders]);
|
||||
|
||||
return {
|
||||
availableModels,
|
||||
favoriteProviders,
|
||||
favoriteModels,
|
||||
toggleFavoriteProvider,
|
||||
toggleFavoriteModel,
|
||||
};
|
||||
}
|
||||
443
packages/dashboard/app/hooks/useModalManager.ts
Normal file
443
packages/dashboard/app/hooks/useModalManager.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { fetchAgents, fetchUnreadCount, type Agent } from "../api";
|
||||
import type { SectionId } from "../components/SettingsModal";
|
||||
import type { ToastType } from "./useToast";
|
||||
|
||||
export type DetailTaskTab =
|
||||
| "definition"
|
||||
| "logs"
|
||||
| "changes"
|
||||
| "commits"
|
||||
| "comments"
|
||||
| "model"
|
||||
| "workflow";
|
||||
|
||||
interface UseModalManagerOptions {
|
||||
projectId?: string;
|
||||
planningSessions: Array<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* State and handler contract for App-level modal/overlay orchestration.
|
||||
*/
|
||||
export interface ModalManager {
|
||||
// State
|
||||
newTaskModalOpen: boolean;
|
||||
isPlanningOpen: boolean;
|
||||
planningInitialPlan: string | null;
|
||||
planningResumeSessionId: string | undefined;
|
||||
isSubtaskOpen: boolean;
|
||||
subtaskInitialDescription: string | null;
|
||||
subtaskResumeSessionId: string | undefined;
|
||||
detailTask: TaskDetail | null;
|
||||
detailTaskInitialTab: DetailTaskTab;
|
||||
settingsOpen: boolean;
|
||||
settingsInitialSection: SectionId | undefined;
|
||||
schedulesOpen: boolean;
|
||||
githubImportOpen: boolean;
|
||||
usageOpen: boolean;
|
||||
terminalOpen: boolean;
|
||||
terminalInitialCommand: string | undefined;
|
||||
filesOpen: boolean;
|
||||
fileBrowserWorkspace: string;
|
||||
activityLogOpen: boolean;
|
||||
mailboxOpen: boolean;
|
||||
mailboxUnreadCount: number;
|
||||
mailboxAgents: Agent[];
|
||||
gitManagerOpen: boolean;
|
||||
workflowStepsOpen: boolean;
|
||||
missionsOpen: boolean;
|
||||
missionResumeSessionId: string | undefined;
|
||||
missionTargetId: string | undefined;
|
||||
agentsOpen: boolean;
|
||||
scriptsOpen: boolean;
|
||||
setupWizardOpen: boolean;
|
||||
modelOnboardingOpen: boolean;
|
||||
anyModalOpen: boolean;
|
||||
|
||||
// Handlers
|
||||
openNewTask: () => void;
|
||||
closeNewTask: () => void;
|
||||
|
||||
openPlanning: () => void;
|
||||
openPlanningWithInitialPlan: (initialPlan: string) => void;
|
||||
resumePlanning: () => void;
|
||||
openPlanningWithSession: (sessionId: string) => void;
|
||||
closePlanning: () => void;
|
||||
|
||||
openSubtaskBreakdown: (description: string) => void;
|
||||
openSubtaskWithSession: (sessionId: string) => void;
|
||||
closeSubtask: () => void;
|
||||
|
||||
openDetailTask: (task: TaskDetail, initialTab?: DetailTaskTab) => void;
|
||||
openDetailWithChangesTab: (task: TaskDetail) => void;
|
||||
updateDetailTask: (updated: Partial<TaskDetail>) => void;
|
||||
closeDetailTask: () => void;
|
||||
|
||||
openSettings: (section?: SectionId) => void;
|
||||
closeSettings: () => void;
|
||||
|
||||
openSchedules: () => void;
|
||||
closeSchedules: () => void;
|
||||
|
||||
openGitHubImport: () => void;
|
||||
closeGitHubImport: () => void;
|
||||
|
||||
openUsage: () => void;
|
||||
closeUsage: () => void;
|
||||
|
||||
toggleTerminal: () => void;
|
||||
closeTerminal: () => void;
|
||||
|
||||
openFiles: () => void;
|
||||
closeFiles: () => void;
|
||||
setFileWorkspace: (workspace: string) => void;
|
||||
|
||||
openActivityLog: () => void;
|
||||
closeActivityLog: () => void;
|
||||
|
||||
openMailbox: () => void;
|
||||
closeMailbox: () => void;
|
||||
|
||||
openGitManager: () => void;
|
||||
closeGitManager: () => void;
|
||||
|
||||
openWorkflowSteps: () => void;
|
||||
closeWorkflowSteps: () => void;
|
||||
|
||||
openMissions: () => void;
|
||||
openMissionById: (missionId: string) => void;
|
||||
openMissionWithSession: (sessionId: string) => void;
|
||||
closeMissions: () => void;
|
||||
|
||||
openAgents: () => void;
|
||||
closeAgents: () => void;
|
||||
|
||||
openScripts: () => void;
|
||||
closeScripts: () => void;
|
||||
runScript: (name: string, command: string) => Promise<void>;
|
||||
|
||||
openSetupWizard: () => void;
|
||||
closeSetupWizard: () => void;
|
||||
|
||||
openModelOnboarding: () => void;
|
||||
closeModelOnboarding: () => 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 { projectId, planningSessions } = options;
|
||||
|
||||
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
|
||||
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
|
||||
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
|
||||
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
|
||||
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
|
||||
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
const [detailTaskInitialTab, setDetailTaskInitialTab] = useState<DetailTaskTab>("definition");
|
||||
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 [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||
const [mailboxOpen, setMailboxOpen] = useState(false);
|
||||
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
||||
const [mailboxAgents, setMailboxAgents] = useState<Agent[]>([]);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||
const [missionsOpen, setMissionsOpen] = useState(false);
|
||||
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [missionTargetId, setMissionTargetId] = 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 ||
|
||||
settingsOpen ||
|
||||
newTaskModalOpen ||
|
||||
isPlanningOpen ||
|
||||
isSubtaskOpen ||
|
||||
terminalOpen ||
|
||||
filesOpen ||
|
||||
activityLogOpen ||
|
||||
mailboxOpen ||
|
||||
gitManagerOpen ||
|
||||
workflowStepsOpen ||
|
||||
missionsOpen ||
|
||||
scriptsOpen ||
|
||||
agentsOpen ||
|
||||
usageOpen ||
|
||||
schedulesOpen ||
|
||||
githubImportOpen ||
|
||||
setupWizardOpen ||
|
||||
modelOnboardingOpen,
|
||||
);
|
||||
|
||||
const openNewTask = useCallback(() => setNewTaskModalOpen(true), []);
|
||||
const closeNewTask = useCallback(() => setNewTaskModalOpen(false), []);
|
||||
|
||||
const openPlanning = useCallback(() => setIsPlanningOpen(true), []);
|
||||
const openPlanningWithInitialPlan = useCallback((initialPlan: string) => {
|
||||
setPlanningInitialPlan(initialPlan);
|
||||
setIsPlanningOpen(true);
|
||||
}, []);
|
||||
const resumePlanning = useCallback(() => {
|
||||
const session = planningSessions[0];
|
||||
if (!session) return;
|
||||
setPlanningResumeSessionId(session.id);
|
||||
setIsPlanningOpen(true);
|
||||
}, [planningSessions]);
|
||||
const openPlanningWithSession = useCallback((sessionId: string) => {
|
||||
setPlanningResumeSessionId(sessionId);
|
||||
setIsPlanningOpen(true);
|
||||
}, []);
|
||||
const closePlanning = useCallback(() => {
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
setPlanningResumeSessionId(undefined);
|
||||
}, []);
|
||||
|
||||
const openSubtaskBreakdown = useCallback((description: string) => {
|
||||
setSubtaskInitialDescription(description);
|
||||
setIsSubtaskOpen(true);
|
||||
}, []);
|
||||
const openSubtaskWithSession = useCallback((sessionId: string) => {
|
||||
setSubtaskResumeSessionId(sessionId);
|
||||
setIsSubtaskOpen(true);
|
||||
}, []);
|
||||
const closeSubtask = useCallback(() => {
|
||||
setIsSubtaskOpen(false);
|
||||
setSubtaskInitialDescription(null);
|
||||
setSubtaskResumeSessionId(undefined);
|
||||
}, []);
|
||||
|
||||
const openDetailTask = useCallback((task: TaskDetail, initialTab: DetailTaskTab = "definition") => {
|
||||
setDetailTask(task);
|
||||
setDetailTaskInitialTab(initialTab);
|
||||
}, []);
|
||||
const openDetailWithChangesTab = useCallback((task: TaskDetail) => {
|
||||
setDetailTask(task);
|
||||
setDetailTaskInitialTab("changes");
|
||||
}, []);
|
||||
const updateDetailTask = useCallback((updated: Partial<TaskDetail>) => {
|
||||
setDetailTask((prev) => (prev ? { ...prev, ...updated } : prev));
|
||||
}, []);
|
||||
const closeDetailTask = useCallback(() => {
|
||||
setDetailTask(null);
|
||||
}, []);
|
||||
|
||||
const openSettings = useCallback((section?: SectionId) => {
|
||||
setSettingsInitialSection(section);
|
||||
setSettingsOpen(true);
|
||||
}, []);
|
||||
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(() => setUsageOpen(true), []);
|
||||
const closeUsage = useCallback(() => setUsageOpen(false), []);
|
||||
|
||||
const toggleTerminal = useCallback(() => {
|
||||
setTerminalOpen((prev) => !prev);
|
||||
}, []);
|
||||
const closeTerminal = useCallback(() => {
|
||||
setTerminalOpen(false);
|
||||
setTerminalInitialCommand(undefined);
|
||||
}, []);
|
||||
|
||||
const openFiles = useCallback(() => setFilesOpen(true), []);
|
||||
const closeFiles = useCallback(() => setFilesOpen(false), []);
|
||||
const setFileWorkspace = useCallback((workspace: string) => {
|
||||
setFileBrowserWorkspace(workspace);
|
||||
}, []);
|
||||
|
||||
const openActivityLog = useCallback(() => setActivityLogOpen(true), []);
|
||||
const closeActivityLog = useCallback(() => setActivityLogOpen(false), []);
|
||||
|
||||
const openMailbox = useCallback(() => {
|
||||
setMailboxOpen(true);
|
||||
|
||||
fetchUnreadCount(projectId)
|
||||
.then((data) => {
|
||||
setMailboxUnreadCount(data.unreadCount);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetchAgents(undefined, projectId)
|
||||
.then((agents) => {
|
||||
setMailboxAgents(agents);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
const closeMailbox = useCallback(() => setMailboxOpen(false), []);
|
||||
|
||||
const openGitManager = useCallback(() => setGitManagerOpen(true), []);
|
||||
const closeGitManager = useCallback(() => setGitManagerOpen(false), []);
|
||||
|
||||
const openWorkflowSteps = useCallback(() => setWorkflowStepsOpen(true), []);
|
||||
const closeWorkflowSteps = useCallback(() => setWorkflowStepsOpen(false), []);
|
||||
|
||||
const openMissions = useCallback(() => setMissionsOpen(true), []);
|
||||
const openMissionById = useCallback((missionId: string) => {
|
||||
setMissionTargetId(missionId);
|
||||
setMissionsOpen(true);
|
||||
}, []);
|
||||
const openMissionWithSession = useCallback((sessionId: string) => {
|
||||
setMissionResumeSessionId(sessionId);
|
||||
setMissionsOpen(true);
|
||||
}, []);
|
||||
const closeMissions = useCallback(() => {
|
||||
setMissionsOpen(false);
|
||||
setMissionResumeSessionId(undefined);
|
||||
setMissionTargetId(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);
|
||||
setTerminalOpen(true);
|
||||
}, []);
|
||||
|
||||
const openSetupWizard = useCallback(() => setSetupWizardOpen(true), []);
|
||||
const closeSetupWizard = useCallback(() => setSetupWizardOpen(false), []);
|
||||
|
||||
const openModelOnboarding = useCallback(() => setModelOnboardingOpen(true), []);
|
||||
const closeModelOnboarding = useCallback(() => setModelOnboardingOpen(false), []);
|
||||
|
||||
const onPlanningTaskCreated = useCallback((task: Task, addToast: (message: string, type?: ToastType) => void) => {
|
||||
addToast(`Created ${task.id} from planning mode`, "success");
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
}, []);
|
||||
|
||||
const onPlanningTasksCreated = useCallback((tasks: Task[], addToast: (message: string, type?: ToastType) => void) => {
|
||||
const ids = tasks.map((task) => task.id).join(", ");
|
||||
addToast(`Created ${ids} from planning mode`, "success");
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
}, []);
|
||||
|
||||
const onSubtaskTasksCreated = useCallback((tasks: Task[], addToast: (message: string, type?: ToastType) => void) => {
|
||||
const ids = tasks.map((task) => task.id).join(", ");
|
||||
addToast(`Created ${ids} from subtask breakdown`, "success");
|
||||
setIsSubtaskOpen(false);
|
||||
setSubtaskInitialDescription(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
newTaskModalOpen,
|
||||
isPlanningOpen,
|
||||
planningInitialPlan,
|
||||
planningResumeSessionId,
|
||||
isSubtaskOpen,
|
||||
subtaskInitialDescription,
|
||||
subtaskResumeSessionId,
|
||||
detailTask,
|
||||
detailTaskInitialTab,
|
||||
settingsOpen,
|
||||
settingsInitialSection,
|
||||
schedulesOpen,
|
||||
githubImportOpen,
|
||||
usageOpen,
|
||||
terminalOpen,
|
||||
terminalInitialCommand,
|
||||
filesOpen,
|
||||
fileBrowserWorkspace,
|
||||
activityLogOpen,
|
||||
mailboxOpen,
|
||||
mailboxUnreadCount,
|
||||
mailboxAgents,
|
||||
gitManagerOpen,
|
||||
workflowStepsOpen,
|
||||
missionsOpen,
|
||||
missionResumeSessionId,
|
||||
missionTargetId,
|
||||
agentsOpen,
|
||||
scriptsOpen,
|
||||
setupWizardOpen,
|
||||
modelOnboardingOpen,
|
||||
anyModalOpen,
|
||||
openNewTask,
|
||||
closeNewTask,
|
||||
openPlanning,
|
||||
openPlanningWithInitialPlan,
|
||||
resumePlanning,
|
||||
openPlanningWithSession,
|
||||
closePlanning,
|
||||
openSubtaskBreakdown,
|
||||
openSubtaskWithSession,
|
||||
closeSubtask,
|
||||
openDetailTask,
|
||||
openDetailWithChangesTab,
|
||||
updateDetailTask,
|
||||
closeDetailTask,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
openSchedules,
|
||||
closeSchedules,
|
||||
openGitHubImport,
|
||||
closeGitHubImport,
|
||||
openUsage,
|
||||
closeUsage,
|
||||
toggleTerminal,
|
||||
closeTerminal,
|
||||
openFiles,
|
||||
closeFiles,
|
||||
setFileWorkspace,
|
||||
openActivityLog,
|
||||
closeActivityLog,
|
||||
openMailbox,
|
||||
closeMailbox,
|
||||
openGitManager,
|
||||
closeGitManager,
|
||||
openWorkflowSteps,
|
||||
closeWorkflowSteps,
|
||||
openMissions,
|
||||
openMissionById,
|
||||
openMissionWithSession,
|
||||
closeMissions,
|
||||
openAgents,
|
||||
closeAgents,
|
||||
openScripts,
|
||||
closeScripts,
|
||||
runScript,
|
||||
openSetupWizard,
|
||||
closeSetupWizard,
|
||||
openModelOnboarding,
|
||||
closeModelOnboarding,
|
||||
onPlanningTaskCreated,
|
||||
onPlanningTasksCreated,
|
||||
onSubtaskTasksCreated,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user