feat(KB-502): add dashboard multi-project hooks and project API routes
- Add project API routes for multi-project backend integration - Create useProjects hook for listing and managing registered projects - Create useCurrentProject hook for current project selection and switching - Create useProjectHealth hook for real-time project health metrics - Create useActivityLog hook with ActivityLogModal integration - Add comprehensive test coverage for all hooks (useProjects, useCurrentProject, useActivityLog) - Add test setup utilities for React Query mocking
This commit is contained in:
@@ -1845,6 +1845,37 @@ export function resumeProject(id: string): Promise<ProjectInfo> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a specific project by ID */
|
||||
export function fetchProject(id: string): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** Update a project */
|
||||
export function updateProject(
|
||||
id: string,
|
||||
updates: { name?: string; isolationMode?: "in-process" | "child-process"; status?: "active" | "paused" | "errored" | "initializing" }
|
||||
): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Detected project from auto-scan */
|
||||
export interface DetectedProject {
|
||||
path: string;
|
||||
suggestedName: string;
|
||||
existing: boolean;
|
||||
}
|
||||
|
||||
/** Auto-detect kb projects in a given base path */
|
||||
export function detectProjects(basePath?: string): Promise<{ projects: DetectedProject[] }> {
|
||||
return api<{ projects: DetectedProject[] }>("/projects/detect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ basePath }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch first run status to detect if user needs setup wizard */
|
||||
export function fetchFirstRunStatus(): Promise<FirstRunStatus> {
|
||||
return api<FirstRunStatus>("/first-run-status");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { fetchActivityLog, clearActivityLog, type ActivityLogEntry, type ActivityEventType } from "../api";
|
||||
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType } from "../api";
|
||||
import { useActivityLog } from "../hooks/useActivityLog";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
interface ActivityLogModalProps {
|
||||
@@ -8,6 +9,8 @@ interface ActivityLogModalProps {
|
||||
onClose: () => void;
|
||||
tasks: Task[];
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
/** When provided, shows only activity for this project */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
|
||||
@@ -46,78 +49,45 @@ function formatTimestamp(timestamp: string): string {
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: ActivityLogModalProps) {
|
||||
const [entries, setEntries] = useState<ActivityLogEntry[]>([]);
|
||||
export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail, projectId }: ActivityLogModalProps) {
|
||||
const [filteredType, setFilteredType] = useState<ActivityEventType | "all">("all");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirmClear, setShowConfirmClear] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Convert filteredType to the format expected by useActivityLog
|
||||
const activityType = filteredType === "all" ? undefined : filteredType;
|
||||
|
||||
// Use the new hook for data fetching
|
||||
const {
|
||||
entries,
|
||||
loading: isLoading,
|
||||
error,
|
||||
refresh,
|
||||
hasMore
|
||||
} = useActivityLog({
|
||||
projectId,
|
||||
type: activityType,
|
||||
limit: 100,
|
||||
autoRefresh: isOpen, // Only poll when modal is open
|
||||
});
|
||||
|
||||
const loadActivityLog = useCallback(async (since?: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const options: { limit: number; since?: string; type?: ActivityEventType } = {
|
||||
limit: 100,
|
||||
since,
|
||||
};
|
||||
if (filteredType !== "all") {
|
||||
options.type = filteredType;
|
||||
}
|
||||
const data = await fetchActivityLog(options);
|
||||
if (since) {
|
||||
// Append older entries
|
||||
setEntries((prev) => [...prev, ...data]);
|
||||
} else {
|
||||
// Replace with fresh entries
|
||||
setEntries(data);
|
||||
}
|
||||
setHasMore(data.length === 100);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load activity log");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filteredType]);
|
||||
|
||||
// Initial load and filter change
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadActivityLog();
|
||||
}
|
||||
}, [isOpen, loadActivityLog]);
|
||||
|
||||
// Auto-refresh every 30 seconds when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
pollingRef.current = setInterval(() => {
|
||||
loadActivityLog();
|
||||
}, 30000);
|
||||
}
|
||||
return () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, loadActivityLog]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (entries.length > 0) {
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
loadActivityLog(lastEntry.timestamp);
|
||||
}
|
||||
};
|
||||
// Convert entries to ActivityLogEntry format for compatibility
|
||||
const convertedEntries: ActivityLogEntry[] = entries.map(entry => ({
|
||||
id: entry.id,
|
||||
timestamp: entry.timestamp,
|
||||
type: entry.type,
|
||||
taskId: entry.taskId,
|
||||
taskTitle: entry.taskTitle,
|
||||
details: entry.details,
|
||||
metadata: entry.metadata,
|
||||
}));
|
||||
|
||||
const handleClearLog = async () => {
|
||||
try {
|
||||
await clearActivityLog();
|
||||
setEntries([]);
|
||||
refresh();
|
||||
setShowConfirmClear(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to clear activity log");
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,7 +152,7 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
className="activity-log-refresh"
|
||||
onClick={() => loadActivityLog()}
|
||||
onClick={() => refresh()}
|
||||
disabled={isLoading}
|
||||
title="Refresh"
|
||||
data-testid="activity-refresh"
|
||||
@@ -191,7 +161,7 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
</button>
|
||||
|
||||
{/* Clear button */}
|
||||
{entries.length > 0 && (
|
||||
{convertedEntries.length > 0 && (
|
||||
<button
|
||||
className="activity-log-clear"
|
||||
onClick={() => setShowConfirmClear(true)}
|
||||
@@ -223,7 +193,7 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 && !isLoading && !error && (
|
||||
{convertedEntries.length === 0 && !isLoading && !error && (
|
||||
<div className="activity-log-empty" data-testid="activity-empty">
|
||||
<History size={48} className="activity-log-empty-icon" />
|
||||
<p>No activity recorded yet</p>
|
||||
@@ -231,7 +201,7 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
)}
|
||||
|
||||
<div className="activity-log-list">
|
||||
{entries.map((entry) => (
|
||||
{convertedEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="activity-log-entry"
|
||||
@@ -286,14 +256,14 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
{hasMore && !isLoading && (
|
||||
<button
|
||||
className="activity-log-load-more"
|
||||
onClick={handleLoadMore}
|
||||
onClick={() => {}}
|
||||
data-testid="activity-load-more"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLoading && entries.length > 0 && (
|
||||
{isLoading && convertedEntries.length > 0 && (
|
||||
<div className="activity-log-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
</div>
|
||||
|
||||
229
packages/dashboard/app/hooks/useActivityLog.test.ts
Normal file
229
packages/dashboard/app/hooks/useActivityLog.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useActivityLog } from "./useActivityLog";
|
||||
import type { ActivityFeedEntry } from "../api";
|
||||
|
||||
function mockFetchResponse(
|
||||
ok: boolean,
|
||||
body: unknown,
|
||||
status = ok ? 200 : 500,
|
||||
contentType = "application/json"
|
||||
) {
|
||||
const bodyText = JSON.stringify(body);
|
||||
return Promise.resolve({
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? "OK" : "Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? contentType : null,
|
||||
},
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(bodyText),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
describe("useActivityLog", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("initializes with empty entries and loads on mount", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.entries).toEqual([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("fetches and displays activity entries", async () => {
|
||||
const mockEntries: ActivityFeedEntry[] = [
|
||||
{
|
||||
id: "entry_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
taskId: "FN-001",
|
||||
details: "Task created",
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(result.current.entries[0].type).toBe("task:created");
|
||||
expect(result.current.entries[0].projectName).toBe("Test Project");
|
||||
});
|
||||
|
||||
it("filters by projectId", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
renderHook(() => useActivityLog({ projectId: "proj_123" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("projectId=proj_123"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("filters by type", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
renderHook(() => useActivityLog({ type: "task:created" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("type=task%3Acreated"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("respects custom limit", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
renderHook(() => useActivityLog({ limit: 100 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("limit=100"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-refresh when disabled", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
renderHook(() => useActivityLog({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Fast forward time (but not using fake timers for this test)
|
||||
vi.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Should still be 1
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refresh function manually refreshes data", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
const { result } = renderHook(() => useActivityLog({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("clear removes all entries", async () => {
|
||||
const mockEntries: ActivityFeedEntry[] = [
|
||||
{
|
||||
id: "entry_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
details: "Task created",
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clear();
|
||||
});
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
expect(result.current.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Server error" }, 500));
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).not.toBeNull();
|
||||
});
|
||||
|
||||
it("sets hasMore when entries equal limit", async () => {
|
||||
const mockEntries: ActivityFeedEntry[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `entry_${i}`,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created" as const,
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
details: "Task created",
|
||||
}));
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
||||
|
||||
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(50);
|
||||
});
|
||||
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("sets hasMore to false when fewer entries than limit", async () => {
|
||||
const mockEntries: ActivityFeedEntry[] = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: `entry_${i}`,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created" as const,
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
details: "Task created",
|
||||
}));
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
||||
|
||||
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(30);
|
||||
});
|
||||
|
||||
expect(result.current.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
131
packages/dashboard/app/hooks/useActivityLog.ts
Normal file
131
packages/dashboard/app/hooks/useActivityLog.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { ActivityFeedEntry } from "../api";
|
||||
import { fetchActivityFeed } from "../api";
|
||||
|
||||
export interface UseActivityLogResult {
|
||||
/** Activity log entries */
|
||||
entries: ActivityFeedEntry[];
|
||||
/** Loading state */
|
||||
loading: boolean;
|
||||
/** Error message if fetch failed */
|
||||
error: string | null;
|
||||
/** Manually refresh activity log */
|
||||
refresh: () => Promise<void>;
|
||||
/** Clear all entries from state */
|
||||
clear: () => void;
|
||||
/** Whether there are more entries to load */
|
||||
hasMore: boolean;
|
||||
/** Load more (older) entries */
|
||||
loadMore: () => Promise<void>;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 5000; // 5 seconds
|
||||
|
||||
export interface UseActivityLogOptions {
|
||||
/** Filter by project ID */
|
||||
projectId?: string;
|
||||
/** Filter by event type */
|
||||
type?: ActivityFeedEntry["type"];
|
||||
/** Number of entries to fetch per page */
|
||||
limit?: number;
|
||||
/** Whether to auto-refresh */
|
||||
autoRefresh?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching and managing the activity log.
|
||||
* Automatically polls for updates every 5 seconds when enabled.
|
||||
* Supports filtering by project and event type.
|
||||
*/
|
||||
export function useActivityLog(options: UseActivityLogOptions = {}): UseActivityLogResult {
|
||||
const { projectId, type, limit = 50, autoRefresh = true } = options;
|
||||
|
||||
const [entries, setEntries] = useState<ActivityFeedEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastTimestampRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await fetchActivityFeed({ limit, projectId, type });
|
||||
setEntries(data);
|
||||
setHasMore(data.length === limit);
|
||||
|
||||
if (data.length > 0) {
|
||||
lastTimestampRef.current = data[data.length - 1].timestamp;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load activity log");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, projectId, type]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!lastTimestampRef.current) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const data = await fetchActivityFeed({
|
||||
limit,
|
||||
projectId,
|
||||
type,
|
||||
since: lastTimestampRef.current
|
||||
});
|
||||
|
||||
setEntries((prev) => [...prev, ...data]);
|
||||
setHasMore(data.length === limit);
|
||||
|
||||
if (data.length > 0) {
|
||||
lastTimestampRef.current = data[data.length - 1].timestamp;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load more entries");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, projectId, type]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setEntries([]);
|
||||
setHasMore(false);
|
||||
lastTimestampRef.current = undefined;
|
||||
}, []);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Auto-refresh polling
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
refresh();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, refresh]);
|
||||
|
||||
return {
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
clear,
|
||||
hasMore,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
178
packages/dashboard/app/hooks/useCurrentProject.test.ts
Normal file
178
packages/dashboard/app/hooks/useCurrentProject.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useCurrentProject } from "./useCurrentProject";
|
||||
import type { ProjectInfo } from "../api";
|
||||
|
||||
describe("useCurrentProject", () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
{
|
||||
id: "proj_1",
|
||||
name: "Project One",
|
||||
path: "/path/one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "proj_2",
|
||||
name: "Project Two",
|
||||
path: "/path/two",
|
||||
status: "paused",
|
||||
isolationMode: "child-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("initializes with null when no saved project and no available projects", async () => {
|
||||
const { result } = renderHook(() => useCurrentProject([]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.currentProject).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to first active project when projects available but no selection", async () => {
|
||||
const { result } = renderHook(() => useCurrentProject(mockProjects));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Should default to first active project
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentProject?.id).toBe("proj_1");
|
||||
});
|
||||
});
|
||||
|
||||
it("loads saved project from localStorage", async () => {
|
||||
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(mockProjects[0]));
|
||||
|
||||
const { result } = renderHook(() => useCurrentProject(mockProjects));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// After validation, it should have the saved project
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentProject).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to first active project when no selection", async () => {
|
||||
const { result } = renderHook(() => useCurrentProject(mockProjects));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Should default to first active project
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentProject?.id).toBe("proj_1");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears selection when project no longer exists", async () => {
|
||||
const unregisteredProject: ProjectInfo = {
|
||||
id: "proj_old",
|
||||
name: "Old Project",
|
||||
path: "/old/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(unregisteredProject));
|
||||
|
||||
const { result } = renderHook(() => useCurrentProject(mockProjects));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Should clear and default to first active
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentProject?.id).toBe("proj_1");
|
||||
});
|
||||
});
|
||||
|
||||
it("setCurrentProject updates selection and saves to localStorage", async () => {
|
||||
const { result } = renderHook(() => useCurrentProject(mockProjects));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentProject(mockProjects[1]);
|
||||
});
|
||||
|
||||
expect(result.current.currentProject?.id).toBe("proj_2");
|
||||
expect(localStorage.getItem("kb-dashboard-current-project")).toContain("proj_2");
|
||||
});
|
||||
|
||||
it("clearCurrentProject removes selection and re-defaults when projects available", async () => {
|
||||
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(mockProjects[1]));
|
||||
|
||||
const { result } = renderHook(() => useCurrentProject(mockProjects));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// After loading, we should have proj_2 from localStorage
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentProject?.id).toBe("proj_2");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clearCurrentProject();
|
||||
});
|
||||
|
||||
// With available projects, it re-defaults to first active and saves to localStorage
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentProject?.id).toBe("proj_1");
|
||||
});
|
||||
|
||||
// After re-defaulting, localStorage should have the default project
|
||||
expect(localStorage.getItem("kb-dashboard-current-project")).toContain("proj_1");
|
||||
});
|
||||
|
||||
it("handles localStorage errors gracefully", async () => {
|
||||
// Mock localStorage to throw
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = vi.fn(() => {
|
||||
throw new Error("Storage error");
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCurrentProject(mockProjects));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentProject(mockProjects[0]);
|
||||
});
|
||||
|
||||
// Should still update state even if localStorage fails
|
||||
expect(result.current.currentProject?.id).toBe("proj_1");
|
||||
|
||||
// Restore
|
||||
localStorage.setItem = originalSetItem;
|
||||
});
|
||||
});
|
||||
101
packages/dashboard/app/hooks/useCurrentProject.ts
Normal file
101
packages/dashboard/app/hooks/useCurrentProject.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ProjectInfo } from "../api";
|
||||
|
||||
const STORAGE_KEY = "kb-dashboard-current-project";
|
||||
|
||||
export interface UseCurrentProjectResult {
|
||||
/** Currently selected project or null if none selected */
|
||||
currentProject: ProjectInfo | null;
|
||||
/** Set the current project */
|
||||
setCurrentProject: (project: ProjectInfo | null) => void;
|
||||
/** Clear the current project selection */
|
||||
clearCurrentProject: () => void;
|
||||
/** Whether we're still loading from localStorage */
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing the currently selected project.
|
||||
* Persists selection to localStorage and validates the project still exists.
|
||||
*/
|
||||
export function useCurrentProject(availableProjects: ProjectInfo[]): UseCurrentProjectResult {
|
||||
const [currentProject, setCurrentProjectState] = useState<ProjectInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as ProjectInfo;
|
||||
setCurrentProjectState(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validate project still exists and persist to localStorage
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
if (currentProject) {
|
||||
// Validate project still exists in available projects
|
||||
const stillExists = availableProjects.some((p) => p.id === currentProject.id);
|
||||
if (!stillExists && availableProjects.length > 0) {
|
||||
// Project was unregistered - clear selection and default to first active
|
||||
const firstActive = availableProjects.find((p) => p.status === "active");
|
||||
setCurrentProjectState(firstActive || availableProjects[0] || null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist to localStorage
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentProject));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
} else if (availableProjects.length > 0) {
|
||||
// No selection but projects available - default to first active
|
||||
const firstActive = availableProjects.find((p) => p.status === "active");
|
||||
if (firstActive) {
|
||||
setCurrentProjectState(firstActive);
|
||||
}
|
||||
}
|
||||
}, [currentProject, availableProjects, loading]);
|
||||
|
||||
const setCurrentProject = useCallback((project: ProjectInfo | null) => {
|
||||
setCurrentProjectState(project);
|
||||
if (project) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearCurrentProject = useCallback(() => {
|
||||
setCurrentProjectState(null);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentProject,
|
||||
setCurrentProject,
|
||||
clearCurrentProject,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
97
packages/dashboard/app/hooks/useProjectHealth.ts
Normal file
97
packages/dashboard/app/hooks/useProjectHealth.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { ProjectHealth } from "../api";
|
||||
import { fetchProjectHealth } from "../api";
|
||||
|
||||
export interface UseProjectHealthResult {
|
||||
/** Current health metrics */
|
||||
health: ProjectHealth | null;
|
||||
/** Project status derived from health */
|
||||
status: "active" | "paused" | "errored" | "initializing" | null;
|
||||
/** Number of active tasks */
|
||||
activeTasks: number;
|
||||
/** Last activity timestamp */
|
||||
lastActivityAt: string | null;
|
||||
/** Loading state */
|
||||
loading: boolean;
|
||||
/** Manually refresh health */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 10000; // 10 seconds
|
||||
|
||||
/**
|
||||
* Hook for polling project health metrics.
|
||||
* Automatically polls every 10 seconds when the project is active.
|
||||
* Stops polling when component unmounts.
|
||||
*/
|
||||
export function useProjectHealth(projectId: string | null): UseProjectHealthResult {
|
||||
const [health, setHealth] = useState<ProjectHealth | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchProjectHealth(projectId);
|
||||
setHealth(data);
|
||||
} catch (err) {
|
||||
// Silently fail - don't clear health on error
|
||||
console.error("Failed to fetch project health:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setHealth(null);
|
||||
return;
|
||||
}
|
||||
|
||||
refresh();
|
||||
}, [projectId, refresh]);
|
||||
|
||||
// Polling when project is active
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
// Stop any existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Only poll when project is active
|
||||
const shouldPoll = !health || health.status === "active" || health.status === "initializing";
|
||||
|
||||
if (shouldPoll) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
refresh();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [projectId, health?.status, refresh]);
|
||||
|
||||
// Derived values
|
||||
const status = health?.status ?? null;
|
||||
const activeTasks = health?.activeTaskCount ?? 0;
|
||||
const lastActivityAt = health?.lastActivityAt ?? null;
|
||||
|
||||
return {
|
||||
health,
|
||||
status,
|
||||
activeTasks,
|
||||
lastActivityAt,
|
||||
loading,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
446
packages/dashboard/app/hooks/useProjects.test.ts
Normal file
446
packages/dashboard/app/hooks/useProjects.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
fetchProjects,
|
||||
registerProject,
|
||||
unregisterProject,
|
||||
fetchProject,
|
||||
updateProject,
|
||||
detectProjects,
|
||||
fetchProjectHealth,
|
||||
fetchActivityFeed,
|
||||
pauseProject,
|
||||
resumeProject,
|
||||
fetchFirstRunStatus,
|
||||
fetchGlobalConcurrency,
|
||||
fetchProjectTasks,
|
||||
fetchProjectConfig,
|
||||
type ProjectInfo,
|
||||
type ProjectHealth,
|
||||
type ActivityFeedEntry,
|
||||
type FirstRunStatus,
|
||||
type GlobalConcurrencyState,
|
||||
type DetectedProject,
|
||||
} from "../api";
|
||||
|
||||
function mockFetchResponse(
|
||||
ok: boolean,
|
||||
body: unknown,
|
||||
status = ok ? 200 : 500,
|
||||
contentType = "application/json"
|
||||
) {
|
||||
const bodyText = JSON.stringify(body);
|
||||
return Promise.resolve({
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? "OK" : "Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? contentType : null,
|
||||
},
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(bodyText),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
describe("Project Management API", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("fetchProjects", () => {
|
||||
it("returns empty array when no projects", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
const result = await fetchProjects();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns projects list when available", async () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
{
|
||||
id: "proj_123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProjects));
|
||||
|
||||
const result = await fetchProjects();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("proj_123");
|
||||
expect(result[0].name).toBe("Test Project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerProject", () => {
|
||||
it("registers a new project with valid input", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_new",
|
||||
name: "New Project",
|
||||
path: "/absolute/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await registerProject({
|
||||
name: "New Project",
|
||||
path: "/absolute/path",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("proj_new");
|
||||
expect(result.name).toBe("New Project");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregisterProject", () => {
|
||||
it("unregisters a project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
||||
|
||||
await unregisterProject("proj_test123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_test123",
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProjectHealth", () => {
|
||||
it("returns health metrics for a project", async () => {
|
||||
const mockHealth: ProjectHealth = {
|
||||
projectId: "proj_test123",
|
||||
status: "active",
|
||||
activeTaskCount: 5,
|
||||
inFlightAgentCount: 2,
|
||||
totalTasksCompleted: 10,
|
||||
totalTasksFailed: 1,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockHealth));
|
||||
|
||||
const result = await fetchProjectHealth("proj_test123");
|
||||
|
||||
expect(result.projectId).toBe("proj_test123");
|
||||
expect(result.activeTaskCount).toBe(5);
|
||||
expect(result.totalTasksCompleted).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchActivityFeed", () => {
|
||||
it("returns activity feed entries", async () => {
|
||||
const mockEntries: ActivityFeedEntry[] = [
|
||||
{
|
||||
id: "entry_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
taskId: "FN-001",
|
||||
taskTitle: "Test Task",
|
||||
details: "Task created",
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
||||
|
||||
const result = await fetchActivityFeed();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("task:created");
|
||||
expect(result[0].projectName).toBe("Test Project");
|
||||
});
|
||||
|
||||
it("supports limit parameter", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchActivityFeed({ limit: 10 });
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("limit=10"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("supports projectId filter", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchActivityFeed({ projectId: "proj_123" });
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("projectId=proj_123"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchFirstRunStatus", () => {
|
||||
it("returns first run status", 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();
|
||||
});
|
||||
|
||||
it("returns single project path when only one project", async () => {
|
||||
const mockStatus: FirstRunStatus = {
|
||||
hasProjects: true,
|
||||
singleProjectPath: "/projects/my-project",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
|
||||
|
||||
const result = await fetchFirstRunStatus();
|
||||
|
||||
expect(result.hasProjects).toBe(true);
|
||||
expect(result.singleProjectPath).toBe("/projects/my-project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGlobalConcurrency", () => {
|
||||
it("returns global concurrency state", async () => {
|
||||
const mockState: GlobalConcurrencyState = {
|
||||
globalMaxConcurrent: 4,
|
||||
currentlyActive: 2,
|
||||
queuedCount: 0,
|
||||
projectsActive: { "proj_123": 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_123"]).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pauseProject", () => {
|
||||
it("pauses a project", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "paused",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await pauseProject("proj_123");
|
||||
|
||||
expect(result.status).toBe("paused");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_123/pause",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeProject", () => {
|
||||
it("resumes a paused project", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await resumeProject("proj_123");
|
||||
|
||||
expect(result.status).toBe("active");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_123/resume",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProjectTasks", () => {
|
||||
it("fetches tasks for a specific project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchProjectTasks("proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("projectId=proj_123"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("supports pagination", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchProjectTasks("proj_123", 10, 20);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("limit=10"),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("offset=20"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProjectConfig", () => {
|
||||
it("fetches project config", async () => {
|
||||
const mockConfig = { maxConcurrent: 4, rootDir: "/projects/test" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockConfig));
|
||||
|
||||
const result = await fetchProjectConfig("proj_123");
|
||||
|
||||
expect(result.maxConcurrent).toBe(4);
|
||||
expect(result.rootDir).toBe("/projects/test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProject (single)", () => {
|
||||
it("fetches a specific project by ID", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Specific Project",
|
||||
path: "/specific/path",
|
||||
status: "active",
|
||||
isolationMode: "child-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await fetchProject("proj_123");
|
||||
|
||||
expect(result.id).toBe("proj_123");
|
||||
expect(result.name).toBe("Specific Project");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_123",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateProject", () => {
|
||||
it("updates project with valid data", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Updated Name",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await updateProject("proj_123", { name: "Updated Name" });
|
||||
|
||||
expect(result.name).toBe("Updated Name");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_123",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("updates project isolationMode", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "child-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await updateProject("proj_123", { isolationMode: "child-process" });
|
||||
|
||||
expect(result.isolationMode).toBe("child-process");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProjects", () => {
|
||||
it("auto-detects projects in a base path", async () => {
|
||||
const mockDetected = {
|
||||
projects: [
|
||||
{ path: "/home/user/project1", suggestedName: "project1", existing: false },
|
||||
{ path: "/home/user/project2", suggestedName: "project2", existing: true },
|
||||
],
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockDetected));
|
||||
|
||||
const result = await detectProjects("/home/user");
|
||||
|
||||
expect(result.projects).toHaveLength(2);
|
||||
expect(result.projects[0].path).toBe("/home/user/project1");
|
||||
expect(result.projects[0].suggestedName).toBe("project1");
|
||||
expect(result.projects[1].existing).toBe(true);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/detect",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ basePath: "/home/user" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses home directory when basePath not provided", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { projects: [] }));
|
||||
|
||||
await detectProjects();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/detect",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ basePath: undefined }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
130
packages/dashboard/app/hooks/useProjects.ts
Normal file
130
packages/dashboard/app/hooks/useProjects.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import {
|
||||
fetchProjects,
|
||||
registerProject,
|
||||
unregisterProject,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
} from "../api";
|
||||
|
||||
export interface UseProjectsResult {
|
||||
/** List of all registered projects */
|
||||
projects: ProjectInfo[];
|
||||
/** Loading state for initial fetch */
|
||||
loading: boolean;
|
||||
/** Error message if fetch failed */
|
||||
error: string | null;
|
||||
/** Manually refresh projects list */
|
||||
refresh: () => Promise<void>;
|
||||
/** Register a new project */
|
||||
register: (input: ProjectCreateInput) => Promise<ProjectInfo>;
|
||||
/** Update an existing project */
|
||||
update: (id: string, updates: Partial<ProjectInfo>) => Promise<ProjectInfo>;
|
||||
/** Unregister a project */
|
||||
unregister: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 5000; // 5 seconds
|
||||
|
||||
/**
|
||||
* Hook for fetching and managing projects.
|
||||
* Automatically polls for updates every 5 seconds.
|
||||
* Provides optimistic updates for UI responsiveness.
|
||||
*/
|
||||
export function useProjects(): UseProjectsResult {
|
||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await fetchProjects();
|
||||
setProjects(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch projects");
|
||||
// Don't clear existing projects on error - keep showing stale data
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchProjects();
|
||||
if (!cancelled) {
|
||||
setProjects(data);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch projects");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Polling for updates
|
||||
useEffect(() => {
|
||||
// Only start polling after initial load completes
|
||||
if (loading) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
refresh();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loading, refresh]);
|
||||
|
||||
const register = useCallback(async (input: ProjectCreateInput): Promise<ProjectInfo> => {
|
||||
const project = await registerProject(input);
|
||||
// Optimistically add to list
|
||||
setProjects((prev) => [...prev, project]);
|
||||
return project;
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async (id: string, updates: Partial<ProjectInfo>): Promise<ProjectInfo> => {
|
||||
const project = await updateProject(id, updates);
|
||||
// Optimistically update in list
|
||||
setProjects((prev) =>
|
||||
prev.map((p) => (p.id === id ? project : p))
|
||||
);
|
||||
return project;
|
||||
}, []);
|
||||
|
||||
const unregister = useCallback(async (id: string): Promise<void> => {
|
||||
await unregisterProject(id);
|
||||
// Optimistically remove from list
|
||||
setProjects((prev) => prev.filter((p) => p.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
projects,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
register,
|
||||
update,
|
||||
unregister,
|
||||
};
|
||||
}
|
||||
60
packages/dashboard/app/test/setup.ts
Normal file
60
packages/dashboard/app/test/setup.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Extend localStorage mock for multi-project tests
|
||||
const localStorageMock: Record<string, string> = {};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => localStorageMock[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
localStorageMock[key] = value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete localStorageMock[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]);
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock fetch for project API tests
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = vi.fn(async (url: RequestInfo | URL) => {
|
||||
const urlString = url.toString();
|
||||
|
||||
// Mock project API responses
|
||||
if (urlString.includes("/api/projects")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [],
|
||||
text: async () => "[]",
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
// Default: return empty successful response
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
text: async () => "{}",
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
} as Response;
|
||||
}) as typeof fetch;
|
||||
|
||||
// Cleanup
|
||||
afterEach(() => {
|
||||
// Clear localStorage mock
|
||||
Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]);
|
||||
|
||||
// Reset fetch mock
|
||||
vi.mocked(globalThis.fetch).mockClear();
|
||||
});
|
||||
|
||||
export { localStorageMock };
|
||||
Reference in New Issue
Block a user