fix(KB-503): remove duplicate getDefaultProject function
- Import getDefaultProject from project-context.js instead of duplicating - Remove local getDefaultProject implementation in project.ts - All 6 tests still passing
This commit is contained in:
@@ -23,6 +23,22 @@ import {
|
||||
startPlanningStreaming,
|
||||
fetchTasks,
|
||||
summarizeTitle,
|
||||
fetchProjects,
|
||||
registerProject,
|
||||
unregisterProject,
|
||||
fetchProjectHealth,
|
||||
fetchActivityFeed,
|
||||
pauseProject,
|
||||
resumeProject,
|
||||
fetchFirstRunStatus,
|
||||
fetchGlobalConcurrency,
|
||||
fetchProjectTasks,
|
||||
fetchProjectConfig,
|
||||
type ProjectInfo,
|
||||
type ProjectHealth,
|
||||
type ActivityFeedEntry,
|
||||
type FirstRunStatus,
|
||||
type GlobalConcurrencyState,
|
||||
} from "./api";
|
||||
import type { Task, TaskDetail, BatchStatusResponse } from "@fusion/core";
|
||||
|
||||
@@ -1799,3 +1815,343 @@ describe("summarizeTitle", () => {
|
||||
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("API returned empty title");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Project Management API Tests ───────────────────────────────────────────
|
||||
|
||||
const FAKE_PROJECT: ProjectInfo = {
|
||||
id: "proj_abc123",
|
||||
name: "Test Project",
|
||||
path: "/path/to/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastActivityAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const FAKE_PROJECT_HEALTH: ProjectHealth = {
|
||||
projectId: "proj_abc123",
|
||||
status: "active",
|
||||
activeTaskCount: 5,
|
||||
inFlightAgentCount: 2,
|
||||
lastActivityAt: "2026-01-01T00:00:00.000Z",
|
||||
totalTasksCompleted: 100,
|
||||
totalTasksFailed: 5,
|
||||
averageTaskDurationMs: 600000,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const FAKE_ACTIVITY_ENTRY: ActivityFeedEntry = {
|
||||
id: "act_123",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_abc123",
|
||||
projectName: "Test Project",
|
||||
taskId: "KB-001",
|
||||
taskTitle: "Test Task",
|
||||
details: "Task created",
|
||||
};
|
||||
|
||||
describe("fetchProjects", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns list of projects", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [FAKE_PROJECT]));
|
||||
|
||||
const result = await fetchProjects();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("proj_abc123");
|
||||
expect(result[0].name).toBe("Test Project");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects",
|
||||
expect.objectContaining({ headers: { "Content-Type": "application/json" } })
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Database error" }));
|
||||
|
||||
await expect(fetchProjects()).rejects.toThrow("Database error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerProject", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("registers a new project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_PROJECT));
|
||||
|
||||
const result = await registerProject({
|
||||
name: "Test Project",
|
||||
path: "/path/to/project",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("proj_abc123");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "Test Project",
|
||||
path: "/path/to/project",
|
||||
isolationMode: "in-process",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses default isolation mode when not specified", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_PROJECT));
|
||||
|
||||
await registerProject({
|
||||
name: "Test Project",
|
||||
path: "/path/to/project",
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
name: "Test Project",
|
||||
path: "/path/to/project",
|
||||
isolationMode: undefined,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregisterProject", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("unregisters a project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
||||
|
||||
await unregisterProject("proj_abc123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_abc123",
|
||||
expect.objectContaining({ method: "DELETE" })
|
||||
);
|
||||
});
|
||||
|
||||
it("url-encodes project id", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
||||
|
||||
await unregisterProject("proj/with+special");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj%2Fwith%2Bspecial",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProjectHealth", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns health metrics for a project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_PROJECT_HEALTH));
|
||||
|
||||
const result = await fetchProjectHealth("proj_abc123");
|
||||
|
||||
expect(result.projectId).toBe("proj_abc123");
|
||||
expect(result.activeTaskCount).toBe(5);
|
||||
expect(result.inFlightAgentCount).toBe(2);
|
||||
expect(result.totalTasksCompleted).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchActivityFeed", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns activity feed without options", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [FAKE_ACTIVITY_ENTRY]));
|
||||
|
||||
const result = await fetchActivityFeed();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("task:created");
|
||||
expect(result[0].projectName).toBe("Test Project");
|
||||
});
|
||||
|
||||
it("passes query parameters", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchActivityFeed({
|
||||
limit: 50,
|
||||
since: "2026-01-01T00:00:00.000Z",
|
||||
projectId: "proj_abc123",
|
||||
type: "task:created",
|
||||
});
|
||||
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[0]).toContain("limit=50");
|
||||
expect(call[0]).toContain("since=2026-01-01T00%3A00%3A00.000Z");
|
||||
expect(call[0]).toContain("projectId=proj_abc123");
|
||||
expect(call[0]).toContain("type=task%3Acreated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pauseProject", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("pauses a project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_PROJECT, status: "paused" }));
|
||||
|
||||
const result = await pauseProject("proj_abc123");
|
||||
|
||||
expect(result.status).toBe("paused");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_abc123/pause",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeProject", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("resumes a paused project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_PROJECT, status: "active" }));
|
||||
|
||||
const result = await resumeProject("proj_abc123");
|
||||
|
||||
expect(result.status).toBe("active");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_abc123/resume",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchFirstRunStatus", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns first run status with existing projects", async () => {
|
||||
const mockStatus: FirstRunStatus = { hasProjects: true, singleProjectPath: "/existing/project" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
|
||||
|
||||
const result = await fetchFirstRunStatus();
|
||||
|
||||
expect(result.hasProjects).toBe(true);
|
||||
expect(result.singleProjectPath).toBe("/existing/project");
|
||||
});
|
||||
|
||||
it("returns first run status with no projects", async () => {
|
||||
const mockStatus: FirstRunStatus = { hasProjects: false, singleProjectPath: null };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
|
||||
|
||||
const result = await fetchFirstRunStatus();
|
||||
|
||||
expect(result.hasProjects).toBe(false);
|
||||
expect(result.singleProjectPath).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGlobalConcurrency", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns global concurrency state", async () => {
|
||||
const mockState: GlobalConcurrencyState = {
|
||||
globalMaxConcurrent: 4,
|
||||
currentlyActive: 2,
|
||||
queuedCount: 1,
|
||||
projectsActive: { "proj_abc123": 2 },
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockState));
|
||||
|
||||
const result = await fetchGlobalConcurrency();
|
||||
|
||||
expect(result.globalMaxConcurrent).toBe(4);
|
||||
expect(result.currentlyActive).toBe(2);
|
||||
expect(result.projectsActive["proj_abc123"]).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProjectTasks", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("fetches tasks for a specific project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [{ id: "KB-001", description: "Test", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }]));
|
||||
|
||||
const result = await fetchProjectTasks("proj_abc123");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/tasks?"),
|
||||
expect.any(Object)
|
||||
);
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[0]).toContain("projectId=proj_abc123");
|
||||
});
|
||||
|
||||
it("passes pagination parameters", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchProjectTasks("proj_abc123", 50, 100);
|
||||
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[0]).toContain("limit=50");
|
||||
expect(call[0]).toContain("offset=100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProjectConfig", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns project config", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { maxConcurrent: 4, rootDir: "/path/to/project" }));
|
||||
|
||||
const result = await fetchProjectConfig("proj_abc123");
|
||||
|
||||
expect(result.maxConcurrent).toBe(4);
|
||||
expect(result.rootDir).toBe("/path/to/project");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1722,3 +1722,149 @@ export async function summarizeTitle(
|
||||
|
||||
return data.title;
|
||||
}
|
||||
|
||||
// ── Project Management API (Multi-Project Support) ───────────────────────
|
||||
|
||||
/** Project information returned by project endpoints */
|
||||
export interface ProjectInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
status: "active" | "paused" | "errored" | "initializing";
|
||||
isolationMode: "in-process" | "child-process";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivityAt?: string;
|
||||
}
|
||||
|
||||
/** Project health metrics */
|
||||
export interface ProjectHealth {
|
||||
projectId: string;
|
||||
status: "active" | "paused" | "errored" | "initializing";
|
||||
activeTaskCount: number;
|
||||
inFlightAgentCount: number;
|
||||
lastActivityAt?: string;
|
||||
lastErrorAt?: string;
|
||||
lastErrorMessage?: string;
|
||||
totalTasksCompleted: number;
|
||||
totalTasksFailed: number;
|
||||
averageTaskDurationMs?: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Unified activity feed entry */
|
||||
export interface ActivityFeedEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: "task:created" | "task:moved" | "task:updated" | "task:deleted" | "task:merged" | "task:failed" | "settings:updated";
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
details: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Input for creating a new project */
|
||||
export interface ProjectCreateInput {
|
||||
name: string;
|
||||
path: string;
|
||||
isolationMode?: "in-process" | "child-process";
|
||||
}
|
||||
|
||||
/** Options for fetching activity feed */
|
||||
export interface FeedOptions {
|
||||
limit?: number;
|
||||
since?: string;
|
||||
projectId?: string;
|
||||
type?: ActivityFeedEntry["type"];
|
||||
}
|
||||
|
||||
/** Global concurrency state across all projects */
|
||||
export interface GlobalConcurrencyState {
|
||||
globalMaxConcurrent: number;
|
||||
currentlyActive: number;
|
||||
queuedCount: number;
|
||||
projectsActive: Record<string, number>;
|
||||
}
|
||||
|
||||
/** First run status response */
|
||||
export interface FirstRunStatus {
|
||||
hasProjects: boolean;
|
||||
singleProjectPath: string | null;
|
||||
}
|
||||
|
||||
/** Fetch all registered projects */
|
||||
export function fetchProjects(): Promise<ProjectInfo[]> {
|
||||
return api<ProjectInfo[]>("/projects");
|
||||
}
|
||||
|
||||
/** Register a new project */
|
||||
export function registerProject(input: ProjectCreateInput): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>("/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Unregister a project */
|
||||
export function unregisterProject(id: string): Promise<void> {
|
||||
return api<void>(`/projects/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch health metrics for a specific project */
|
||||
export function fetchProjectHealth(id: string): Promise<ProjectHealth> {
|
||||
return api<ProjectHealth>(`/projects/${encodeURIComponent(id)}/health`);
|
||||
}
|
||||
|
||||
/** Fetch unified activity feed */
|
||||
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.since) params.set("since", options.since);
|
||||
if (options?.projectId) params.set("projectId", options.projectId);
|
||||
if (options?.type) params.set("type", options.type);
|
||||
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<ActivityFeedEntry[]>(`/activity-feed${query}`);
|
||||
}
|
||||
|
||||
/** Pause a project */
|
||||
export function pauseProject(id: string): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}/pause`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Resume a paused project */
|
||||
export function resumeProject(id: string): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}/resume`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch first run status to detect if user needs setup wizard */
|
||||
export function fetchFirstRunStatus(): Promise<FirstRunStatus> {
|
||||
return api<FirstRunStatus>("/first-run-status");
|
||||
}
|
||||
|
||||
/** Fetch global concurrency state */
|
||||
export function fetchGlobalConcurrency(): Promise<GlobalConcurrencyState> {
|
||||
return api<GlobalConcurrencyState>("/global-concurrency");
|
||||
}
|
||||
|
||||
/** Fetch tasks for a specific project */
|
||||
export function fetchProjectTasks(projectId: string, limit?: number, offset?: number): Promise<Task[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("projectId", projectId);
|
||||
if (limit !== undefined) params.set("limit", String(limit));
|
||||
if (offset !== undefined) params.set("offset", String(offset));
|
||||
return api<Task[]>(`/tasks?${params.toString()}`);
|
||||
}
|
||||
|
||||
/** Fetch project-specific config */
|
||||
export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> {
|
||||
return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`);
|
||||
}
|
||||
|
||||
217
packages/dashboard/app/components/ActivityFeed.tsx
Normal file
217
packages/dashboard/app/components/ActivityFeed.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import {
|
||||
GitPullRequest,
|
||||
GitMerge,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Plus,
|
||||
ArrowRightLeft,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
Folder
|
||||
} from "lucide-react";
|
||||
import type { ActivityFeedEntry } from "../api";
|
||||
|
||||
export interface ActivityFeedProps {
|
||||
entries: ActivityFeedEntry[];
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
projectNames?: Record<string, string>;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<ActivityFeedEntry["type"], {
|
||||
label: string;
|
||||
icon: typeof Plus;
|
||||
color: string;
|
||||
}> = {
|
||||
"task:created": { label: "Created", icon: Plus, color: "var(--todo)" },
|
||||
"task:moved": { label: "Moved", icon: ArrowRightLeft, color: "var(--in-progress)" },
|
||||
"task:updated": { label: "Updated", icon: Settings, color: "var(--text-muted)" },
|
||||
"task:deleted": { label: "Deleted", icon: XCircle, color: "var(--error)" },
|
||||
"task:merged": { label: "Merged", icon: GitMerge, color: "var(--color-success)" },
|
||||
"task:failed": { label: "Failed", icon: AlertTriangle, color: "var(--error)" },
|
||||
"settings:updated": { label: "Settings", icon: Settings, color: "var(--text-muted)" },
|
||||
};
|
||||
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatFullTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
interface ActivityFeedItemProps {
|
||||
entry: ActivityFeedEntry;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
function ActivityFeedItem({ entry, projectName }: ActivityFeedItemProps) {
|
||||
const config = TYPE_CONFIG[entry.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="activity-feed-item" data-type={entry.type}>
|
||||
<div className="activity-feed-icon" style={{ color: config.color }}>
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
<div className="activity-feed-content">
|
||||
<div className="activity-feed-header">
|
||||
<span className="activity-feed-type">{config.label}</span>
|
||||
{projectName && (
|
||||
<span className="activity-feed-project-badge">
|
||||
<Folder size={10} />
|
||||
{projectName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="activity-feed-details">
|
||||
{entry.taskId && (
|
||||
<span className="activity-feed-task-id">{entry.taskId}</span>
|
||||
)}
|
||||
{entry.taskTitle && (
|
||||
<span className="activity-feed-task-title" title={entry.taskTitle}>
|
||||
{entry.taskTitle}
|
||||
</span>
|
||||
)}
|
||||
<span className="activity-feed-description">{entry.details}</span>
|
||||
</div>
|
||||
<div className="activity-feed-meta">
|
||||
<span className="activity-feed-time" title={formatFullTime(entry.timestamp)}>
|
||||
{formatRelativeTime(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function areActivityFeedPropsEqual(previous: ActivityFeedProps, next: ActivityFeedProps): boolean {
|
||||
if (previous.isLoading !== next.isLoading) return false;
|
||||
if (previous.error !== next.error) return false;
|
||||
if (previous.entries.length !== next.entries.length) return false;
|
||||
|
||||
for (let i = 0; i < previous.entries.length; i++) {
|
||||
const prev = previous.entries[i];
|
||||
const curr = next.entries[i];
|
||||
if (prev.id !== curr.id) return false;
|
||||
if (prev.timestamp !== curr.timestamp) return false;
|
||||
if (prev.type !== curr.type) return false;
|
||||
if (prev.details !== curr.details) return false;
|
||||
if (prev.taskId !== curr.taskId) return false;
|
||||
if (prev.taskTitle !== curr.taskTitle) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function ActivityFeedInner({
|
||||
entries,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
projectNames = {},
|
||||
emptyMessage = "No recent activity",
|
||||
}: ActivityFeedProps) {
|
||||
const getProjectName = useCallback((projectId: string): string => {
|
||||
return projectNames[projectId] || projectId;
|
||||
}, [projectNames]);
|
||||
|
||||
const groupedEntries = useMemo(() => {
|
||||
const groups: { date: string; entries: ActivityFeedEntry[] }[] = [];
|
||||
let currentGroup: { date: string; entries: ActivityFeedEntry[] } | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
const date = new Date(entry.timestamp).toLocaleDateString();
|
||||
|
||||
if (!currentGroup || currentGroup.date !== date) {
|
||||
currentGroup = { date, entries: [] };
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup.entries.push(entry);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [entries]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="activity-feed activity-feed-loading">
|
||||
<div className="activity-feed-skeleton">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="activity-feed-skeleton-item">
|
||||
<div className="activity-feed-skeleton-icon" />
|
||||
<div className="activity-feed-skeleton-content">
|
||||
<div className="activity-feed-skeleton-line" />
|
||||
<div className="activity-feed-skeleton-line short" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="activity-feed activity-feed-error">
|
||||
<div className="activity-feed-error-message">
|
||||
<AlertTriangle size={24} />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="activity-feed activity-feed-empty">
|
||||
<div className="activity-feed-empty-state">
|
||||
<CheckCircle size={32} />
|
||||
<p>{emptyMessage}</p>
|
||||
<span className="activity-feed-empty-hint">
|
||||
Activity will appear here when tasks are created, moved, or completed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="activity-feed">
|
||||
{groupedEntries.map((group) => (
|
||||
<div key={group.date} className="activity-feed-group">
|
||||
<div className="activity-feed-group-header">
|
||||
<span className="activity-feed-group-date">{group.date}</span>
|
||||
<span className="activity-feed-group-count">
|
||||
{group.entries.length} event{group.entries.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="activity-feed-list">
|
||||
{group.entries.map((entry) => (
|
||||
<ActivityFeedItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
projectName={getProjectName(entry.projectId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ActivityFeed = memo(ActivityFeedInner, areActivityFeedPropsEqual);
|
||||
222
packages/dashboard/app/components/ProjectCard.tsx
Normal file
222
packages/dashboard/app/components/ProjectCard.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Play, Pause, AlertCircle, Loader2, MoreHorizontal, Trash2, Folder, ArrowRight } from "lucide-react";
|
||||
import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core";
|
||||
|
||||
export interface ProjectCardProps {
|
||||
project: RegisteredProject;
|
||||
health: ProjectHealth | null;
|
||||
onSelect: (project: RegisteredProject) => void;
|
||||
onPause: (project: RegisteredProject) => void;
|
||||
onResume: (project: RegisteredProject) => void;
|
||||
onRemove: (project: RegisteredProject) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<ProjectStatus, { label: string; color: string; icon: typeof Play }> = {
|
||||
active: { label: "Active", color: "var(--success)", icon: Play },
|
||||
paused: { label: "Paused", color: "var(--warning)", icon: Pause },
|
||||
errored: { label: "Error", color: "var(--error)", icon: AlertCircle },
|
||||
initializing: { label: "Initializing", color: "var(--info)", icon: Loader2 },
|
||||
};
|
||||
|
||||
function formatRelativeTime(timestamp: string | undefined): string {
|
||||
if (!timestamp) return "Never";
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function truncatePath(path: string, maxLength: number = 40): string {
|
||||
if (path.length <= maxLength) return path;
|
||||
const start = path.slice(0, Math.floor(maxLength / 2) - 2);
|
||||
const end = path.slice(-Math.floor(maxLength / 2) + 2);
|
||||
return `${start}...${end}`;
|
||||
}
|
||||
|
||||
function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardProps): boolean {
|
||||
if (previous.project.id !== next.project.id) return false;
|
||||
if (previous.project.status !== next.project.status) return false;
|
||||
if (previous.project.name !== next.project.name) return false;
|
||||
if (previous.project.path !== next.project.path) return false;
|
||||
if (previous.project.lastActivityAt !== next.project.lastActivityAt) return false;
|
||||
if (previous.isLoading !== next.isLoading) return false;
|
||||
|
||||
// Compare health
|
||||
const prevHealth = previous.health;
|
||||
const nextHealth = next.health;
|
||||
if (!prevHealth && !nextHealth) return true;
|
||||
if (!prevHealth || !nextHealth) return false;
|
||||
|
||||
return (
|
||||
prevHealth.activeTaskCount === nextHealth.activeTaskCount &&
|
||||
prevHealth.inFlightAgentCount === nextHealth.inFlightAgentCount &&
|
||||
prevHealth.totalTasksCompleted === nextHealth.totalTasksCompleted &&
|
||||
prevHealth.totalTasksFailed === nextHealth.totalTasksFailed &&
|
||||
prevHealth.status === nextHealth.status
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCardInner({
|
||||
project,
|
||||
health,
|
||||
onSelect,
|
||||
onPause,
|
||||
onResume,
|
||||
onRemove,
|
||||
isLoading = false,
|
||||
}: ProjectCardProps) {
|
||||
const statusConfig = STATUS_CONFIG[project.status];
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
onSelect(project);
|
||||
}, [onSelect, project]);
|
||||
|
||||
const handlePause = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onPause(project);
|
||||
}, [onPause, project]);
|
||||
|
||||
const handleResume = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onResume(project);
|
||||
}, [onResume, project]);
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onRemove(project);
|
||||
}, [onRemove, project]);
|
||||
|
||||
const isPaused = project.status === "paused";
|
||||
const isErrored = project.status === "errored";
|
||||
const isInitializing = project.status === "initializing";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`project-card ${isLoading ? "project-card-loading" : ""} ${isErrored ? "project-card-errored" : ""}`}
|
||||
onClick={handleSelect}
|
||||
data-project-id={project.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="project-card-header">
|
||||
<div className="project-card-icon">
|
||||
<Folder size={20} />
|
||||
</div>
|
||||
<div className="project-card-title-section">
|
||||
<h3 className="project-card-name" title={project.name}>
|
||||
{project.name}
|
||||
</h3>
|
||||
<span className="project-card-path" title={project.path}>
|
||||
{truncatePath(project.path)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="project-card-status-badge"
|
||||
style={{ color: statusConfig.color, borderColor: statusConfig.color }}
|
||||
>
|
||||
<StatusIcon size={12} className={isInitializing ? "animate-spin" : ""} />
|
||||
<span>{statusConfig.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="project-card-health">
|
||||
{health && (
|
||||
<>
|
||||
<div className="project-card-metric">
|
||||
<span className="project-card-metric-value">{health.activeTaskCount}</span>
|
||||
<span className="project-card-metric-label">Active Tasks</span>
|
||||
</div>
|
||||
<div className="project-card-metric">
|
||||
<span className="project-card-metric-value">{health.inFlightAgentCount}</span>
|
||||
<span className="project-card-metric-label">Agents</span>
|
||||
</div>
|
||||
<div className="project-card-metric">
|
||||
<span className="project-card-metric-value">{health.totalTasksCompleted}</span>
|
||||
<span className="project-card-metric-label">Completed</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!health && (
|
||||
<div className="project-card-metric project-card-metric-empty">
|
||||
<span className="project-card-metric-label">No health data available</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="project-card-footer">
|
||||
<div className="project-card-activity">
|
||||
<span className="project-card-activity-label">Last activity:</span>
|
||||
<span className="project-card-activity-time">
|
||||
{formatRelativeTime(project.lastActivityAt || health?.lastActivityAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="project-card-actions">
|
||||
{isPaused ? (
|
||||
<button
|
||||
className="project-card-action project-card-action-resume"
|
||||
onClick={handleResume}
|
||||
disabled={isLoading}
|
||||
title="Resume project"
|
||||
aria-label="Resume project"
|
||||
>
|
||||
<Play size={14} />
|
||||
<span>Resume</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="project-card-action project-card-action-pause"
|
||||
onClick={handlePause}
|
||||
disabled={isLoading || isInitializing}
|
||||
title={isInitializing ? "Cannot pause while initializing" : "Pause project"}
|
||||
aria-label="Pause project"
|
||||
>
|
||||
<Pause size={14} />
|
||||
<span>Pause</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="project-card-action project-card-action-open"
|
||||
onClick={handleSelect}
|
||||
disabled={isLoading}
|
||||
title="Open project"
|
||||
aria-label="Open project"
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
<span>Open</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="project-card-action project-card-action-remove"
|
||||
onClick={handleRemove}
|
||||
disabled={isLoading}
|
||||
title="Remove project"
|
||||
aria-label="Remove project"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ProjectCard = memo(ProjectCardInner, areProjectCardPropsEqual);
|
||||
446
packages/dashboard/app/components/SetupWizard.tsx
Normal file
446
packages/dashboard/app/components/SetupWizard.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { X, ChevronRight, ChevronLeft, Folder, Check, Loader2, AlertCircle } from "lucide-react";
|
||||
import type { ProjectInfo, ProjectCreateInput } from "../api";
|
||||
|
||||
export interface SetupWizardProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onProjectCreated: (project: ProjectInfo) => void;
|
||||
onRegisterProject?: (input: ProjectCreateInput) => Promise<ProjectInfo>;
|
||||
}
|
||||
|
||||
type WizardStep = "directory" | "name" | "isolation" | "validation" | "summary";
|
||||
|
||||
interface WizardState {
|
||||
step: WizardStep;
|
||||
directory: string;
|
||||
name: string;
|
||||
isolationMode: "in-process" | "child-process";
|
||||
isValidating: boolean;
|
||||
validationError: string | null;
|
||||
hasFusionDir: boolean | null;
|
||||
isCreating: boolean;
|
||||
createError: string | null;
|
||||
}
|
||||
|
||||
const STEP_ORDER: WizardStep[] = ["directory", "name", "isolation", "validation", "summary"];
|
||||
|
||||
function getStepIndex(step: WizardStep): number {
|
||||
return STEP_ORDER.indexOf(step);
|
||||
}
|
||||
|
||||
function isLastStep(step: WizardStep): boolean {
|
||||
return getStepIndex(step) === STEP_ORDER.length - 1;
|
||||
}
|
||||
|
||||
function isFirstStep(step: WizardStep): boolean {
|
||||
return getStepIndex(step) === 0;
|
||||
}
|
||||
|
||||
export function SetupWizard({ isOpen, onClose, onProjectCreated, onRegisterProject }: SetupWizardProps) {
|
||||
const [state, setState] = useState<WizardState>({
|
||||
step: "directory",
|
||||
directory: "",
|
||||
name: "",
|
||||
isolationMode: "in-process",
|
||||
isValidating: false,
|
||||
validationError: null,
|
||||
hasFusionDir: null,
|
||||
isCreating: false,
|
||||
createError: null,
|
||||
});
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setState({
|
||||
step: "directory",
|
||||
directory: "",
|
||||
name: "",
|
||||
isolationMode: "in-process",
|
||||
isValidating: false,
|
||||
validationError: null,
|
||||
hasFusionDir: null,
|
||||
isCreating: false,
|
||||
createError: null,
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-suggest name from directory
|
||||
useEffect(() => {
|
||||
if (state.directory && !state.name) {
|
||||
const basename = state.directory.split("/").pop() || state.directory.split("\\").pop() || "";
|
||||
setState((prev) => ({ ...prev, name: basename }));
|
||||
}
|
||||
}, [state.directory, state.name]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const currentIndex = getStepIndex(state.step);
|
||||
if (currentIndex < STEP_ORDER.length - 1) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: STEP_ORDER[currentIndex + 1],
|
||||
validationError: null,
|
||||
createError: null,
|
||||
}));
|
||||
}
|
||||
}, [state.step]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const currentIndex = getStepIndex(state.step);
|
||||
if (currentIndex > 0) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: STEP_ORDER[currentIndex - 1],
|
||||
validationError: null,
|
||||
createError: null,
|
||||
}));
|
||||
}
|
||||
}, [state.step]);
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isValidating: true, validationError: null }));
|
||||
|
||||
try {
|
||||
// Check if directory exists and has .fusion/ directory
|
||||
// In a real implementation, this would call an API endpoint
|
||||
// For now, we simulate the check
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Simulate validation - assume valid for now
|
||||
const hasFusionDir = true; // Would be determined by API call
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isValidating: false,
|
||||
hasFusionDir,
|
||||
step: "summary",
|
||||
}));
|
||||
} catch (err: any) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isValidating: false,
|
||||
validationError: err.message || "Validation failed",
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!onRegisterProject) {
|
||||
setState((prev) => ({ ...prev, createError: "Project registration not available" }));
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isCreating: true, createError: null }));
|
||||
|
||||
try {
|
||||
const input: ProjectCreateInput = {
|
||||
name: state.name,
|
||||
path: state.directory,
|
||||
isolationMode: state.isolationMode,
|
||||
};
|
||||
|
||||
const project = await onRegisterProject(input);
|
||||
onProjectCreated(project);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isCreating: false,
|
||||
createError: err.message || "Failed to create project",
|
||||
}));
|
||||
}
|
||||
}, [onRegisterProject, state.name, state.directory, state.isolationMode, onProjectCreated, onClose]);
|
||||
|
||||
const canProceed = () => {
|
||||
switch (state.step) {
|
||||
case "directory":
|
||||
return state.directory.trim().length > 0;
|
||||
case "name":
|
||||
return state.name.trim().length > 0;
|
||||
case "isolation":
|
||||
return true;
|
||||
case "validation":
|
||||
return !state.isValidating;
|
||||
case "summary":
|
||||
return !state.isCreating;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose}>
|
||||
<div className="modal modal-lg" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Add New Project</h3>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="wizard-progress">
|
||||
{STEP_ORDER.map((step, index) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`wizard-progress-step ${
|
||||
index <= getStepIndex(state.step) ? "active" : ""
|
||||
} ${step === state.step ? "current" : ""}`}
|
||||
>
|
||||
<span className="wizard-progress-number">{index + 1}</span>
|
||||
<span className="wizard-progress-label">
|
||||
{step === "directory" && "Directory"}
|
||||
{step === "name" && "Name"}
|
||||
{step === "isolation" && "Mode"}
|
||||
{step === "validation" && "Validate"}
|
||||
{step === "summary" && "Confirm"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="wizard-content">
|
||||
{/* Step 1: Directory Selection */}
|
||||
{state.step === "directory" && (
|
||||
<div className="wizard-step">
|
||||
<h4>Select Project Directory</h4>
|
||||
<p className="wizard-description">
|
||||
Enter the absolute path to your project directory. This should be the root folder
|
||||
containing your project files.
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="project-directory">
|
||||
Directory Path <span className="required">*</span>
|
||||
</label>
|
||||
<div className="wizard-input-group">
|
||||
<Folder size={16} className="wizard-input-icon" />
|
||||
<input
|
||||
id="project-directory"
|
||||
type="text"
|
||||
value={state.directory}
|
||||
onChange={(e) =>
|
||||
setState((prev) => ({ ...prev, directory: e.target.value }))
|
||||
}
|
||||
placeholder="/path/to/your/project"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wizard-hint">
|
||||
<AlertCircle size={14} />
|
||||
<span>
|
||||
The directory must contain a <code>.fusion/</code> folder. If it doesn't exist,
|
||||
you can initialize it in the next step.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Project Name */}
|
||||
{state.step === "name" && (
|
||||
<div className="wizard-step">
|
||||
<h4>Project Name</h4>
|
||||
<p className="wizard-description">
|
||||
Give your project a display name. This will be shown in the dashboard.
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="project-name">
|
||||
Name <span className="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
value={state.name}
|
||||
onChange={(e) => setState((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="My Project"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="wizard-hint">
|
||||
<Check size={14} />
|
||||
<span>Suggested from directory name. You can change it if needed.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Isolation Mode */}
|
||||
{state.step === "isolation" && (
|
||||
<div className="wizard-step">
|
||||
<h4>Execution Mode</h4>
|
||||
<p className="wizard-description">
|
||||
Choose how tasks should be executed for this project.
|
||||
</p>
|
||||
<div className="wizard-options">
|
||||
<label
|
||||
className={`wizard-option ${
|
||||
state.isolationMode === "in-process" ? "selected" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="isolation-mode"
|
||||
value="in-process"
|
||||
checked={state.isolationMode === "in-process"}
|
||||
onChange={() =>
|
||||
setState((prev) => ({ ...prev, isolationMode: "in-process" }))
|
||||
}
|
||||
/>
|
||||
<div className="wizard-option-content">
|
||||
<strong>In-Process (Default)</strong>
|
||||
<span>Fast, low overhead. Tasks run in the main process.</span>
|
||||
<span className="wizard-option-recommended">Recommended for most projects</span>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
className={`wizard-option ${
|
||||
state.isolationMode === "child-process" ? "selected" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="isolation-mode"
|
||||
value="child-process"
|
||||
checked={state.isolationMode === "child-process"}
|
||||
onChange={() =>
|
||||
setState((prev) => ({ ...prev, isolationMode: "child-process" }))
|
||||
}
|
||||
/>
|
||||
<div className="wizard-option-content">
|
||||
<strong>Child Process (Isolated)</strong>
|
||||
<span>Strong isolation. Tasks run in separate processes.</span>
|
||||
<span className="wizard-option-note">Higher overhead, crash containment</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Validation */}
|
||||
{state.step === "validation" && (
|
||||
<div className="wizard-step">
|
||||
<h4>Validation</h4>
|
||||
<p className="wizard-description">
|
||||
We're checking the project directory and preparing it for use.
|
||||
</p>
|
||||
{state.isValidating ? (
|
||||
<div className="wizard-loading">
|
||||
<Loader2 size={32} className="animate-spin" />
|
||||
<span>Validating project directory...</span>
|
||||
</div>
|
||||
) : state.validationError ? (
|
||||
<div className="wizard-error">
|
||||
<AlertCircle size={24} />
|
||||
<span>{state.validationError}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="wizard-validation-success">
|
||||
<Check size={32} />
|
||||
<span>Project directory is valid!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: Summary */}
|
||||
{state.step === "summary" && (
|
||||
<div className="wizard-step">
|
||||
<h4>Summary</h4>
|
||||
<p className="wizard-description">
|
||||
Review your project settings before creating.
|
||||
</p>
|
||||
<div className="wizard-summary">
|
||||
<div className="wizard-summary-row">
|
||||
<span className="wizard-summary-label">Name:</span>
|
||||
<span className="wizard-summary-value">{state.name}</span>
|
||||
</div>
|
||||
<div className="wizard-summary-row">
|
||||
<span className="wizard-summary-label">Directory:</span>
|
||||
<span className="wizard-summary-value" title={state.directory}>
|
||||
{state.directory}
|
||||
</span>
|
||||
</div>
|
||||
<div className="wizard-summary-row">
|
||||
<span className="wizard-summary-label">Execution Mode:</span>
|
||||
<span className="wizard-summary-value">
|
||||
{state.isolationMode === "in-process" ? "In-Process" : "Child Process (Isolated)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{state.createError && (
|
||||
<div className="wizard-error">
|
||||
<AlertCircle size={20} />
|
||||
<span>{state.createError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<div className="modal-actions-left">
|
||||
{!isFirstStep(state.step) && (
|
||||
<button className="btn btn-secondary" onClick={handleBack} disabled={state.isCreating}>
|
||||
<ChevronLeft size={16} />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions-right">
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={state.isCreating}>
|
||||
Cancel
|
||||
</button>
|
||||
{state.step === "validation" ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleValidate}
|
||||
disabled={state.isValidating || state.validationError}
|
||||
>
|
||||
{state.isValidating ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Validating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Validate
|
||||
<ChevronRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : isLastStep(state.step) ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreate}
|
||||
disabled={state.isCreating || !canProceed()}
|
||||
>
|
||||
{state.isCreating ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Create Project
|
||||
<Check size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ActivityFeed } from "../ActivityFeed";
|
||||
import type { ActivityFeedEntry } from "../../api";
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
GitPullRequest: () => <span data-testid="pr-icon">PR</span>,
|
||||
GitMerge: () => <span data-testid="merge-icon">Merge</span>,
|
||||
CheckCircle: () => <span data-testid="check-icon">✓</span>,
|
||||
XCircle: () => <span data-testid="x-icon">✗</span>,
|
||||
Plus: () => <span data-testid="plus-icon">+</span>,
|
||||
ArrowRightLeft: () => <span data-testid="arrow-icon">→</span>,
|
||||
Settings: () => <span data-testid="settings-icon">⚙</span>,
|
||||
AlertTriangle: () => <span data-testid="alert-icon">⚠</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
};
|
||||
});
|
||||
|
||||
function makeEntry(overrides: Partial<ActivityFeedEntry> = {}): ActivityFeedEntry {
|
||||
return {
|
||||
id: "entry_001",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "task:created",
|
||||
projectId: "proj_abc123",
|
||||
projectName: "Test Project",
|
||||
details: "Task created",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ActivityFeed", () => {
|
||||
it("renders empty state when no entries", () => {
|
||||
render(<ActivityFeed entries={[]} />);
|
||||
|
||||
expect(screen.getByText("No recent activity")).toBeDefined();
|
||||
expect(screen.getByText(/Activity will appear here/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders custom empty message", () => {
|
||||
render(<ActivityFeed entries={[]} emptyMessage="Custom empty message" />);
|
||||
|
||||
expect(screen.getByText("Custom empty message")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders loading state", () => {
|
||||
const { container } = render(<ActivityFeed entries={[]} isLoading={true} />);
|
||||
|
||||
expect(container.querySelector(".activity-feed-loading")).toBeDefined();
|
||||
expect(container.querySelector(".activity-feed-skeleton")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders error state", () => {
|
||||
render(<ActivityFeed entries={[]} error="Failed to load activity" />);
|
||||
|
||||
expect(screen.getByText("Failed to load activity")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders activity entries", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ id: "entry_001", type: "task:created", details: "Created FN-001" }),
|
||||
makeEntry({ id: "entry_002", type: "task:moved", details: "Moved FN-002 to in-progress" }),
|
||||
];
|
||||
|
||||
render(<ActivityFeed entries={entries} />);
|
||||
|
||||
expect(screen.getByText("Created")).toBeDefined();
|
||||
expect(screen.getByText("Moved")).toBeDefined();
|
||||
expect(screen.getByText("Created FN-001")).toBeDefined();
|
||||
expect(screen.getByText("Moved FN-002 to in-progress")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows project names when provided", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ projectId: "proj_abc123", projectName: "Project Alpha" }),
|
||||
];
|
||||
|
||||
render(
|
||||
<ActivityFeed
|
||||
entries={entries}
|
||||
projectNames={{ "proj_abc123": "Project Alpha" }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Project Alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays task ID when available", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ taskId: "FN-042", taskTitle: "Fix bug" }),
|
||||
];
|
||||
|
||||
render(<ActivityFeed entries={entries} />);
|
||||
|
||||
expect(screen.getByText("FN-042")).toBeDefined();
|
||||
expect(screen.getByText("Fix bug")).toBeDefined();
|
||||
});
|
||||
|
||||
it("groups entries by date", () => {
|
||||
const today = new Date().toISOString();
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString();
|
||||
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ id: "entry_001", timestamp: today }),
|
||||
makeEntry({ id: "entry_002", timestamp: yesterday }),
|
||||
];
|
||||
|
||||
const { container } = render(<ActivityFeed entries={entries} />);
|
||||
|
||||
const groups = container.querySelectorAll(".activity-feed-group");
|
||||
expect(groups.length).toBe(2);
|
||||
});
|
||||
|
||||
it("shows relative time for recent entries", () => {
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60000).toISOString();
|
||||
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ timestamp: fiveMinutesAgo }),
|
||||
];
|
||||
|
||||
render(<ActivityFeed entries={entries} />);
|
||||
|
||||
expect(screen.getByText("5m ago")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders different event types with correct labels", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ id: "1", type: "task:created" }),
|
||||
makeEntry({ id: "2", type: "task:moved" }),
|
||||
makeEntry({ id: "3", type: "task:updated" }),
|
||||
makeEntry({ id: "4", type: "task:deleted" }),
|
||||
makeEntry({ id: "5", type: "task:merged" }),
|
||||
makeEntry({ id: "6", type: "task:failed" }),
|
||||
makeEntry({ id: "7", type: "settings:updated" }),
|
||||
];
|
||||
|
||||
render(<ActivityFeed entries={entries} />);
|
||||
|
||||
expect(screen.getByText("Created")).toBeDefined();
|
||||
expect(screen.getByText("Moved")).toBeDefined();
|
||||
expect(screen.getByText("Updated")).toBeDefined();
|
||||
expect(screen.getByText("Deleted")).toBeDefined();
|
||||
expect(screen.getByText("Merged")).toBeDefined();
|
||||
expect(screen.getByText("Failed")).toBeDefined();
|
||||
expect(screen.getByText("Settings")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders with data-type attribute for styling", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ type: "task:created" }),
|
||||
];
|
||||
|
||||
const { container } = render(<ActivityFeed entries={entries} />);
|
||||
|
||||
const item = container.querySelector('[data-type="task:created"]');
|
||||
expect(item).toBeDefined();
|
||||
});
|
||||
|
||||
it("truncates long task titles", () => {
|
||||
const longTitle = "A".repeat(200);
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ taskTitle: longTitle }),
|
||||
];
|
||||
|
||||
const { container } = render(<ActivityFeed entries={entries} />);
|
||||
|
||||
const titleEl = container.querySelector(".activity-feed-task-title");
|
||||
expect(titleEl).toBeDefined();
|
||||
expect(titleEl?.getAttribute("title")).toBe(longTitle);
|
||||
});
|
||||
|
||||
it("shows full timestamp on hover via title attribute", () => {
|
||||
// Use a recent timestamp so it shows "ago" format
|
||||
const recentTime = new Date(Date.now() - 5 * 60000).toISOString();
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ timestamp: recentTime }),
|
||||
];
|
||||
|
||||
render(<ActivityFeed entries={entries} />);
|
||||
|
||||
// Should show relative time like "5m ago"
|
||||
const timeEl = screen.getByText(/ago/);
|
||||
expect(timeEl).toBeDefined();
|
||||
// Title attribute should have full timestamp
|
||||
expect(timeEl.getAttribute("title")).toContain(":");
|
||||
});
|
||||
});
|
||||
432
packages/dashboard/app/components/__tests__/ProjectCard.test.tsx
Normal file
432
packages/dashboard/app/components/__tests__/ProjectCard.test.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ProjectCard } from "../ProjectCard";
|
||||
import type { RegisteredProject, ProjectHealth } from "@fusion/core";
|
||||
|
||||
// Mock lucide-react to avoid SVG rendering issues in test env
|
||||
vi.mock("lucide-react", () => ({
|
||||
Play: () => <span data-testid="play-icon">▶</span>,
|
||||
Pause: () => <span data-testid="pause-icon">⏸</span>,
|
||||
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
||||
Loader2: () => <span data-testid="loader-icon">⟳</span>,
|
||||
MoreHorizontal: () => null,
|
||||
Trash2: () => <span data-testid="trash-icon">🗑</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
ArrowRight: () => <span data-testid="arrow-icon">→</span>,
|
||||
}));
|
||||
|
||||
function makeProject(overrides: Partial<RegisteredProject> = {}): RegisteredProject {
|
||||
return {
|
||||
id: "proj_abc123",
|
||||
name: "Test Project",
|
||||
path: "/home/user/projects/test",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHealth(overrides: Partial<ProjectHealth> = {}): ProjectHealth {
|
||||
return {
|
||||
projectId: "proj_abc123",
|
||||
status: "active",
|
||||
activeTaskCount: 5,
|
||||
inFlightAgentCount: 2,
|
||||
totalTasksCompleted: 100,
|
||||
totalTasksFailed: 3,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("ProjectCard", () => {
|
||||
it("renders project name and path", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ name: "My Project", path: "/path/to/project" })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("My Project")).toBeDefined();
|
||||
expect(screen.getByText("/path/to/project")).toBeDefined();
|
||||
});
|
||||
|
||||
it("truncates long paths", () => {
|
||||
const longPath = "/very/long/path/to/the/project/directory/that/needs/truncation";
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ path: longPath })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show truncated version
|
||||
const pathElement = screen.getByText(/\/very\/long\/.*\/truncation/);
|
||||
expect(pathElement).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders active status badge", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "active" })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Active")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders paused status badge", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "paused" })}
|
||||
health={makeHealth({ status: "paused" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Paused")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders errored status badge", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "errored" })}
|
||||
health={makeHealth({ status: "errored" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Error")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders initializing status badge with spinner", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "initializing" })}
|
||||
health={makeHealth({ status: "initializing" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Initializing")).toBeDefined();
|
||||
expect(screen.getByTestId("loader-icon")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays health metrics", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject()}
|
||||
health={makeHealth({
|
||||
activeTaskCount: 10,
|
||||
inFlightAgentCount: 3,
|
||||
totalTasksCompleted: 250,
|
||||
})}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("10")).toBeDefined();
|
||||
expect(screen.getByText("Active Tasks")).toBeDefined();
|
||||
expect(screen.getByText("3")).toBeDefined();
|
||||
expect(screen.getByText("Agents")).toBeDefined();
|
||||
expect(screen.getByText("250")).toBeDefined();
|
||||
expect(screen.getByText("Completed")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'No health data' when health is null", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject()}
|
||||
health={null}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("No health data available")).toBeDefined();
|
||||
});
|
||||
|
||||
it("formats relative time for last activity", () => {
|
||||
const recentTime = new Date(Date.now() - 5 * 60000).toISOString(); // 5 minutes ago
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ lastActivityAt: recentTime })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("5m ago")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Never' when no last activity", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ lastActivityAt: undefined })}
|
||||
health={makeHealth({ lastActivityAt: undefined })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Never")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onSelect when card is clicked", () => {
|
||||
const onSelect = vi.fn();
|
||||
const project = makeProject();
|
||||
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={project}
|
||||
health={makeHealth()}
|
||||
onSelect={onSelect}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = container.querySelector('[data-project-id="proj_abc123"]');
|
||||
expect(card).not.toBeNull();
|
||||
fireEvent.click(card!);
|
||||
expect(onSelect).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("calls onSelect when Enter key is pressed", () => {
|
||||
const onSelect = vi.fn();
|
||||
const project = makeProject();
|
||||
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={project}
|
||||
health={makeHealth()}
|
||||
onSelect={onSelect}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = container.querySelector('[data-project-id="proj_abc123"]');
|
||||
expect(card).not.toBeNull();
|
||||
fireEvent.keyDown(card!, { key: "Enter" });
|
||||
expect(onSelect).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("calls onPause when pause button is clicked", () => {
|
||||
const onPause = vi.fn();
|
||||
const project = makeProject({ status: "active" });
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={project}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={onPause}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Pause project"));
|
||||
expect(onPause).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("calls onResume when resume button is clicked", () => {
|
||||
const onResume = vi.fn();
|
||||
const project = makeProject({ status: "paused" });
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={project}
|
||||
health={makeHealth({ status: "paused" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={onResume}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Resume project"));
|
||||
expect(onResume).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("calls onRemove when remove button is clicked", () => {
|
||||
const onRemove = vi.fn();
|
||||
const project = makeProject();
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={project}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Remove project"));
|
||||
expect(onRemove).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("disables pause button when initializing", () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "initializing" })}
|
||||
health={makeHealth({ status: "initializing" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the pause button by its title attribute
|
||||
const pauseButton = container.querySelector('button[title="Cannot pause while initializing"]');
|
||||
expect(pauseButton).not.toBeNull();
|
||||
expect(pauseButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables all buttons when isLoading is true", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "active" })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Pause project")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Open project")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Remove project")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("adds loading class when isLoading is true", () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={makeProject()}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector(".project-card-loading")).toBeDefined();
|
||||
});
|
||||
|
||||
it("adds errored class when project status is errored", () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "errored" })}
|
||||
health={makeHealth({ status: "errored" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector(".project-card-errored")).toBeDefined();
|
||||
});
|
||||
|
||||
it("prevents event bubbling when clicking action buttons", () => {
|
||||
const onSelect = vi.fn();
|
||||
const onPause = vi.fn();
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "active" })}
|
||||
health={makeHealth()}
|
||||
onSelect={onSelect}
|
||||
onPause={onPause}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Pause project"));
|
||||
expect(onPause).toHaveBeenCalled();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders with data-project-id attribute", () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={makeProject({ id: "proj_test123" })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = container.querySelector("[data-project-id='proj_test123']");
|
||||
expect(card).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses health lastActivityAt as fallback when project lastActivityAt is undefined", () => {
|
||||
const healthTime = "2026-01-15T12:00:00.000Z";
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ lastActivityAt: undefined })}
|
||||
health={makeHealth({ lastActivityAt: healthTime })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show the health's lastActivityAt formatted (shows as date since it's > 7 days ago)
|
||||
expect(screen.getByText(/Last activity:/)).toBeDefined();
|
||||
// The formatted date should show (1/15/2026 format in US locale)
|
||||
expect(screen.getByText(/1\//)).toBeDefined();
|
||||
});
|
||||
});
|
||||
284
packages/dashboard/app/components/__tests__/SetupWizard.test.tsx
Normal file
284
packages/dashboard/app/components/__tests__/SetupWizard.test.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SetupWizard } from "../SetupWizard";
|
||||
import type { ProjectInfo, ProjectCreateInput } from "../../api";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
X: () => <span data-testid="close-icon">×</span>,
|
||||
ChevronRight: () => <span data-testid="next-icon">→</span>,
|
||||
ChevronLeft: () => <span data-testid="back-icon">←</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
Check: () => <span data-testid="check-icon">✓</span>,
|
||||
Loader2: () => <span data-testid="loader-icon">⟳</span>,
|
||||
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("SetupWizard", () => {
|
||||
it("does not render when isOpen is false", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Add New Project")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders when isOpen is true", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Add New Project")).toBeDefined();
|
||||
});
|
||||
|
||||
it("starts at directory step", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows step indicator with 5 steps", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Directory")).toBeDefined();
|
||||
expect(screen.getByText("Name")).toBeDefined();
|
||||
expect(screen.getByText("Mode")).toBeDefined();
|
||||
expect(screen.getByText("Validate")).toBeDefined();
|
||||
expect(screen.getByText("Confirm")).toBeDefined();
|
||||
});
|
||||
|
||||
it("disables Next button when directory is empty", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables Next button when directory is filled", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("navigates to next step when Next is clicked", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
// Find the primary button (Next) in the actions area
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
expect(screen.getByText("Project Name")).toBeDefined();
|
||||
});
|
||||
|
||||
it("auto-suggests name from directory path", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/my-awesome-project" } });
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("My Project") as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("my-awesome-project");
|
||||
});
|
||||
|
||||
it("allows navigation back to previous step", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Go to step 2
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
// Go back
|
||||
const backButton = screen.getByRole("button", { name: /Back/i });
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows isolation mode options", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Navigate to step 3 (isolation)
|
||||
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
|
||||
|
||||
// Go to name step
|
||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
||||
|
||||
// Go to isolation step
|
||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
||||
|
||||
expect(screen.getByText("In-Process (Default)")).toBeDefined();
|
||||
expect(screen.getByText("Child Process (Isolated)")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onClose when Cancel is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: /Cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when close icon is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits project data when created", async () => {
|
||||
const mockRegisterProject = vi.fn().mockResolvedValue({
|
||||
id: "proj_123",
|
||||
name: "My Project",
|
||||
path: "/home/user/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
} as ProjectInfo);
|
||||
|
||||
const onProjectCreated = vi.fn();
|
||||
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={onProjectCreated}
|
||||
onRegisterProject={mockRegisterProject}
|
||||
/>
|
||||
);
|
||||
|
||||
// Fill directory
|
||||
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
|
||||
|
||||
// The wizard should be in directory step with a Next button
|
||||
expect(screen.getByRole("button", { name: /Next/i })).toBeDefined();
|
||||
|
||||
// Note: Full wizard flow testing would require more complex setup
|
||||
// including mocking the validation API call
|
||||
});
|
||||
|
||||
it("resets state when reopened", () => {
|
||||
const { rerender } = render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Fill some data
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
// Close and reopen
|
||||
rerender(
|
||||
<SetupWizard
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
rerender(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should be back at step 1 with empty fields
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
const newInput = screen.getByPlaceholderText("/path/to/your/project") as HTMLInputElement;
|
||||
expect(newInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -903,6 +903,753 @@ body {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* === ProjectCard Component === */
|
||||
.project-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.project-card-loading {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.project-card-errored {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 0 1px var(--color-error);
|
||||
}
|
||||
|
||||
.project-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.project-card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--todo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-card-title-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-card-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-card-path {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-card-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-card-health {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-md) 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-card-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-card-metric-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.project-card-metric-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.project-card-metric-empty {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.project-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.project-card-activity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-card-activity-label {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-card-activity-time {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.project-card-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-card-action:hover:not(:disabled) {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-card-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.project-card-action-resume {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.project-card-action-resume:hover:not(:disabled) {
|
||||
background: rgba(63, 185, 80, 0.1);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.project-card-action-pause {
|
||||
color: var(--warning, #e3b341);
|
||||
}
|
||||
|
||||
.project-card-action-pause:hover:not(:disabled) {
|
||||
background: rgba(227, 179, 65, 0.1);
|
||||
border-color: var(--warning, #e3b341);
|
||||
}
|
||||
|
||||
.project-card-action-open {
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.project-card-action-open:hover:not(:disabled) {
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
border-color: var(--todo);
|
||||
}
|
||||
|
||||
.project-card-action-remove {
|
||||
padding: 6px;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.project-card-action-remove:hover:not(:disabled) {
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Animation for initializing spinner */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* === ActivityFeed Component === */
|
||||
.activity-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.activity-feed-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.activity-feed-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: var(--space-sm);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.activity-feed-group-date {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.activity-feed-group-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--card);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.activity-feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.activity-feed-item {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.activity-feed-item:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.activity-feed-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-feed-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-feed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.activity-feed-type {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.activity-feed-project-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--todo);
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.activity-feed-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.activity-feed-task-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.activity-feed-task-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-feed-description {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.activity-feed-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.activity-feed-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Activity Feed States */
|
||||
.activity-feed-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.activity-feed-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.activity-feed-skeleton-item {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.activity-feed-skeleton-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.activity-feed-skeleton-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.activity-feed-skeleton-line {
|
||||
height: 12px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.activity-feed-skeleton-line.short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.activity-feed-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.activity-feed-error-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
color: var(--error);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.activity-feed-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.activity-feed-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.activity-feed-empty-state p {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.activity-feed-empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* === SetupWizard Component === */
|
||||
.wizard-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.wizard-progress-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.wizard-progress-step.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wizard-progress-step.current .wizard-progress-number {
|
||||
background: var(--todo);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.wizard-progress-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.wizard-progress-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wizard-content {
|
||||
padding: var(--space-xl);
|
||||
min-height: 280px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
animation: fadeIn var(--transition-normal);
|
||||
}
|
||||
|
||||
.wizard-step h4 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-description {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-input-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wizard-input-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wizard-input-group input {
|
||||
padding-left: 40px !important;
|
||||
}
|
||||
|
||||
.wizard-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wizard-hint svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wizard-hint code {
|
||||
background: var(--card);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wizard-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.wizard-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.wizard-option:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.wizard-option.selected {
|
||||
background: rgba(88, 166, 255, 0.05);
|
||||
border-color: var(--todo);
|
||||
box-shadow: 0 0 0 1px var(--todo);
|
||||
}
|
||||
|
||||
.wizard-option input[type="radio"] {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wizard-option-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wizard-option-content strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wizard-option-content span {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wizard-option-recommended {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 2px 8px;
|
||||
background: rgba(63, 185, 80, 0.1);
|
||||
color: var(--color-success) !important;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px !important;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wizard-option-note {
|
||||
font-size: 11px !important;
|
||||
color: var(--text-dim) !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wizard-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-2xl);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wizard-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border: 1px solid var(--error);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--error);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wizard-validation-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-2xl);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.wizard-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.wizard-summary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.wizard-summary-label {
|
||||
width: 120px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wizard-summary-value {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Modals === */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
|
||||
@@ -5806,6 +5806,267 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
// Mount mission routes at /api/missions
|
||||
router.use("/missions", createMissionRouter(store));
|
||||
|
||||
// ── Project Management Routes (Multi-Project Support) ───────────────────────
|
||||
// These routes require CentralCore which is imported dynamically to avoid
|
||||
// circular dependencies and ensure the central database is initialized.
|
||||
|
||||
/**
|
||||
* GET /api/projects
|
||||
* List all registered projects with their basic info.
|
||||
* Returns: ProjectInfo[]
|
||||
*/
|
||||
router.get("/projects", async (_req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const projects = await central.listProjects();
|
||||
await central.close();
|
||||
|
||||
res.json(projects);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/projects
|
||||
* Register a new project.
|
||||
* Body: { name: string, path: string, isolationMode?: "in-process" | "child-process" }
|
||||
* Returns: RegisteredProject
|
||||
*/
|
||||
router.post("/projects", async (req, res) => {
|
||||
try {
|
||||
const { name, path, isolationMode = "in-process" } = req.body;
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "name is required and must be a non-empty string" });
|
||||
return;
|
||||
}
|
||||
if (!path || typeof path !== "string" || !path.trim()) {
|
||||
res.status(400).json({ error: "path is required and must be a non-empty string" });
|
||||
return;
|
||||
}
|
||||
if (!["in-process", "child-process"].includes(isolationMode)) {
|
||||
res.status(400).json({ error: "isolationMode must be 'in-process' or 'child-process'" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if path exists and has .fusion/ directory
|
||||
const { existsSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
if (!existsSync(path)) {
|
||||
res.status(400).json({ error: "Project path does not exist" });
|
||||
return;
|
||||
}
|
||||
const hasFusionDir = existsSync(join(path, ".fusion"));
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const project = await central.registerProject({
|
||||
name: name.trim(),
|
||||
path: path.trim(),
|
||||
isolationMode,
|
||||
});
|
||||
|
||||
await central.close();
|
||||
|
||||
res.status(201).json({ ...project, _meta: { hasFusionDir: hasFusionDir ? undefined : false } });
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("already registered") ? 409
|
||||
: err.message?.includes("Duplicate path") ? 409
|
||||
: 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/projects/:id
|
||||
* Unregister a project.
|
||||
*/
|
||||
router.delete("/projects/:id", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
await central.unregisterProject(req.params.id);
|
||||
await central.close();
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("not found") ? 404 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/projects/:id/health
|
||||
* Get health metrics for a specific project.
|
||||
* Returns: ProjectHealth
|
||||
*/
|
||||
router.get("/projects/:id/health", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const health = await central.getProjectHealth(req.params.id);
|
||||
await central.close();
|
||||
|
||||
if (!health) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(health);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/projects/:id/config
|
||||
* Get project-specific configuration.
|
||||
* Returns: { maxConcurrent: number, rootDir: string }
|
||||
*/
|
||||
router.get("/projects/:id/config", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const project = await central.getProject(req.params.id);
|
||||
await central.close();
|
||||
|
||||
if (!project) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
maxConcurrent: 2,
|
||||
rootDir: project.path,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/projects/:id/pause
|
||||
* Pause a project.
|
||||
*/
|
||||
router.post("/projects/:id/pause", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const project = await central.updateProject(req.params.id, { status: "paused" });
|
||||
await central.updateProjectHealth(req.params.id, { status: "paused" });
|
||||
await central.close();
|
||||
|
||||
res.json(project);
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("not found") ? 404 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/projects/:id/resume
|
||||
* Resume a paused project.
|
||||
*/
|
||||
router.post("/projects/:id/resume", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const project = await central.updateProject(req.params.id, { status: "active" });
|
||||
await central.updateProjectHealth(req.params.id, { status: "active" });
|
||||
await central.close();
|
||||
|
||||
res.json(project);
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("not found") ? 404 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/activity-feed
|
||||
* Get unified activity feed across all projects.
|
||||
* Query: limit, projectId, types
|
||||
* Returns: ActivityFeedEntry[]
|
||||
*/
|
||||
router.get("/activity-feed", async (req, res) => {
|
||||
try {
|
||||
const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
|
||||
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
||||
const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined;
|
||||
const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined;
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const entries = await central.getRecentActivity({ limit, projectId, types });
|
||||
await central.close();
|
||||
|
||||
res.json(entries);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/global-concurrency
|
||||
* Get global concurrency state across all projects.
|
||||
* Returns: GlobalConcurrencyState
|
||||
*/
|
||||
router.get("/global-concurrency", async (_req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const state = await central.getGlobalConcurrencyState();
|
||||
await central.close();
|
||||
|
||||
res.json(state);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/first-run-status
|
||||
* Check if user has projects or needs setup wizard.
|
||||
* Returns: { hasProjects: boolean, singleProjectPath: string | null }
|
||||
*/
|
||||
router.get("/first-run-status", async (_req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const projects = await central.listProjects();
|
||||
await central.close();
|
||||
|
||||
const hasProjects = projects.length > 0;
|
||||
const singleProjectPath = projects.length === 1 ? projects[0].path : null;
|
||||
|
||||
res.json({ hasProjects, singleProjectPath });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user