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:
gsxdsm
2026-04-08 05:53:24 -07:00
parent 2a2f147780
commit 7becb33972
11 changed files with 1680 additions and 381 deletions

View File

@@ -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");
});
});

View 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();
});
});

View 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);
});
});
});

View 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"]);
});
});

View 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();
});
});

View 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,
};
}

View 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]);
}

View 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 };
}

View 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,
};
}

View 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,
};
}