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:
@@ -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