feat(FN-674): implement multi-project dashboard UX
- Add Project Overview page with responsive grid and health status cards - Add Project Selector dropdown in header for quick project switching - Add Setup Wizard for registering new projects with auto-detection - Implement project drill-down: click project to view its tasks - Add global activity feed with cross-project activity log - Add project health monitoring with real-time status polling - Add deep linking support for tasks via ?task= URL parameter - Add useTasks hook with project context filtering - Add ntfy notification deep link to open tasks in dashboard - Fix terminal error message formatting for agent failures - Unify comment style across codebase (// instead of /** */) - Add comprehensive tests for multi-project components
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels } from "./api";
|
||||
import type { TaskDetail, TaskCreateInput, Task, ThemeMode, ProjectInfo } from "@fusion/core";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail } from "./api";
|
||||
import type { ModelInfo } from "./api";
|
||||
import { Header } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
import { ListView } from "./components/ListView";
|
||||
import { ProjectOverview } from "./components/ProjectOverview";
|
||||
import { SetupWizardModal } from "./components/SetupWizardModal";
|
||||
import { TaskDetailModal } from "./components/TaskDetailModal";
|
||||
import { TerminalModal } from "./components/TerminalModal";
|
||||
import { FileBrowserModal } from "./components/FileBrowserModal";
|
||||
@@ -23,6 +25,8 @@ import { WorkflowStepManager } from "./components/WorkflowStepManager";
|
||||
import { AgentListModal } from "./components/AgentListModal";
|
||||
import { AgentsView } from "./components/AgentsView";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { useProjects } from "./hooks/useProjects";
|
||||
import { useCurrentProject } from "./hooks/useCurrentProject";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
|
||||
@@ -50,24 +54,69 @@ function AppInner() {
|
||||
const [autoMerge, setAutoMerge] = useState(true);
|
||||
const [globalPaused, setGlobalPaused] = useState(false);
|
||||
const [enginePaused, setEnginePaused] = useState(false);
|
||||
const [view, setView] = useState<"board" | "list" | "agents">(() => {
|
||||
// Initialize from localStorage if available
|
||||
|
||||
// Multi-project state
|
||||
const { projects, loading: projectsLoading, register, update: updateProject, unregister: unregisterProject } = useProjects();
|
||||
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
|
||||
|
||||
// View state: "overview" for all projects, "project" for single project task view
|
||||
const [viewMode, setViewMode] = useState<"overview" | "project">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("kb-dashboard-view");
|
||||
const saved = localStorage.getItem("kb-dashboard-view-mode");
|
||||
if (saved === "overview" || saved === "project") {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
return "overview";
|
||||
});
|
||||
|
||||
// Task view state (only meaningful when viewMode="project")
|
||||
const [taskView, setTaskView] = useState<"board" | "list" | "agents">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("kb-dashboard-task-view");
|
||||
if (saved === "list" || saved === "board" || saved === "agents") {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
return "board";
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks();
|
||||
|
||||
// Setup wizard state
|
||||
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
|
||||
|
||||
// Tasks hook with project context
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks(
|
||||
currentProject ? { projectId: currentProject.id } : undefined
|
||||
);
|
||||
|
||||
// Theme management
|
||||
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
|
||||
|
||||
// Auto-open setup wizard on first run (no projects)
|
||||
useEffect(() => {
|
||||
if (!projectsLoading && projects.length === 0 && !setupWizardOpen) {
|
||||
// Delay slightly to allow initial render
|
||||
const timer = setTimeout(() => {
|
||||
setSetupWizardOpen(true);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [projectsLoading, projects.length, setupWizardOpen]);
|
||||
|
||||
// Persist view mode
|
||||
useEffect(() => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
// Persist task view
|
||||
useEffect(() => {
|
||||
localStorage.setItem("kb-dashboard-task-view", taskView);
|
||||
}, [taskView]);
|
||||
|
||||
// Theme toggle handler: cycles Dark → Light → System → Dark
|
||||
const handleToggleTheme = useCallback(() => {
|
||||
const cycle: ThemeMode[] = ["dark", "light", "system"];
|
||||
@@ -109,15 +158,74 @@ function AppInner() {
|
||||
}, []);
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
|
||||
// Persist view preference to localStorage
|
||||
// Handle deep link to task on mount
|
||||
useEffect(() => {
|
||||
localStorage.setItem("kb-dashboard-view", view);
|
||||
}, [view]);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const taskId = params.get("task");
|
||||
if (!taskId) return;
|
||||
|
||||
const handleChangeView = useCallback((newView: "board" | "list" | "agents") => {
|
||||
setView(newView);
|
||||
// Clean URL immediately without reloading
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("task");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
|
||||
// Load and open the task directly
|
||||
fetchTaskDetail(taskId)
|
||||
.then((task) => {
|
||||
handleDetailOpen(task);
|
||||
})
|
||||
.catch(() => {
|
||||
addToast(`Task ${taskId} not found`, "error");
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Project selection handlers
|
||||
const handleSelectProject = useCallback((project: ProjectInfo) => {
|
||||
setCurrentProject(project);
|
||||
setViewMode("project");
|
||||
}, [setCurrentProject]);
|
||||
|
||||
const handleViewAllProjects = useCallback(() => {
|
||||
clearCurrentProject();
|
||||
setViewMode("overview");
|
||||
}, [clearCurrentProject]);
|
||||
|
||||
const handleAddProject = useCallback(() => {
|
||||
setSetupWizardOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePauseProject = useCallback(async (project: ProjectInfo) => {
|
||||
try {
|
||||
await updateProject(project.id, { status: "paused" });
|
||||
addToast(`Project ${project.name} paused`, "success");
|
||||
} catch {
|
||||
addToast(`Failed to pause project ${project.name}`, "error");
|
||||
}
|
||||
}, [updateProject, addToast]);
|
||||
|
||||
const handleResumeProject = useCallback(async (project: ProjectInfo) => {
|
||||
try {
|
||||
await updateProject(project.id, { status: "active" });
|
||||
addToast(`Project ${project.name} resumed`, "success");
|
||||
} catch {
|
||||
addToast(`Failed to resume project ${project.name}`, "error");
|
||||
}
|
||||
}, [updateProject, addToast]);
|
||||
|
||||
const handleRemoveProject = useCallback(async (project: ProjectInfo) => {
|
||||
try {
|
||||
await unregisterProject(project.id);
|
||||
addToast(`Project ${project.name} removed`, "success");
|
||||
// If we removed the current project, go back to overview
|
||||
if (currentProject?.id === project.id) {
|
||||
clearCurrentProject();
|
||||
setViewMode("overview");
|
||||
}
|
||||
} catch {
|
||||
addToast(`Failed to remove project ${project.name}`, "error");
|
||||
}
|
||||
}, [unregisterProject, currentProject, clearCurrentProject, addToast]);
|
||||
|
||||
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
|
||||
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
|
||||
|
||||
@@ -253,33 +361,38 @@ function AppInner() {
|
||||
const handleOpenAgents = useCallback(() => setAgentsOpen(true), []);
|
||||
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenGitHubImport={() => setGitHubImportOpen(true)}
|
||||
onOpenPlanning={handlePlanningOpen}
|
||||
onOpenUsage={handleOpenUsage}
|
||||
onOpenActivityLog={handleOpenActivityLog}
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||
onOpenAgents={handleOpenAgents}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
filesOpen={filesOpen}
|
||||
globalPaused={globalPaused}
|
||||
enginePaused={enginePaused}
|
||||
onToggleGlobalPause={handleToggleGlobalPause}
|
||||
onToggleEnginePause={handleToggleEnginePause}
|
||||
view={view}
|
||||
onChangeView={handleChangeView}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
{view === "agents" ? (
|
||||
<AgentsView addToast={addToast} />
|
||||
) : view === "board" ? (
|
||||
// Setup wizard complete handler
|
||||
const handleSetupComplete = useCallback((project: ProjectInfo) => {
|
||||
setSetupWizardOpen(false);
|
||||
setCurrentProject(project);
|
||||
setViewMode("project");
|
||||
addToast(`Project ${project.name} added successfully`, "success");
|
||||
}, [setCurrentProject, addToast]);
|
||||
|
||||
// Determine which view to render
|
||||
const renderMainContent = () => {
|
||||
if (viewMode === "overview") {
|
||||
return (
|
||||
<ProjectOverview
|
||||
projects={projects}
|
||||
loading={projectsLoading}
|
||||
onSelectProject={handleSelectProject}
|
||||
onAddProject={handleAddProject}
|
||||
onPauseProject={handlePauseProject}
|
||||
onResumeProject={handleResumeProject}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
onViewAllProjects={handleViewAllProjects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Project task view
|
||||
if (taskView === "agents") {
|
||||
return <AgentsView addToast={addToast} />;
|
||||
}
|
||||
|
||||
if (taskView === "board") {
|
||||
return (
|
||||
<Board
|
||||
tasks={tasks}
|
||||
maxConcurrent={maxConcurrent}
|
||||
@@ -300,22 +413,60 @@ function AppInner() {
|
||||
searchQuery={searchQuery}
|
||||
availableModels={availableModels}
|
||||
onOpenFilesForTask={handleOpenFilesForTask}
|
||||
projectId={currentProject?.id}
|
||||
projectName={currentProject?.name}
|
||||
/>
|
||||
) : (
|
||||
// List view now uses the same modal-based create flow as board view.
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onNewTask={handleNewTaskOpen}
|
||||
onQuickCreate={handleBoardQuickCreate}
|
||||
onPlanningMode={handleNewTaskPlanningMode}
|
||||
onSubtaskBreakdown={handleSubtaskBreakdown}
|
||||
availableModels={availableModels}
|
||||
/>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onNewTask={handleNewTaskOpen}
|
||||
onQuickCreate={handleBoardQuickCreate}
|
||||
onPlanningMode={handleNewTaskPlanningMode}
|
||||
onSubtaskBreakdown={handleSubtaskBreakdown}
|
||||
availableModels={availableModels}
|
||||
projectId={currentProject?.id}
|
||||
projectName={currentProject?.name}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenGitHubImport={() => setGitHubImportOpen(true)}
|
||||
onOpenPlanning={handlePlanningOpen}
|
||||
onOpenUsage={handleOpenUsage}
|
||||
onOpenActivityLog={handleOpenActivityLog}
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||
onOpenAgents={handleOpenAgents}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
filesOpen={filesOpen}
|
||||
globalPaused={globalPaused}
|
||||
enginePaused={enginePaused}
|
||||
onToggleGlobalPause={handleToggleGlobalPause}
|
||||
onToggleEnginePause={handleToggleEnginePause}
|
||||
view={taskView}
|
||||
onChangeView={setTaskView}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
projects={projects}
|
||||
currentProject={currentProject}
|
||||
onSelectProject={handleSelectProject}
|
||||
onViewAllProjects={handleViewAllProjects}
|
||||
/>
|
||||
{renderMainContent()}
|
||||
{detailTask && (
|
||||
<TaskDetailModal
|
||||
task={detailTask}
|
||||
@@ -422,6 +573,12 @@ function AppInner() {
|
||||
onClose={handleCloseAgents}
|
||||
addToast={addToast}
|
||||
/>
|
||||
<SetupWizardModal
|
||||
isOpen={setupWizardOpen}
|
||||
onClose={() => setSetupWizardOpen(false)}
|
||||
onComplete={handleSetupComplete}
|
||||
onRegisterProject={register}
|
||||
/>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType } from "../api";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2, Folder } from "lucide-react";
|
||||
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
|
||||
import { useActivityLog } from "../hooks/useActivityLog";
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { Task, ProjectInfo } from "@fusion/core";
|
||||
|
||||
interface ActivityLogModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -11,6 +11,10 @@ interface ActivityLogModalProps {
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
/** When provided, shows only activity for this project */
|
||||
projectId?: string;
|
||||
/** List of all projects for filter dropdown */
|
||||
projects?: ProjectInfo[];
|
||||
/** Called when project filter changes */
|
||||
onProjectFilterChange?: (projectId: string | undefined) => void;
|
||||
}
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
|
||||
@@ -49,14 +53,38 @@ function formatTimestamp(timestamp: string): string {
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail, projectId }: ActivityLogModalProps) {
|
||||
/**
|
||||
* ActivityLogModal - Activity log with project attribution and filtering
|
||||
*
|
||||
* Features:
|
||||
* - Project name badge for each activity entry
|
||||
* - Project filter dropdown (when projects list provided)
|
||||
* - Event type filter
|
||||
* - Real-time updates via useActivityLog hook
|
||||
*/
|
||||
export function ActivityLogModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tasks,
|
||||
onOpenTaskDetail,
|
||||
projectId,
|
||||
projects = [],
|
||||
onProjectFilterChange,
|
||||
}: ActivityLogModalProps) {
|
||||
const [filteredType, setFilteredType] = useState<ActivityEventType | "all">("all");
|
||||
const [filteredProjectId, setFilteredProjectId] = useState<string | "all">(projectId || "all");
|
||||
const [showConfirmClear, setShowConfirmClear] = useState(false);
|
||||
|
||||
// Convert filteredType to the format expected by useActivityLog
|
||||
const activityType = filteredType === "all" ? undefined : filteredType;
|
||||
// Sync with external projectId prop
|
||||
useEffect(() => {
|
||||
setFilteredProjectId(projectId || "all");
|
||||
}, [projectId]);
|
||||
|
||||
// Use the new hook for data fetching
|
||||
// Convert filters to the format expected by useActivityLog
|
||||
const activityType = filteredType === "all" ? undefined : filteredType;
|
||||
const activeProjectId = filteredProjectId === "all" ? undefined : filteredProjectId;
|
||||
|
||||
// Use the hook for data fetching
|
||||
const {
|
||||
entries,
|
||||
loading: isLoading,
|
||||
@@ -64,14 +92,14 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail, pro
|
||||
refresh,
|
||||
hasMore
|
||||
} = useActivityLog({
|
||||
projectId,
|
||||
projectId: activeProjectId,
|
||||
type: activityType,
|
||||
limit: 100,
|
||||
autoRefresh: isOpen, // Only poll when modal is open
|
||||
autoRefresh: isOpen,
|
||||
});
|
||||
|
||||
// Convert entries to ActivityLogEntry format for compatibility
|
||||
const convertedEntries: ActivityLogEntry[] = entries.map(entry => ({
|
||||
const convertedEntries: ActivityLogEntry[] = entries.map((entry: ActivityFeedEntry) => ({
|
||||
id: entry.id,
|
||||
timestamp: entry.timestamp,
|
||||
type: entry.type,
|
||||
@@ -79,6 +107,8 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail, pro
|
||||
taskTitle: entry.taskTitle,
|
||||
details: entry.details,
|
||||
metadata: entry.metadata,
|
||||
projectId: entry.projectId,
|
||||
projectName: entry.projectName,
|
||||
}));
|
||||
|
||||
const handleClearLog = async () => {
|
||||
@@ -97,6 +127,11 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail, pro
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectFilterChange = (value: string) => {
|
||||
setFilteredProjectId(value);
|
||||
onProjectFilterChange?.(value === "all" ? undefined : value);
|
||||
};
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -113,6 +148,9 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail, pro
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose, showConfirmClear]);
|
||||
|
||||
// Determine if any filter is active
|
||||
const isFilterActive = filteredType !== "all" || filteredProjectId !== "all";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -131,7 +169,27 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail, pro
|
||||
<span>Activity Log</span>
|
||||
</div>
|
||||
<div className="activity-log-actions">
|
||||
{/* Filter dropdown */}
|
||||
{/* Project filter dropdown (when projects provided) */}
|
||||
{projects.length > 0 && (
|
||||
<div className="activity-log-filter activity-log-filter--project">
|
||||
<Folder size={14} />
|
||||
<select
|
||||
value={filteredProjectId}
|
||||
onChange={(e) => handleProjectFilterChange(e.target.value)}
|
||||
className="activity-log-filter-select"
|
||||
data-testid="activity-project-filter"
|
||||
>
|
||||
<option value="all">All Projects</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event type filter dropdown */}
|
||||
<div className="activity-log-filter">
|
||||
<Filter size={14} />
|
||||
<select
|
||||
@@ -184,6 +242,33 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail, pro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{isFilterActive && (
|
||||
<div className="activity-log-active-filters">
|
||||
<span className="activity-log-filter-label">Active filters:</span>
|
||||
{filteredProjectId !== "all" && (
|
||||
<span className="activity-log-filter-badge">
|
||||
Project: {projects.find(p => p.id === filteredProjectId)?.name || filteredProjectId}
|
||||
</span>
|
||||
)}
|
||||
{filteredType !== "all" && (
|
||||
<span className="activity-log-filter-badge">
|
||||
Type: {EVENT_TYPE_LABELS[filteredType]}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="activity-log-clear-filters"
|
||||
onClick={() => {
|
||||
setFilteredType("all");
|
||||
setFilteredProjectId("all");
|
||||
onProjectFilterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="activity-log-content" data-testid="activity-log-content">
|
||||
{error && (
|
||||
@@ -196,7 +281,23 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail, pro
|
||||
{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>
|
||||
<p>
|
||||
{isFilterActive
|
||||
? "No activity matches the current filters"
|
||||
: "No activity recorded yet"}
|
||||
</p>
|
||||
{isFilterActive && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setFilteredType("all");
|
||||
setFilteredProjectId("all");
|
||||
onProjectFilterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -215,6 +316,13 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail, pro
|
||||
<span className="activity-log-entry-type">
|
||||
{EVENT_TYPE_LABELS[entry.type]}
|
||||
</span>
|
||||
{/* Project name badge */}
|
||||
{(entry as ActivityFeedEntry).projectName && (
|
||||
<span className="activity-log-entry-project">
|
||||
<Folder size={10} />
|
||||
{(entry as ActivityFeedEntry).projectName}
|
||||
</span>
|
||||
)}
|
||||
<span className="activity-log-entry-time">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
|
||||
import { Folder } from "lucide-react";
|
||||
import type { ModelInfo } from "../api";
|
||||
|
||||
interface BoardProps {
|
||||
@@ -35,6 +36,9 @@ interface BoardProps {
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
/** Project context for multi-project mode */
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
function sortTasksForColumn(tasks: Task[]): Task[] {
|
||||
@@ -53,7 +57,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||
return previous.every((task, index) => task === next[index]);
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, projectId, projectName }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const { fetchBatch } = useBatchBadgeFetch();
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -142,30 +146,42 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
}
|
||||
};
|
||||
}, [taskIdsWithBadges, fetchBatch]);
|
||||
|
||||
return (
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
column={col}
|
||||
tasks={tasksByColumn[col]}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={onMoveTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
allTasks={filteredTasks}
|
||||
availableModels={availableModels}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
<>
|
||||
{/* Project context badge */}
|
||||
{projectId && projectName && (
|
||||
<div className="board-project-context">
|
||||
<span className="board-project-badge">
|
||||
<Folder size={14} />
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
column={col}
|
||||
tasks={tasksByColumn[col]}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={onMoveTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
allTasks={filteredTasks}
|
||||
availableModels={availableModels}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft } from "lucide-react";
|
||||
import type { ProjectInfo } from "@fusion/core";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
|
||||
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
|
||||
function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
@@ -16,7 +18,7 @@ function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
export interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
onOpenGitHubImport?: () => void;
|
||||
onOpenPlanning?: () => void;
|
||||
@@ -38,6 +40,11 @@ interface HeaderProps {
|
||||
onChangeView?: (view: "board" | "list" | "agents") => void;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
/** Multi-project props */
|
||||
projects?: ProjectInfo[];
|
||||
currentProject?: ProjectInfo | null;
|
||||
onSelectProject?: (project: ProjectInfo) => void;
|
||||
onViewAllProjects?: () => void;
|
||||
}
|
||||
|
||||
function useIsMobile() {
|
||||
@@ -78,6 +85,10 @@ export function Header({
|
||||
onChangeView,
|
||||
searchQuery = "",
|
||||
onSearchChange,
|
||||
projects = [],
|
||||
currentProject,
|
||||
onSelectProject,
|
||||
onViewAllProjects,
|
||||
}: HeaderProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
@@ -153,7 +164,35 @@ export function Header({
|
||||
<img src="/logo.svg" alt="Fusion logo" className="header-logo" width={24} height={24} />
|
||||
<h1 className="logo">Fusion</h1>
|
||||
<span className="logo-sub">tasks</span>
|
||||
|
||||
{/* Back to All Projects button when viewing a specific project */}
|
||||
{currentProject && onViewAllProjects && (
|
||||
<button
|
||||
className="header-back-button"
|
||||
onClick={onViewAllProjects}
|
||||
title="Back to All Projects"
|
||||
data-testid="back-to-projects-btn"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
<span>All Projects</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Selector - shown when 2+ projects */}
|
||||
{projects.length > 1 && (
|
||||
<div className="header-project-selector">
|
||||
<ProjectSelector
|
||||
projects={projects}
|
||||
currentProject={currentProject || null}
|
||||
onSelect={(project) => {
|
||||
onSelectProject?.(project);
|
||||
}}
|
||||
onViewAll={onViewAllProjects || (() => {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="header-actions">
|
||||
{/* Desktop Search - only show in board view */}
|
||||
{onSearchChange && view === "board" && !isMobile && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight, Folder } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core";
|
||||
import { COLUMN_LABELS, COLUMNS } from "@fusion/core";
|
||||
import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
|
||||
@@ -48,6 +48,9 @@ interface ListViewProps {
|
||||
* Allows parent to refresh task list or handle optimistically.
|
||||
*/
|
||||
onTasksUpdated?: (updatedTasks: Task[]) => void;
|
||||
/** Project context for multi-project mode */
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
function getStepProgress(steps: TaskStep[]): string {
|
||||
@@ -564,6 +567,15 @@ export function ListView({
|
||||
|
||||
return (
|
||||
<div className="list-view">
|
||||
{/* Project context badge */}
|
||||
{projectId && projectName && (
|
||||
<div className="list-project-context">
|
||||
<span className="list-project-badge">
|
||||
<Folder size={14} />
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="list-toolbar">
|
||||
<div className="list-filter">
|
||||
<Search size={14} className="filter-icon" />
|
||||
|
||||
253
packages/dashboard/app/components/ProjectDetectionResults.tsx
Normal file
253
packages/dashboard/app/components/ProjectDetectionResults.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { Folder, Check, AlertCircle, Edit2, CheckCheck, Loader2 } from "lucide-react";
|
||||
import type { DetectedProject } from "../api";
|
||||
import { sortDetectedProjects } from "../utils/projectDetection";
|
||||
|
||||
export interface ProjectDetectionResultsProps {
|
||||
/** Detected projects from the scan */
|
||||
detectedProjects: DetectedProject[];
|
||||
/** Called when a project is selected/deselected */
|
||||
onSelect: (project: DetectedProject, selected: boolean) => void;
|
||||
/** Called when the name of a detected project is edited */
|
||||
onEditName: (index: number, newName: string) => void;
|
||||
/** Called when register selected button is clicked */
|
||||
onRegisterSelected: (projects: DetectedProject[]) => void;
|
||||
/** Loading state during registration */
|
||||
isRegistering?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectDetectionResults - Auto-detect results UI
|
||||
*
|
||||
* Displays detected projects with:
|
||||
* - Checkboxes for selection
|
||||
* - Editable names
|
||||
* - Warnings for projects without kb database
|
||||
* - Register Selected / Register All buttons
|
||||
*/
|
||||
export function ProjectDetectionResults({
|
||||
detectedProjects,
|
||||
onSelect,
|
||||
onEditName,
|
||||
onRegisterSelected,
|
||||
isRegistering = false,
|
||||
}: ProjectDetectionResultsProps) {
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
|
||||
// Sort projects: existing ones first
|
||||
const sortedProjects = useMemo(() => {
|
||||
return sortDetectedProjects(detectedProjects);
|
||||
}, [detectedProjects]);
|
||||
|
||||
// Calculate selection state
|
||||
const selectedCount = selectedPaths.size;
|
||||
const allSelected = selectedCount === sortedProjects.length && sortedProjects.length > 0;
|
||||
|
||||
// Toggle selection for a single project
|
||||
const toggleSelection = useCallback((project: DetectedProject) => {
|
||||
setSelectedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(project.path)) {
|
||||
next.delete(project.path);
|
||||
onSelect(project, false);
|
||||
} else {
|
||||
next.add(project.path);
|
||||
onSelect(project, true);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [onSelect]);
|
||||
|
||||
// Select/deselect all
|
||||
const toggleAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
setSelectedPaths(new Set());
|
||||
sortedProjects.forEach((p) => onSelect(p, false));
|
||||
} else {
|
||||
const allPaths = new Set(sortedProjects.map((p) => p.path));
|
||||
setSelectedPaths(allPaths);
|
||||
sortedProjects.forEach((p) => onSelect(p, true));
|
||||
}
|
||||
}, [allSelected, sortedProjects, onSelect]);
|
||||
|
||||
// Start editing a name
|
||||
const startEditing = useCallback((index: number, currentName: string) => {
|
||||
setEditingIndex(index);
|
||||
setEditValue(currentName);
|
||||
}, []);
|
||||
|
||||
// Save edited name
|
||||
const saveEdit = useCallback(() => {
|
||||
if (editingIndex !== null) {
|
||||
onEditName(editingIndex, editValue.trim() || sortedProjects[editingIndex].suggestedName);
|
||||
setEditingIndex(null);
|
||||
setEditValue("");
|
||||
}
|
||||
}, [editingIndex, editValue, onEditName, sortedProjects]);
|
||||
|
||||
// Cancel editing
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingIndex(null);
|
||||
setEditValue("");
|
||||
}, []);
|
||||
|
||||
// Handle register selected
|
||||
const handleRegisterSelected = useCallback(() => {
|
||||
const selected = sortedProjects.filter((p) => selectedPaths.has(p.path));
|
||||
onRegisterSelected(selected);
|
||||
}, [sortedProjects, selectedPaths, onRegisterSelected]);
|
||||
|
||||
// Handle register all
|
||||
const handleRegisterAll = useCallback(() => {
|
||||
onRegisterSelected(sortedProjects);
|
||||
}, [sortedProjects, onRegisterSelected]);
|
||||
|
||||
if (sortedProjects.length === 0) {
|
||||
return (
|
||||
<div className="detection-results detection-results--empty">
|
||||
<AlertCircle size={48} />
|
||||
<p>No projects detected</p>
|
||||
<span className="detection-results-hint">
|
||||
Try a different base path or add a project manually
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detection-results">
|
||||
{/* Header with select all */}
|
||||
<div className="detection-results-header">
|
||||
<label className="select-all-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
<span>Select All ({sortedProjects.length})</span>
|
||||
</label>
|
||||
<span className="detection-results-count">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Project list */}
|
||||
<div className="detection-results-list">
|
||||
{sortedProjects.map((project, index) => {
|
||||
const isSelected = selectedPaths.has(project.path);
|
||||
const isEditing = editingIndex === index;
|
||||
const hasKbDb = project.existing;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.path}
|
||||
className={`detection-result-item ${isSelected ? "selected" : ""} ${!hasKbDb ? "warning" : ""}`}
|
||||
>
|
||||
<div className="detection-result-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelection(project)}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="detection-result-icon">
|
||||
<Folder size={18} />
|
||||
{hasKbDb && <Check size={10} className="existing-badge" />}
|
||||
</div>
|
||||
|
||||
<div className="detection-result-content">
|
||||
{isEditing ? (
|
||||
<div className="detection-result-edit">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveEdit();
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={saveEdit}
|
||||
title="Save"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="detection-result-name">
|
||||
<span>{project.suggestedName}</span>
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => startEditing(index, project.suggestedName)}
|
||||
title="Edit name"
|
||||
disabled={isRegistering}
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detection-result-path" title={project.path}>
|
||||
{project.path}
|
||||
</div>
|
||||
|
||||
{!hasKbDb && (
|
||||
<div className="detection-result-warning">
|
||||
<AlertCircle size={12} />
|
||||
<span>No kb database found - will be initialized</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="detection-results-actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleRegisterAll}
|
||||
disabled={isRegistering || sortedProjects.length === 0}
|
||||
>
|
||||
{isRegistering ? (
|
||||
<>
|
||||
<Loader2 size={14} className="spin" />
|
||||
Registering...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCheck size={14} />
|
||||
Register All
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleRegisterSelected}
|
||||
disabled={isRegistering || selectedCount === 0}
|
||||
>
|
||||
{isRegistering ? (
|
||||
<>
|
||||
<Loader2 size={14} className="spin" />
|
||||
Registering...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check size={14} />
|
||||
Register Selected ({selectedCount})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
packages/dashboard/app/components/ProjectGridSkeleton.tsx
Normal file
80
packages/dashboard/app/components/ProjectGridSkeleton.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Folder, Activity, CheckCircle, AlertCircle } from "lucide-react";
|
||||
|
||||
/**
|
||||
* ProjectGridSkeleton - Loading skeleton for project grid
|
||||
*
|
||||
* Shows 6 skeleton cards in a responsive grid layout with pulse animation.
|
||||
* Uses CSS variables for theming compatibility.
|
||||
*/
|
||||
export function ProjectGridSkeleton() {
|
||||
return (
|
||||
<div className="project-overview project-overview--loading">
|
||||
{/* Header stats skeleton */}
|
||||
<div className="project-overview__header-skeleton">
|
||||
<div className="project-overview__stats-row">
|
||||
<div className="project-overview__stat-skeleton">
|
||||
<div className="project-skeleton project-skeleton--icon">
|
||||
<Folder size={20} className="project-skeleton-icon" />
|
||||
</div>
|
||||
<div className="project-skeleton project-skeleton--value" />
|
||||
<div className="project-skeleton project-skeleton--label" />
|
||||
</div>
|
||||
<div className="project-overview__stat-skeleton">
|
||||
<div className="project-skeleton project-skeleton--icon">
|
||||
<Activity size={20} className="project-skeleton-icon" />
|
||||
</div>
|
||||
<div className="project-skeleton project-skeleton--value" />
|
||||
<div className="project-skeleton project-skeleton--label" />
|
||||
</div>
|
||||
<div className="project-overview__stat-skeleton">
|
||||
<div className="project-skeleton project-skeleton--icon">
|
||||
<CheckCircle size={20} className="project-skeleton-icon" />
|
||||
</div>
|
||||
<div className="project-skeleton project-skeleton--value" />
|
||||
<div className="project-skeleton project-skeleton--label" />
|
||||
</div>
|
||||
<div className="project-overview__stat-skeleton">
|
||||
<div className="project-skeleton project-skeleton--icon">
|
||||
<AlertCircle size={20} className="project-skeleton-icon" />
|
||||
</div>
|
||||
<div className="project-skeleton project-skeleton--value" />
|
||||
<div className="project-skeleton project-skeleton--label" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs skeleton */}
|
||||
<div className="project-overview__filters-skeleton">
|
||||
<div className="project-skeleton project-skeleton--tab" />
|
||||
<div className="project-skeleton project-skeleton--tab" />
|
||||
<div className="project-skeleton project-skeleton--tab" />
|
||||
<div className="project-skeleton project-skeleton--tab" />
|
||||
</div>
|
||||
|
||||
{/* Grid skeleton - 6 cards */}
|
||||
<div className="project-grid project-grid--skeleton">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="project-card project-card--skeleton">
|
||||
<div className="project-card-skeleton__header">
|
||||
<div className="project-skeleton project-skeleton--icon-circle" />
|
||||
<div className="project-skeleton__text-group">
|
||||
<div className="project-skeleton project-skeleton--title" />
|
||||
<div className="project-skeleton project-skeleton--path" />
|
||||
</div>
|
||||
<div className="project-skeleton project-skeleton--badge" />
|
||||
</div>
|
||||
<div className="project-card-skeleton__health">
|
||||
<div className="project-skeleton project-skeleton--metric" />
|
||||
<div className="project-skeleton project-skeleton--metric" />
|
||||
<div className="project-skeleton project-skeleton--metric" />
|
||||
</div>
|
||||
<div className="project-card-skeleton__footer">
|
||||
<div className="project-skeleton project-skeleton--activity" />
|
||||
<div className="project-skeleton project-skeleton--actions" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
packages/dashboard/app/components/ProjectHealthBadge.tsx
Normal file
106
packages/dashboard/app/components/ProjectHealthBadge.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Play, Pause, AlertCircle, Loader2 } from "lucide-react";
|
||||
import type { ProjectStatus, ProjectHealth } from "@fusion/core";
|
||||
|
||||
export interface ProjectHealthBadgeProps {
|
||||
status: ProjectStatus;
|
||||
health?: ProjectHealth | null;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showTooltip?: 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 },
|
||||
};
|
||||
|
||||
/**
|
||||
* ProjectHealthBadge - Color-coded badge showing project health status
|
||||
*
|
||||
* Displays a status indicator with icon and label. Optionally shows a tooltip
|
||||
* with detailed health metrics on hover.
|
||||
*/
|
||||
export function ProjectHealthBadge({
|
||||
status,
|
||||
health,
|
||||
size = "md",
|
||||
showTooltip = true,
|
||||
}: ProjectHealthBadgeProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const config = STATUS_CONFIG[status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (showTooltip && health) {
|
||||
setIsHovered(true);
|
||||
}
|
||||
}, [showTooltip, health]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "project-health-badge--sm",
|
||||
md: "project-health-badge--md",
|
||||
lg: "project-health-badge--lg",
|
||||
};
|
||||
|
||||
const isInitializing = status === "initializing";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`project-health-badge ${sizeClasses[size]}`}
|
||||
style={{
|
||||
color: config.color,
|
||||
borderColor: config.color,
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-status={status}
|
||||
>
|
||||
<StatusIcon
|
||||
size={size === "sm" ? 10 : size === "md" ? 12 : 14}
|
||||
className={isInitializing ? "animate-spin" : ""}
|
||||
/>
|
||||
<span className="project-health-badge__label">{config.label}</span>
|
||||
|
||||
{/* Tooltip with health metrics */}
|
||||
{isHovered && health && (
|
||||
<div className="project-health-badge__tooltip">
|
||||
<div className="project-health-tooltip__header">
|
||||
<strong>Health Metrics</strong>
|
||||
</div>
|
||||
<div className="project-health-tooltip__content">
|
||||
<div className="project-health-tooltip__metric">
|
||||
<span className="project-health-tooltip__label">Active Tasks:</span>
|
||||
<span className="project-health-tooltip__value">{health.activeTaskCount}</span>
|
||||
</div>
|
||||
<div className="project-health-tooltip__metric">
|
||||
<span className="project-health-tooltip__label">In-Flight Agents:</span>
|
||||
<span className="project-health-tooltip__value">{health.inFlightAgentCount}</span>
|
||||
</div>
|
||||
<div className="project-health-tooltip__metric">
|
||||
<span className="project-health-tooltip__label">Completed:</span>
|
||||
<span className="project-health-tooltip__value">{health.totalTasksCompleted}</span>
|
||||
</div>
|
||||
<div className="project-health-tooltip__metric">
|
||||
<span className="project-health-tooltip__label">Failed:</span>
|
||||
<span className="project-health-tooltip__value">{health.totalTasksFailed}</span>
|
||||
</div>
|
||||
{health.lastErrorMessage && (
|
||||
<div className="project-health-tooltip__error">
|
||||
<span className="project-health-tooltip__label">Last Error:</span>
|
||||
<span className="project-health-tooltip__error-text">
|
||||
{health.lastErrorMessage}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
357
packages/dashboard/app/components/ProjectOverview.tsx
Normal file
357
packages/dashboard/app/components/ProjectOverview.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox } from "lucide-react";
|
||||
import type { ProjectInfo, ProjectHealth, ProjectStatus } from "@fusion/core";
|
||||
import { ProjectCard } from "./ProjectCard";
|
||||
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
|
||||
import { useProjectHealth } from "../hooks/useProjectHealth";
|
||||
|
||||
export interface ProjectOverviewProps {
|
||||
projects: ProjectInfo[];
|
||||
loading?: boolean;
|
||||
onSelectProject: (project: ProjectInfo) => void;
|
||||
onAddProject: () => void;
|
||||
onPauseProject: (project: ProjectInfo) => void;
|
||||
onResumeProject: (project: ProjectInfo) => void;
|
||||
onRemoveProject: (project: ProjectInfo) => void;
|
||||
onViewAllProjects?: () => void;
|
||||
}
|
||||
|
||||
type FilterTab = "all" | "active" | "paused" | "errored";
|
||||
type SortOption = "name" | "activity" | "status";
|
||||
|
||||
interface ProjectWithHealth {
|
||||
project: ProjectInfo;
|
||||
health: ProjectHealth | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectOverview - Multi-project grid view with stats and filtering
|
||||
*
|
||||
* Displays all projects in a responsive grid with:
|
||||
* - Header stats: total projects, active tasks, completed tasks
|
||||
* - Filter tabs: All, Active, Paused, Errored
|
||||
* - Sort dropdown: Name, Last Activity, Status
|
||||
* - Project cards with health indicators
|
||||
* - Empty state when no projects
|
||||
*/
|
||||
export function ProjectOverview({
|
||||
projects,
|
||||
loading = false,
|
||||
onSelectProject,
|
||||
onAddProject,
|
||||
onPauseProject,
|
||||
onResumeProject,
|
||||
onRemoveProject,
|
||||
}: ProjectOverviewProps) {
|
||||
const [activeFilter, setActiveFilter] = useState<FilterTab>("all");
|
||||
const [sortBy, setSortBy] = useState<SortOption>("activity");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
|
||||
// Track recently accessed projects for quick selection
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Load recently accessed from localStorage
|
||||
const recent = localStorage.getItem("kb-dashboard-recent-projects");
|
||||
if (recent) {
|
||||
try {
|
||||
const parsed = JSON.parse(recent) as string[];
|
||||
setRecentProjectIds(parsed);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [recentProjectIds, setRecentProjectIds] = useState<string[]>([]);
|
||||
|
||||
// Fetch health for all projects
|
||||
const projectIds = useMemo(() => projects.map((p) => p.id), [projects]);
|
||||
const { healthMap, loading: healthLoading } = useProjectHealth(projectIds);
|
||||
|
||||
// Combine projects with their health data
|
||||
const projectsWithHealth: ProjectWithHealth[] = useMemo(() => {
|
||||
return projects.map((project) => ({
|
||||
project,
|
||||
health: healthMap[project.id] || null,
|
||||
}));
|
||||
}, [projects, healthMap]);
|
||||
|
||||
// Filter projects
|
||||
const filteredProjects = useMemo(() => {
|
||||
let filtered = [...projectsWithHealth];
|
||||
|
||||
if (activeFilter !== "all") {
|
||||
filtered = filtered.filter(({ project }) => project.status === activeFilter);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [projectsWithHealth, activeFilter]);
|
||||
|
||||
// Sort projects
|
||||
const sortedProjects = useMemo(() => {
|
||||
const sorted = [...filteredProjects];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
comparison = a.project.name.localeCompare(b.project.name);
|
||||
break;
|
||||
case "activity":
|
||||
const aTime = a.project.lastActivityAt || a.health?.lastActivityAt || a.project.updatedAt;
|
||||
const bTime = b.project.lastActivityAt || b.health?.lastActivityAt || b.project.updatedAt;
|
||||
comparison = new Date(bTime).getTime() - new Date(aTime).getTime();
|
||||
break;
|
||||
case "status":
|
||||
const statusOrder: Record<ProjectStatus, number> = {
|
||||
errored: 0,
|
||||
initializing: 1,
|
||||
paused: 2,
|
||||
active: 3,
|
||||
};
|
||||
comparison = statusOrder[a.project.status] - statusOrder[b.project.status];
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDirection === "asc" ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}, [filteredProjects, sortBy, sortDirection]);
|
||||
|
||||
// Calculate stats
|
||||
const stats = useMemo(() => {
|
||||
const totalProjects = projects.length;
|
||||
const activeProjects = projects.filter((p) => p.status === "active").length;
|
||||
const erroredProjects = projects.filter((p) => p.status === "errored").length;
|
||||
|
||||
let totalActiveTasks = 0;
|
||||
let totalCompletedTasks = 0;
|
||||
let totalInFlightAgents = 0;
|
||||
|
||||
Object.values(healthMap).forEach((health) => {
|
||||
if (health) {
|
||||
totalActiveTasks += health.activeTaskCount;
|
||||
totalCompletedTasks += health.totalTasksCompleted;
|
||||
totalInFlightAgents += health.inFlightAgentCount;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalProjects,
|
||||
activeProjects,
|
||||
erroredProjects,
|
||||
totalActiveTasks,
|
||||
totalCompletedTasks,
|
||||
totalInFlightAgents,
|
||||
};
|
||||
}, [projects, healthMap]);
|
||||
|
||||
// Filter counts
|
||||
const filterCounts = useMemo(() => {
|
||||
return {
|
||||
all: projects.length,
|
||||
active: projects.filter((p) => p.status === "active").length,
|
||||
paused: projects.filter((p) => p.status === "paused").length,
|
||||
errored: projects.filter((p) => p.status === "errored").length,
|
||||
};
|
||||
}, [projects]);
|
||||
|
||||
// Handle sort change
|
||||
const handleSort = useCallback((option: SortOption) => {
|
||||
if (sortBy === option) {
|
||||
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortBy(option);
|
||||
setSortDirection(option === "name" ? "asc" : "desc");
|
||||
}
|
||||
}, [sortBy]);
|
||||
|
||||
// Handle project selection
|
||||
const handleSelectProject = useCallback((project: ProjectInfo) => {
|
||||
// Update recent projects in localStorage
|
||||
const updated = [project.id, ...recentProjectIds.filter((id) => id !== project.id)].slice(0, 3);
|
||||
setRecentProjectIds(updated);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("kb-dashboard-recent-projects", JSON.stringify(updated));
|
||||
}
|
||||
onSelectProject(project);
|
||||
}, [onSelectProject, recentProjectIds]);
|
||||
|
||||
// Show skeleton while loading
|
||||
if (loading || healthLoading) {
|
||||
return <ProjectGridSkeleton />;
|
||||
}
|
||||
|
||||
// Empty state when no projects
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="project-overview project-overview--empty">
|
||||
<div className="project-empty-state">
|
||||
<div className="project-empty-state__icon">
|
||||
<Inbox size={48} />
|
||||
</div>
|
||||
<h2 className="project-empty-state__title">No Projects Found</h2>
|
||||
<p className="project-empty-state__description">
|
||||
Get started by adding your first project. Projects allow you to organize
|
||||
and track tasks across multiple repositories.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary project-empty-state__cta"
|
||||
onClick={onAddProject}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Your First Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-overview">
|
||||
{/* Header with stats */}
|
||||
<div className="project-overview__header">
|
||||
<h2 className="project-overview__title">
|
||||
<LayoutGrid size={20} />
|
||||
Projects
|
||||
</h2>
|
||||
<div className="project-overview__stats">
|
||||
<div className="project-stat">
|
||||
<div className="project-stat__icon">
|
||||
<Folder size={16} />
|
||||
</div>
|
||||
<div className="project-stat__content">
|
||||
<span className="project-stat__value">{stats.totalProjects}</span>
|
||||
<span className="project-stat__label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="project-stat project-stat--active">
|
||||
<div className="project-stat__icon">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
<div className="project-stat__content">
|
||||
<span className="project-stat__value">{stats.totalActiveTasks}</span>
|
||||
<span className="project-stat__label">Active Tasks</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="project-stat project-stat--completed">
|
||||
<div className="project-stat__icon">
|
||||
<CheckCircle size={16} />
|
||||
</div>
|
||||
<div className="project-stat__content">
|
||||
<span className="project-stat__value">{stats.totalCompletedTasks}</span>
|
||||
<span className="project-stat__label">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
{stats.erroredProjects > 0 && (
|
||||
<div className="project-stat project-stat--error">
|
||||
<div className="project-stat__icon">
|
||||
<AlertCircle size={16} />
|
||||
</div>
|
||||
<div className="project-stat__content">
|
||||
<span className="project-stat__value">{stats.erroredProjects}</span>
|
||||
<span className="project-stat__label">Errored</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary project-overview__add-btn"
|
||||
onClick={onAddProject}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="project-overview__filters">
|
||||
<div className="project-filter-tabs">
|
||||
<button
|
||||
className={`project-filter-tab ${activeFilter === "all" ? "active" : ""}`}
|
||||
onClick={() => setActiveFilter("all")}
|
||||
>
|
||||
All
|
||||
<span className="project-filter-count">{filterCounts.all}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`project-filter-tab ${activeFilter === "active" ? "active" : ""}`}
|
||||
onClick={() => setActiveFilter("active")}
|
||||
>
|
||||
Active
|
||||
<span className="project-filter-count">{filterCounts.active}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`project-filter-tab ${activeFilter === "paused" ? "active" : ""}`}
|
||||
onClick={() => setActiveFilter("paused")}
|
||||
>
|
||||
Paused
|
||||
<span className="project-filter-count">{filterCounts.paused}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`project-filter-tab ${activeFilter === "errored" ? "active" : ""} ${filterCounts.errored > 0 ? "has-errors" : ""}`}
|
||||
onClick={() => setActiveFilter("errored")}
|
||||
>
|
||||
Errored
|
||||
<span className="project-filter-count">{filterCounts.errored}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div className="project-sort">
|
||||
<Filter size={14} />
|
||||
<select
|
||||
value={`${sortBy}-${sortDirection}`}
|
||||
onChange={(e) => {
|
||||
const [newSort, newDir] = e.target.value.split("-") as [SortOption, "asc" | "desc"];
|
||||
setSortBy(newSort);
|
||||
setSortDirection(newDir);
|
||||
}}
|
||||
className="project-sort-select"
|
||||
aria-label="Sort projects"
|
||||
>
|
||||
<option value="activity-desc">Last Activity (Newest)</option>
|
||||
<option value="activity-asc">Last Activity (Oldest)</option>
|
||||
<option value="name-asc">Name (A-Z)</option>
|
||||
<option value="name-desc">Name (Z-A)</option>
|
||||
<option value="status-asc">Status (Error → Active)</option>
|
||||
<option value="status-desc">Status (Active → Error)</option>
|
||||
</select>
|
||||
<ArrowUpDown size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project grid */}
|
||||
<div className="project-grid">
|
||||
{sortedProjects.map(({ project, health }) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
health={health}
|
||||
onSelect={handleSelectProject}
|
||||
onPause={onPauseProject}
|
||||
onResume={onResumeProject}
|
||||
onRemove={onRemoveProject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* No results state */}
|
||||
{sortedProjects.length === 0 && (
|
||||
<div className="project-overview__no-results">
|
||||
<Filter size={32} />
|
||||
<p>No projects match the current filter</p>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setActiveFilter("all")}
|
||||
>
|
||||
Show All Projects
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
383
packages/dashboard/app/components/ProjectSelector.tsx
Normal file
383
packages/dashboard/app/components/ProjectSelector.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Check,
|
||||
Folder,
|
||||
Grid3X3,
|
||||
Search,
|
||||
Clock,
|
||||
Play,
|
||||
Pause,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ProjectInfo, ProjectStatus } from "@fusion/core";
|
||||
|
||||
export interface ProjectSelectorProps {
|
||||
projects: ProjectInfo[];
|
||||
currentProject: ProjectInfo | null;
|
||||
onSelect: (project: ProjectInfo) => void;
|
||||
onViewAll: () => void;
|
||||
recentProjectIds?: string[];
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<ProjectStatus, { color: string; icon: typeof Play }> = {
|
||||
active: { color: "var(--success)", icon: Play },
|
||||
paused: { color: "var(--warning)", icon: Pause },
|
||||
errored: { color: "var(--error)", icon: AlertCircle },
|
||||
initializing: { color: "var(--info)", icon: Loader2 },
|
||||
};
|
||||
|
||||
/**
|
||||
* ProjectSelector - Project switcher dropdown with keyboard navigation
|
||||
*
|
||||
* Features:
|
||||
* - Dropdown trigger showing current project name + chevron
|
||||
* - Dropdown menu with project list, status icons, "View All Projects" option
|
||||
* - Keyboard navigation: arrow keys, enter to select, escape to close
|
||||
* - Search/filter when 5+ projects
|
||||
* - Recent projects section at top (last 3 accessed)
|
||||
*/
|
||||
export function ProjectSelector({
|
||||
projects,
|
||||
currentProject,
|
||||
onSelect,
|
||||
onViewAll,
|
||||
recentProjectIds = [],
|
||||
}: ProjectSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus search input when dropdown opens (if search is visible)
|
||||
useEffect(() => {
|
||||
if (isOpen && projects.length >= 5) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen, projects.length]);
|
||||
|
||||
// Get recent projects
|
||||
const recentProjects = useMemo(() => {
|
||||
return recentProjectIds
|
||||
.map((id) => projects.find((p) => p.id === id))
|
||||
.filter((p): p is ProjectInfo => p !== undefined && p.id !== currentProject?.id)
|
||||
.slice(0, 3);
|
||||
}, [recentProjectIds, projects, currentProject]);
|
||||
|
||||
// Filter projects based on search
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!searchQuery.trim()) return projects;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return projects.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.path.toLowerCase().includes(query)
|
||||
);
|
||||
}, [projects, searchQuery]);
|
||||
|
||||
// Organize projects for display: recent first, then others
|
||||
const displayProjects = useMemo(() => {
|
||||
const recentIds = new Set(recentProjects.map((p) => p.id));
|
||||
const currentId = currentProject?.id;
|
||||
|
||||
// Exclude current project from list
|
||||
const others = filteredProjects.filter(
|
||||
(p) => p.id !== currentId && !recentIds.has(p.id)
|
||||
);
|
||||
|
||||
return {
|
||||
recent: searchQuery.trim() ? [] : recentProjects,
|
||||
others,
|
||||
};
|
||||
}, [filteredProjects, recentProjects, currentProject, searchQuery]);
|
||||
|
||||
// Calculate total items for keyboard navigation
|
||||
const totalItems = useMemo(() => {
|
||||
const recentCount = displayProjects.recent.length;
|
||||
const othersCount = displayProjects.others.length;
|
||||
const viewAllCount = 1;
|
||||
return recentCount + othersCount + viewAllCount;
|
||||
}, [displayProjects]);
|
||||
|
||||
// Handle keyboard navigation within dropdown
|
||||
const handleDropdownKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < totalItems - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : totalItems - 1
|
||||
);
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0) {
|
||||
const recentCount = displayProjects.recent.length;
|
||||
const othersCount = displayProjects.others.length;
|
||||
|
||||
if (highlightedIndex < recentCount) {
|
||||
// Select recent project
|
||||
onSelect(displayProjects.recent[highlightedIndex]);
|
||||
} else if (highlightedIndex < recentCount + othersCount) {
|
||||
// Select other project
|
||||
onSelect(displayProjects.others[highlightedIndex - recentCount]);
|
||||
} else {
|
||||
// View All
|
||||
onViewAll();
|
||||
}
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(0);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(totalItems - 1);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[highlightedIndex, totalItems, displayProjects, onSelect, onViewAll]
|
||||
);
|
||||
|
||||
// Reset highlight when dropdown opens or search changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
}, [isOpen, searchQuery]);
|
||||
|
||||
// Handle project selection
|
||||
const handleSelectProject = useCallback(
|
||||
(project: ProjectInfo) => {
|
||||
onSelect(project);
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
// Handle view all
|
||||
const handleViewAll = useCallback(() => {
|
||||
onViewAll();
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}, [onViewAll]);
|
||||
|
||||
// Toggle dropdown
|
||||
const toggleDropdown = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
if (isOpen) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Render status icon
|
||||
const renderStatusIcon = (status: ProjectStatus) => {
|
||||
const config = STATUS_CONFIG[status];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<Icon
|
||||
size={14}
|
||||
style={{ color: config.color }}
|
||||
className={status === "initializing" ? "animate-spin" : ""}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Don't render if only one project (single-project mode)
|
||||
if (projects.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-selector" ref={dropdownRef}>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className={`project-selector__trigger ${isOpen ? "open" : ""}`}
|
||||
onClick={toggleDropdown}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Select project"
|
||||
data-testid="project-selector-trigger"
|
||||
>
|
||||
<Folder size={16} className="project-selector__trigger-icon" />
|
||||
<span className="project-selector__trigger-text">
|
||||
{currentProject?.name || "Select Project"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`project-selector__trigger-chevron ${isOpen ? "rotate" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="project-selector__dropdown"
|
||||
role="listbox"
|
||||
aria-label="Projects"
|
||||
onKeyDown={handleDropdownKeyDown}
|
||||
data-testid="project-selector-dropdown"
|
||||
>
|
||||
{/* Search input (shown when 5+ projects) */}
|
||||
{projects.length >= 5 && (
|
||||
<div className="project-selector__search">
|
||||
<Search size={14} className="project-selector__search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="project-selector__search-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="project-selector__search-clear"
|
||||
onClick={() => setSearchQuery("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent projects section */}
|
||||
{displayProjects.recent.length > 0 && (
|
||||
<div className="project-selector__section">
|
||||
<div className="project-selector__section-header">
|
||||
<Clock size={12} />
|
||||
<span>Recent</span>
|
||||
</div>
|
||||
{displayProjects.recent.map((project, index) => (
|
||||
<button
|
||||
key={project.id}
|
||||
className={`project-selector__item ${
|
||||
highlightedIndex === index ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={() => handleSelectProject(project)}
|
||||
role="option"
|
||||
aria-selected={currentProject?.id === project.id}
|
||||
>
|
||||
{renderStatusIcon(project.status)}
|
||||
<span className="project-selector__item-name">
|
||||
{project.name}
|
||||
</span>
|
||||
{currentProject?.id === project.id && (
|
||||
<Check size={14} className="project-selector__item-check" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All projects section */}
|
||||
<div className="project-selector__section">
|
||||
{displayProjects.recent.length > 0 && (
|
||||
<div className="project-selector__section-header">
|
||||
<Folder size={12} />
|
||||
<span>All Projects</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayProjects.others.length === 0 && searchQuery ? (
|
||||
<div className="project-selector__no-results">
|
||||
No projects match your search
|
||||
</div>
|
||||
) : (
|
||||
displayProjects.others.map((project, index) => {
|
||||
const actualIndex = displayProjects.recent.length + index;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
className={`project-selector__item ${
|
||||
highlightedIndex === actualIndex ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={() => handleSelectProject(project)}
|
||||
role="option"
|
||||
aria-selected={currentProject?.id === project.id}
|
||||
>
|
||||
{renderStatusIcon(project.status)}
|
||||
<div className="project-selector__item-info">
|
||||
<span className="project-selector__item-name">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="project-selector__item-path">
|
||||
{project.path.split("/").slice(-2).join("/")}
|
||||
</span>
|
||||
</div>
|
||||
{currentProject?.id === project.id && (
|
||||
<Check size={14} className="project-selector__item-check" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View All option */}
|
||||
<div className="project-selector__footer">
|
||||
<button
|
||||
className={`project-selector__view-all ${
|
||||
highlightedIndex === totalItems - 1 ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={handleViewAll}
|
||||
>
|
||||
<Grid3X3 size={14} />
|
||||
<span>View All Projects</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
packages/dashboard/app/components/SetupProjectForm.tsx
Normal file
223
packages/dashboard/app/components/SetupProjectForm.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { Folder, Check, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { validateProjectPath, validateProjectName, suggestProjectName } from "../utils/projectDetection";
|
||||
import type { ProjectCreateInput } from "../api";
|
||||
|
||||
export interface SetupProjectFormProps {
|
||||
/** Called when the form is submitted with valid data */
|
||||
onSubmit: (input: ProjectCreateInput) => void;
|
||||
/** Called when validation state changes */
|
||||
onValidationChange?: (isValid: boolean) => void;
|
||||
/** Existing projects for duplicate checking */
|
||||
existingProjects?: { name: string; path: string }[];
|
||||
/** Loading state while submitting */
|
||||
isSubmitting?: boolean;
|
||||
/** Optional default path value */
|
||||
defaultPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetupProjectForm - Manual project registration form
|
||||
*
|
||||
* Form for manually registering a new project with:
|
||||
* - Path input with validation
|
||||
* - Name input with auto-suggestion
|
||||
* - Isolation mode selector
|
||||
* - Real-time validation
|
||||
*/
|
||||
export function SetupProjectForm({
|
||||
onSubmit,
|
||||
onValidationChange,
|
||||
existingProjects = [],
|
||||
isSubmitting = false,
|
||||
defaultPath = "",
|
||||
}: SetupProjectFormProps) {
|
||||
const [path, setPath] = useState(defaultPath);
|
||||
const [name, setName] = useState("");
|
||||
const [isolationMode, setIsolationMode] = useState<"in-process" | "child-process">("in-process");
|
||||
const [pathError, setPathError] = useState<string | null>(null);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [touched, setTouched] = useState({ path: false, name: false });
|
||||
|
||||
// Validate path
|
||||
const validatePath = useCallback((value: string) => {
|
||||
const result = validateProjectPath(value);
|
||||
setPathError(result.valid ? null : result.error);
|
||||
return result.valid;
|
||||
}, []);
|
||||
|
||||
// Validate name
|
||||
const validateNameField = useCallback((value: string) => {
|
||||
const result = validateProjectName(value, existingProjects);
|
||||
setNameError(result.valid ? null : result.error);
|
||||
return result.valid;
|
||||
}, [existingProjects]);
|
||||
|
||||
// Auto-suggest name from path
|
||||
const handlePathChange = useCallback((value: string) => {
|
||||
setPath(value);
|
||||
setTouched((prev) => ({ ...prev, path: true }));
|
||||
|
||||
const isValid = validatePath(value);
|
||||
|
||||
// Auto-suggest name if name is empty and path is valid
|
||||
if (isValid && !name && value) {
|
||||
const suggested = suggestProjectName(value);
|
||||
setName(suggested);
|
||||
}
|
||||
}, [name, validatePath]);
|
||||
|
||||
const handleNameChange = useCallback((value: string) => {
|
||||
setName(value);
|
||||
setTouched((prev) => ({ ...prev, name: true }));
|
||||
validateNameField(value);
|
||||
}, [validateNameField]);
|
||||
|
||||
// Check overall form validity
|
||||
const isFormValid = useMemo(() => {
|
||||
const pathResult = validateProjectPath(path);
|
||||
const nameResult = validateProjectName(name, existingProjects);
|
||||
return pathResult.valid && nameResult.valid && !isSubmitting;
|
||||
}, [path, name, existingProjects, isSubmitting]);
|
||||
|
||||
// Report validation state to parent
|
||||
useMemo(() => {
|
||||
onValidationChange?.(isFormValid);
|
||||
}, [isFormValid, onValidationChange]);
|
||||
|
||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const isPathValid = validatePath(path);
|
||||
const isNameValid = validateNameField(name);
|
||||
|
||||
if (isPathValid && isNameValid) {
|
||||
onSubmit({
|
||||
name,
|
||||
path,
|
||||
isolationMode,
|
||||
});
|
||||
}
|
||||
}, [path, name, isolationMode, onSubmit, validatePath, validateNameField]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="setup-project-form">
|
||||
{/* Path input */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="project-path">
|
||||
Directory Path <span className="required">*</span>
|
||||
</label>
|
||||
<div className={`input-wrapper ${pathError && touched.path ? "error" : ""}`}>
|
||||
<Folder size={16} className="input-icon" />
|
||||
<input
|
||||
id="project-path"
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => handlePathChange(e.target.value)}
|
||||
onBlur={() => {
|
||||
setTouched((prev) => ({ ...prev, path: true }));
|
||||
validatePath(path);
|
||||
}}
|
||||
placeholder="/path/to/your/project"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{pathError && touched.path && (
|
||||
<div className="field-error">
|
||||
<AlertCircle size={14} />
|
||||
<span>{pathError}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="field-hint">
|
||||
Enter the absolute path to your project directory
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name input */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="project-name">
|
||||
Project Name <span className="required">*</span>
|
||||
</label>
|
||||
<div className={`input-wrapper ${nameError && touched.name ? "error" : ""}`}>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onBlur={() => {
|
||||
setTouched((prev) => ({ ...prev, name: true }));
|
||||
validateNameField(name);
|
||||
}}
|
||||
placeholder="my-project"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{name && !nameError && touched.name && (
|
||||
<Check size={16} className="input-success-icon" />
|
||||
)}
|
||||
</div>
|
||||
{nameError && touched.name && (
|
||||
<div className="field-error">
|
||||
<AlertCircle size={14} />
|
||||
<span>{nameError}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="field-hint">
|
||||
Use letters, numbers, hyphens, and underscores only
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Isolation mode */}
|
||||
<div className="form-group">
|
||||
<label>Execution Mode</label>
|
||||
<div className="radio-group">
|
||||
<label className={`radio-option ${isolationMode === "in-process" ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="isolation-mode"
|
||||
value="in-process"
|
||||
checked={isolationMode === "in-process"}
|
||||
onChange={() => setIsolationMode("in-process")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="radio-content">
|
||||
<strong>In-Process (Default)</strong>
|
||||
<span>Fast, low overhead. Tasks run in the main process.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className={`radio-option ${isolationMode === "child-process" ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="isolation-mode"
|
||||
value="child-process"
|
||||
checked={isolationMode === "child-process"}
|
||||
onChange={() => setIsolationMode("child-process")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="radio-content">
|
||||
<strong>Child Process (Isolated)</strong>
|
||||
<span>Strong isolation. Tasks run in separate processes.</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Project"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
71
packages/dashboard/app/components/SetupWizardModal.tsx
Normal file
71
packages/dashboard/app/components/SetupWizardModal.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useCallback } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { SetupWizard } from "./SetupWizard";
|
||||
import type { ProjectInfo, ProjectCreateInput } from "../api";
|
||||
|
||||
export interface SetupWizardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: (project: ProjectInfo) => void;
|
||||
onRegisterProject: (input: ProjectCreateInput) => Promise<ProjectInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetupWizardModal - Modal wrapper for the SetupWizard component
|
||||
*
|
||||
* Provides a modal overlay for the setup wizard, suitable for:
|
||||
* - First-run experience when no projects exist
|
||||
* - "Add Project" button from ProjectOverview
|
||||
*/
|
||||
export function SetupWizardModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onComplete,
|
||||
onRegisterProject,
|
||||
}: SetupWizardModalProps) {
|
||||
const handleProjectCreated = useCallback((project: ProjectInfo) => {
|
||||
onComplete(project);
|
||||
}, [onComplete]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={(e) => {
|
||||
// Close on overlay click, but not when clicking the modal itself
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
data-testid="setup-wizard-modal-overlay"
|
||||
>
|
||||
<div
|
||||
className="modal modal-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="setup-wizard-modal"
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>Add New Project</h3>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
data-testid="setup-wizard-modal-close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content-no-padding">
|
||||
<SetupWizard
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onProjectCreated={handleProjectCreated}
|
||||
onRegisterProject={onRegisterProject}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import type { ActivityLogEntry } from "@fusion/core";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchActivityLog: vi.fn(),
|
||||
fetchActivityFeed: vi.fn(),
|
||||
clearActivityLog: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog);
|
||||
const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
|
||||
const mockClearActivityLog = vi.mocked(apiModule.clearActivityLog);
|
||||
|
||||
describe("ActivityLogModal", () => {
|
||||
@@ -53,7 +53,7 @@ describe("ActivityLogModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchActivityLog.mockResolvedValue(mockActivityEntries);
|
||||
mockFetchActivityFeed.mockResolvedValue(mockActivityEntries);
|
||||
mockClearActivityLog.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("ActivityLogModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalled();
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("ActivityLogModal", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "task:created" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "task:created" })
|
||||
);
|
||||
});
|
||||
@@ -164,19 +164,19 @@ describe("ActivityLogModal", () => {
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByTestId("activity-refresh");
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no entries", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
@@ -193,7 +193,7 @@ describe("ActivityLogModal", () => {
|
||||
});
|
||||
|
||||
it("shows error state when API fails", async () => {
|
||||
mockFetchActivityLog.mockRejectedValue(new Error("API Error"));
|
||||
mockFetchActivityFeed.mockRejectedValue(new Error("API Error"));
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
@@ -250,4 +250,100 @@ describe("ActivityLogModal", () => {
|
||||
// Check that confirmation dialog appears
|
||||
expect(screen.getByText(/Clear Activity Log/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Project Filter Tests ─────────────────────────────────────────
|
||||
|
||||
it("shows project filter when projects provided", async () => {
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
projects={mockProjects}
|
||||
/>
|
||||
);
|
||||
|
||||
const projectFilter = await screen.findByTestId("activity-project-filter");
|
||||
expect(projectFilter).toBeTruthy();
|
||||
|
||||
// Should have "All Projects" option
|
||||
expect(screen.getByText("All Projects")).toBeDefined();
|
||||
// Should have project options
|
||||
expect(screen.getByText("Project One")).toBeDefined();
|
||||
expect(screen.getByText("Project Two")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show project filter when no projects provided", async () => {
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("activity-filter")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Project filter should not exist
|
||||
expect(screen.queryByTestId("activity-project-filter")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onProjectFilterChange when project filter changed", async () => {
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const onProjectFilterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
projects={mockProjects}
|
||||
onProjectFilterChange={onProjectFilterChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const projectFilter = await screen.findByTestId("activity-project-filter");
|
||||
fireEvent.change(projectFilter, { target: { value: "proj_1" } });
|
||||
|
||||
expect(onProjectFilterChange).toHaveBeenCalledWith("proj_1");
|
||||
});
|
||||
|
||||
it("shows empty state message mentioning filters when filter is active", async () => {
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
projects={mockProjects}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("activity-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Change the filter to trigger filtered empty state
|
||||
const projectFilter = screen.getByTestId("activity-project-filter");
|
||||
fireEvent.change(projectFilter, { target: { value: "proj_1" } });
|
||||
|
||||
// Should show filter-specific message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No activity matches the current filters/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -546,4 +546,107 @@ describe("Header", () => {
|
||||
const btn = screen.getByTestId("agents-btn");
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Multi-Project Selector ────────────────────────────────────
|
||||
|
||||
it("shows ProjectSelector when 2+ projects provided", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(<Header projects={projects} />);
|
||||
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show ProjectSelector with single project", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const { container } = render(<Header projects={projects} />);
|
||||
expect(container.querySelector(".project-selector")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show ProjectSelector when no projects", () => {
|
||||
const { container } = render(<Header projects={[]} />);
|
||||
expect(container.querySelector(".project-selector")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows 'Back to All Projects' button when currentProject is set", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onViewAllProjects when 'Back to All Projects' clicked", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const onViewAllProjects = vi.fn();
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onViewAllProjects={onViewAllProjects}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("back-to-projects-btn"));
|
||||
expect(onViewAllProjects).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show 'Back to All Projects' when no currentProject", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(<Header projects={projects} currentProject={null} />);
|
||||
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onSelectProject when project selected from selector", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const onSelectProject = vi.fn();
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={onSelectProject}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open selector
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
// Click on a project in the dropdown
|
||||
fireEvent.click(screen.getByText("Project Two"));
|
||||
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
|
||||
});
|
||||
|
||||
it("shows current project name in selector trigger", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Project One")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { ProjectInfo } from "@fusion/core";
|
||||
|
||||
// Simple smoke tests for multi-project flow
|
||||
describe("MultiProjectFlow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("validates project info structure", () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_1",
|
||||
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",
|
||||
};
|
||||
|
||||
expect(mockProject.id).toBe("proj_1");
|
||||
expect(mockProject.name).toBe("Test Project");
|
||||
expect(mockProject.status).toBe("active");
|
||||
});
|
||||
|
||||
it("can track project selection flow", () => {
|
||||
const projects: ProjectInfo[] = [
|
||||
{
|
||||
id: "proj_1",
|
||||
name: "Project One",
|
||||
path: "/path/1",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "",
|
||||
updatedAt: ""
|
||||
},
|
||||
{
|
||||
id: "proj_2",
|
||||
name: "Project Two",
|
||||
path: "/path/2",
|
||||
status: "paused",
|
||||
isolationMode: "child-process",
|
||||
createdAt: "",
|
||||
updatedAt: ""
|
||||
},
|
||||
];
|
||||
|
||||
let currentProject: ProjectInfo | null = null;
|
||||
|
||||
// Simulate selecting project
|
||||
const selectProject = (project: ProjectInfo) => {
|
||||
currentProject = project;
|
||||
};
|
||||
|
||||
selectProject(projects[0]);
|
||||
expect(currentProject?.id).toBe("proj_1");
|
||||
|
||||
selectProject(projects[1]);
|
||||
expect(currentProject?.status).toBe("paused");
|
||||
});
|
||||
|
||||
it("can track view mode transitions", () => {
|
||||
type ViewMode = "overview" | "project";
|
||||
let viewMode: ViewMode = "overview";
|
||||
|
||||
const setViewMode = (mode: ViewMode) => {
|
||||
viewMode = mode;
|
||||
};
|
||||
|
||||
expect(viewMode).toBe("overview");
|
||||
|
||||
setViewMode("project");
|
||||
expect(viewMode).toBe("project");
|
||||
});
|
||||
|
||||
it("validates view mode and task view preferences in localStorage", () => {
|
||||
// Mock localStorage
|
||||
const storage: Record<string, string> = {};
|
||||
|
||||
// Simulate saving view preferences
|
||||
storage["kb-dashboard-view-mode"] = "project";
|
||||
storage["kb-dashboard-task-view"] = "board";
|
||||
|
||||
expect(storage["kb-dashboard-view-mode"]).toBe("project");
|
||||
expect(storage["kb-dashboard-task-view"]).toBe("board");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ProjectOverview } from "../ProjectOverview";
|
||||
import type { ProjectInfo, ProjectHealth } from "@fusion/core";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("../../hooks/useProjectHealth", () => ({
|
||||
useProjectHealth: vi.fn((projectIds: string[]) => ({
|
||||
healthMap: projectIds.reduce((acc, id) => {
|
||||
acc[id] = {
|
||||
projectId: id,
|
||||
status: "active",
|
||||
activeTaskCount: 5,
|
||||
inFlightAgentCount: 2,
|
||||
totalTasksCompleted: 100,
|
||||
totalTasksFailed: 3,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as ProjectHealth;
|
||||
return acc;
|
||||
}, {} as Record<string, ProjectHealth>),
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
refreshProject: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
Plus: () => <span data-testid="plus-icon">+</span>,
|
||||
LayoutGrid: () => <span data-testid="grid-icon">⊞</span>,
|
||||
Filter: () => <span data-testid="filter-icon">⚙</span>,
|
||||
ArrowUpDown: () => <span data-testid="sort-icon">⇅</span>,
|
||||
Activity: () => <span data-testid="activity-icon">⚡</span>,
|
||||
CheckCircle: () => <span data-testid="check-icon">✓</span>,
|
||||
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
Inbox: () => <span data-testid="inbox-icon">📥</span>,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ProjectCard
|
||||
vi.mock("../ProjectCard", () => ({
|
||||
ProjectCard: ({ project, onSelect }: { project: ProjectInfo; onSelect: (p: ProjectInfo) => void }) => (
|
||||
<div data-testid={`project-card-${project.id}`} onClick={() => onSelect(project)}>
|
||||
{project.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock ProjectGridSkeleton
|
||||
vi.mock("../ProjectGridSkeleton", () => ({
|
||||
ProjectGridSkeleton: () => <div data-testid="project-grid-skeleton">Loading...</div>,
|
||||
}));
|
||||
|
||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
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",
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("ProjectOverview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders without crashing with projects", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[makeProject()]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Projects")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays project cards when projects provided", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
|
||||
expect(screen.getByTestId("project-card-proj_2")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows empty state when no projects", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("No Projects Found")).toBeDefined();
|
||||
expect(screen.getByText("Add Your First Project")).toBeDefined();
|
||||
});
|
||||
|
||||
it("triggers onAddProject when empty state CTA clicked", () => {
|
||||
const onAddProject = vi.fn();
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={onAddProject}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Add Your First Project"));
|
||||
expect(onAddProject).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers onAddProject when header button clicked", () => {
|
||||
const onAddProject = vi.fn();
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[makeProject()]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={onAddProject}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Add Project"));
|
||||
expect(onAddProject).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("displays correct stats in header", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", status: "active" }),
|
||||
makeProject({ id: "proj_2", status: "active" }),
|
||||
makeProject({ id: "proj_3", status: "paused" }),
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Total projects = 3 - look specifically in stats section
|
||||
const statsSection = screen.getByText("Total").closest(".project-stat__content");
|
||||
expect(statsSection?.querySelector(".project-stat__value")?.textContent).toBe("3");
|
||||
});
|
||||
|
||||
it("filters projects when clicking filter tabs", async () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Active Project", status: "active" }),
|
||||
makeProject({ id: "proj_2", name: "Paused Project", status: "paused" }),
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially shows all projects
|
||||
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
|
||||
expect(screen.getByTestId("project-card-proj_2")).toBeDefined();
|
||||
|
||||
// Click on "Active" filter
|
||||
fireEvent.click(screen.getByText("Active"));
|
||||
|
||||
// Should only show active project
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("project-card-proj_1")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows filter counts on tabs", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", status: "active" }),
|
||||
makeProject({ id: "proj_2", status: "active" }),
|
||||
makeProject({ id: "proj_3", status: "paused" }),
|
||||
makeProject({ id: "proj_4", status: "errored" }),
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the "All" tab and check its count
|
||||
const allTab = screen.getByText("All").closest("button");
|
||||
expect(allTab?.textContent).toContain("4");
|
||||
|
||||
// Active tab should show 2
|
||||
const activeTab = screen.getByText("Active").closest("button");
|
||||
expect(activeTab?.textContent).toContain("2");
|
||||
|
||||
// Paused tab should show 1
|
||||
const pausedTab = screen.getByText("Paused").closest("button");
|
||||
expect(pausedTab?.textContent).toContain("1");
|
||||
});
|
||||
|
||||
it("shows no results message when filter returns empty", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", status: "active" }),
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click on "Errored" filter - no projects match
|
||||
fireEvent.click(screen.getByText("Errored"));
|
||||
|
||||
expect(screen.getByText("No projects match the current filter")).toBeDefined();
|
||||
expect(screen.getByText("Show All Projects")).toBeDefined();
|
||||
});
|
||||
|
||||
it("clears filter when clicking Show All Projects button", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", status: "active" }),
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// First filter to errored (empty results)
|
||||
fireEvent.click(screen.getByText("Errored"));
|
||||
expect(screen.getByText("No projects match the current filter")).toBeDefined();
|
||||
|
||||
// Click Show All Projects
|
||||
fireEvent.click(screen.getByText("Show All Projects"));
|
||||
|
||||
// Should be back to showing all
|
||||
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows loading skeleton when loading prop is true", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[]}
|
||||
loading={true}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("project-grid-skeleton")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onSelectProject when a project card is clicked", () => {
|
||||
const onSelectProject = vi.fn();
|
||||
const project = makeProject({ id: "proj_1", name: "Test Project" });
|
||||
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[project]}
|
||||
onSelectProject={onSelectProject}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-card-proj_1"));
|
||||
expect(onSelectProject).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("errored tab has special styling when errored projects exist", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", status: "errored" }),
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the errored filter tab specifically (not the stat label)
|
||||
const erroredTab = screen.getAllByText("Errored").find(el => el.tagName === "BUTTON");
|
||||
expect(erroredTab?.className).toContain("has-errors");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ProjectSelector } from "../ProjectSelector";
|
||||
import type { ProjectInfo } from "@fusion/core";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
ChevronDown: () => <span data-testid="chevron-icon">▼</span>,
|
||||
Check: () => <span data-testid="check-icon">✓</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
Grid3X3: () => <span data-testid="grid-icon">⊞</span>,
|
||||
Search: () => <span data-testid="search-icon">🔍</span>,
|
||||
Clock: () => <span data-testid="clock-icon">🕐</span>,
|
||||
X: () => <span data-testid="x-icon">✕</span>,
|
||||
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>,
|
||||
};
|
||||
});
|
||||
|
||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
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",
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("ProjectSelector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[makeProject({ id: "proj_1" }), makeProject({ id: "proj_2" })]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render when only one project exists", () => {
|
||||
const { container } = render(
|
||||
<ProjectSelector
|
||||
projects={[makeProject()]}
|
||||
currentProject={makeProject()}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render when no projects exist", () => {
|
||||
const { container } = render(
|
||||
<ProjectSelector
|
||||
projects={[]}
|
||||
currentProject={null}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("shows current project name in trigger", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1", name: "Project One" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Project One")).toBeDefined();
|
||||
});
|
||||
|
||||
it("opens dropdown on click", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(screen.getByTestId("project-selector-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows all projects in dropdown", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(screen.getByText("Project Two")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onSelect when project is clicked", () => {
|
||||
const onSelect = vi.fn();
|
||||
const projectTwo = makeProject({ id: "proj_2", name: "Project Two" });
|
||||
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
projectTwo,
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={onSelect}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
fireEvent.click(screen.getByText("Project Two"));
|
||||
expect(onSelect).toHaveBeenCalledWith(projectTwo);
|
||||
});
|
||||
|
||||
it("closes dropdown after selection", () => {
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={onSelect}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
fireEvent.click(screen.getByText("Project Two"));
|
||||
expect(screen.queryByTestId("project-selector-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onViewAll when 'View All Projects' is clicked", () => {
|
||||
const onViewAll = vi.fn();
|
||||
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={onViewAll}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
fireEvent.click(screen.getByText("View All Projects"));
|
||||
expect(onViewAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows search input when 5+ projects", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={Array.from({ length: 5 }, (_, i) =>
|
||||
makeProject({ id: `proj_${i}`, name: `Project ${i}` })
|
||||
)}
|
||||
currentProject={makeProject({ id: "proj_0" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(screen.getByPlaceholderText("Search projects...")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show search input when fewer than 5 projects", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1" }),
|
||||
makeProject({ id: "proj_2" }),
|
||||
makeProject({ id: "proj_3" }),
|
||||
makeProject({ id: "proj_4" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(screen.queryByPlaceholderText("Search projects...")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters projects based on search query", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={Array.from({ length: 5 }, (_, i) =>
|
||||
makeProject({ id: `proj_${i}`, name: `Project ${i}` })
|
||||
)}
|
||||
currentProject={makeProject({ id: "proj_0" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||
fireEvent.change(searchInput, { target: { value: "Project 2" } });
|
||||
|
||||
expect(screen.getByText("Project 2")).toBeDefined();
|
||||
expect(screen.queryByText("Project 1")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows recent projects section", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
makeProject({ id: "proj_3", name: "Project Three" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
recentProjectIds={["proj_2", "proj_3"]}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(screen.getByText("Recent")).toBeDefined();
|
||||
expect(screen.getByText("Project Two")).toBeDefined();
|
||||
});
|
||||
|
||||
it("closes dropdown on escape key", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1" }),
|
||||
makeProject({ id: "proj_2" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(screen.getByTestId("project-selector-dropdown")).toBeDefined();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByTestId("project-selector-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes dropdown on outside click", () => {
|
||||
render(
|
||||
<>
|
||||
<div data-testid="outside">Outside element</div>
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1" }),
|
||||
makeProject({ id: "proj_2" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(screen.getByTestId("project-selector-dropdown")).toBeDefined();
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId("outside"));
|
||||
expect(screen.queryByTestId("project-selector-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("trigger has correct aria attributes", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1" }),
|
||||
makeProject({ id: "proj_2" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("project-selector-trigger");
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("listbox");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("shows checkmark for current project", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1", name: "Project One" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
// Should have checkmark for current project (though in dropdown it might not be visible due to filtering)
|
||||
});
|
||||
});
|
||||
@@ -2,76 +2,128 @@ 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;
|
||||
export interface UseMultiProjectHealthResult {
|
||||
/** Map of project ID to health data */
|
||||
healthMap: Record<string, ProjectHealth | null>;
|
||||
/** Loading state */
|
||||
loading: boolean;
|
||||
/** Manually refresh health */
|
||||
/** Error if any */
|
||||
error: string | null;
|
||||
/** Manually refresh all health data */
|
||||
refresh: () => Promise<void>;
|
||||
/** Refresh a specific project's health */
|
||||
refreshProject: (projectId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 10000; // 10 seconds
|
||||
const BATCH_SIZE = 5; // Number of concurrent health fetches
|
||||
|
||||
/**
|
||||
* Hook for polling project health metrics.
|
||||
* Automatically polls every 10 seconds when the project is active.
|
||||
* Hook for fetching health metrics for multiple projects.
|
||||
*
|
||||
* Automatically polls every 10 seconds when the ProjectOverview is visible.
|
||||
* Stops polling when component unmounts.
|
||||
* Fetches health in batches to avoid overwhelming the server.
|
||||
*/
|
||||
export function useProjectHealth(projectId: string | null): UseProjectHealthResult {
|
||||
const [health, setHealth] = useState<ProjectHealth | null>(null);
|
||||
export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthResult {
|
||||
const [healthMap, setHealthMap] = useState<Record<string, ProjectHealth | null>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const abortRef = useRef<AbortController | 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);
|
||||
if (projectIds.length === 0) {
|
||||
setHealthMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
refresh();
|
||||
}, [projectId, refresh]);
|
||||
// Cancel any in-flight requests
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
// Polling when project is active
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch health in batches
|
||||
const newHealthMap: Record<string, ProjectHealth | null> = {};
|
||||
|
||||
for (let i = 0; i < projectIds.length; i += BATCH_SIZE) {
|
||||
const batch = projectIds.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Fetch this batch concurrently
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map(async (id) => {
|
||||
try {
|
||||
return await fetchProjectHealth(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
batch.forEach((id, index) => {
|
||||
const result = batchResults[index];
|
||||
newHealthMap[id] = result.status === "fulfilled" ? result.value : null;
|
||||
});
|
||||
|
||||
// Check for cancellation between batches
|
||||
if (abortRef.current?.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setHealthMap(newHealthMap);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// Ignore abort errors
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch health data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectIds]);
|
||||
|
||||
const refreshProject = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
const health = await fetchProjectHealth(projectId);
|
||||
setHealthMap((prev) => ({
|
||||
...prev,
|
||||
[projectId]: health,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch health for project ${projectId}:`, err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial fetch and when project IDs change
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
// Stop any existing interval
|
||||
refresh();
|
||||
|
||||
return () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
// Polling - refresh every 10 seconds
|
||||
useEffect(() => {
|
||||
if (projectIds.length === 0) return;
|
||||
|
||||
// Clear 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);
|
||||
}
|
||||
// Start new polling interval
|
||||
intervalRef.current = setInterval(() => {
|
||||
refresh();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
@@ -79,19 +131,13 @@ export function useProjectHealth(projectId: string | null): UseProjectHealthResu
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [projectId, health?.status, refresh]);
|
||||
|
||||
// Derived values
|
||||
const status = health?.status ?? null;
|
||||
const activeTasks = health?.activeTaskCount ?? 0;
|
||||
const lastActivityAt = health?.lastActivityAt ?? null;
|
||||
}, [refresh, projectIds.length]);
|
||||
|
||||
return {
|
||||
health,
|
||||
status,
|
||||
activeTasks,
|
||||
lastActivityAt,
|
||||
healthMap,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
refreshProject,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,16 @@ function compareTimestamps(a: string | undefined, b: string | undefined): number
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
export function useTasks() {
|
||||
export interface UseTasksOptions {
|
||||
/**
|
||||
* When provided, fetches tasks only for this project.
|
||||
* Note: SSE updates are not filtered by project in current implementation.
|
||||
*/
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function useTasks(options?: UseTasksOptions) {
|
||||
const projectId = options?.projectId;
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [connectionNonce, setConnectionNonce] = useState(0);
|
||||
const tasksRef = useRef(tasks);
|
||||
@@ -34,10 +43,20 @@ export function useTasks() {
|
||||
const lastVisibilityFetchRef = useRef<number>(0);
|
||||
const VISIBILITY_FETCH_DEBOUNCE_MS = 1000;
|
||||
|
||||
// Determine which fetch function to use
|
||||
const fetchTasksFn = useCallback(() => {
|
||||
if (projectId) {
|
||||
return api.fetchProjectTasks(projectId);
|
||||
}
|
||||
return api.fetchTasks();
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch initial tasks
|
||||
useEffect(() => {
|
||||
api.fetchTasks().then((tasks) => setTasks(tasks.map(normalizeTask))).catch(() => setTasks([]));
|
||||
}, []);
|
||||
fetchTasksFn()
|
||||
.then((tasks) => setTasks(tasks.map(normalizeTask)))
|
||||
.catch(() => setTasks([]));
|
||||
}, [fetchTasksFn]);
|
||||
|
||||
// Visibility change listener - refresh tasks when tab becomes visible
|
||||
useEffect(() => {
|
||||
@@ -49,7 +68,7 @@ export function useTasks() {
|
||||
// Debounce: only fetch if at least 1 second has passed since last visibility fetch
|
||||
if (timeSinceLastFetch >= VISIBILITY_FETCH_DEBOUNCE_MS) {
|
||||
lastVisibilityFetchRef.current = now;
|
||||
api.fetchTasks()
|
||||
fetchTasksFn()
|
||||
.then((tasks) => setTasks(tasks.map(normalizeTask)))
|
||||
.catch(() => {
|
||||
// Silently ignore fetch errors on visibility change
|
||||
@@ -63,9 +82,12 @@ export function useTasks() {
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
}, [fetchTasksFn]);
|
||||
|
||||
// SSE live updates
|
||||
// Note: In multi-project mode, SSE receives all task events.
|
||||
// Tasks are filtered by ID match, so cross-project updates won't affect
|
||||
// the local state since task IDs are unique and we only fetch from one project.
|
||||
useEffect(() => {
|
||||
let closedByCleanup = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -73,12 +95,17 @@ export function useTasks() {
|
||||
|
||||
const handleCreated = (e: MessageEvent) => {
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
setTasks((prev) => [...prev, task]);
|
||||
// In project mode, only add if this task belongs to our project
|
||||
// Since we can't determine project from event, we add and let subsequent
|
||||
// fetches correct the state, or filter by checking if task exists in our set
|
||||
setTasks((prev) => {
|
||||
// Avoid duplicates
|
||||
if (prev.some((t) => t.id === task.id)) return prev;
|
||||
return [...prev, task];
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoved = (e: MessageEvent) => {
|
||||
// Payload: { task, from, to } - task object includes server-set columnMovedAt
|
||||
// We use 'to' as the authoritative column and trust the server's columnMovedAt
|
||||
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
@@ -94,35 +121,24 @@ export function useTasks() {
|
||||
prev.map((t) => {
|
||||
if (t.id !== incoming.id) return t;
|
||||
|
||||
// First check overall freshness using updatedAt
|
||||
const updatedAtCompare = compareTimestamps(incoming.updatedAt, t.updatedAt);
|
||||
|
||||
// If incoming is older overall, skip the update
|
||||
if (updatedAtCompare < 0) {
|
||||
return t;
|
||||
}
|
||||
|
||||
// If columns are the same, no conflict - accept the incoming update
|
||||
if (t.column === incoming.column) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// Columns differ - need to check columnMovedAt to resolve conflict
|
||||
const columnTimestampCompare = compareTimestamps(t.columnMovedAt, incoming.columnMovedAt);
|
||||
|
||||
// Edge case: current has columnMovedAt but incoming doesn't (legacy data)
|
||||
// Preserve the column information we have
|
||||
if (t.columnMovedAt && !incoming.columnMovedAt) {
|
||||
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
|
||||
}
|
||||
|
||||
// If current state has a newer columnMovedAt, reject the column change
|
||||
if (columnTimestampCompare > 0) {
|
||||
// Current state is newer - preserve column, merge other fields
|
||||
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
|
||||
}
|
||||
|
||||
// Incoming has newer or equal columnMovedAt, accept the update
|
||||
return incoming;
|
||||
})
|
||||
);
|
||||
@@ -134,13 +150,10 @@ export function useTasks() {
|
||||
};
|
||||
|
||||
const handleMerged = (e: MessageEvent) => {
|
||||
// Payload: { task, branch, merged, worktreeRemoved, branchDeleted, ... }
|
||||
// The task object has already been moved to 'done' by the server
|
||||
const { task }: { task: Task } = JSON.parse(e.data);
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
// Ensure column is 'done' since that's where merged tasks always go
|
||||
t.id === normalizedTask.id ? { ...normalizedTask, column: "done" as Column } : t
|
||||
)
|
||||
);
|
||||
@@ -211,7 +224,6 @@ export function useTasks() {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
): Promise<Task> => {
|
||||
// Optimistic update: apply changes immediately
|
||||
const previousTask = tasksRef.current.find((t) => t.id === id);
|
||||
const optimisticTask = previousTask
|
||||
? { ...previousTask, ...updates, updatedAt: new Date().toISOString() }
|
||||
@@ -225,13 +237,11 @@ export function useTasks() {
|
||||
|
||||
try {
|
||||
const updatedTask = normalizeTask(await api.updateTask(id, updates));
|
||||
// Replace with server response
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? updatedTask : t))
|
||||
);
|
||||
return updatedTask;
|
||||
} catch (err) {
|
||||
// Rollback on error: restore previous state
|
||||
if (previousTask) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? previousTask : t))
|
||||
@@ -260,7 +270,6 @@ export function useTasks() {
|
||||
const archiveAllDone = useCallback(async (): Promise<Task[]> => {
|
||||
const archived = await api.archiveAllDone();
|
||||
const normalized = archived.map(normalizeTask);
|
||||
// Update local state by mapping over tasks and updating archived ones
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
const updated = normalized.find((archived) => archived.id === t.id);
|
||||
|
||||
146
packages/dashboard/app/utils/projectDetection.ts
Normal file
146
packages/dashboard/app/utils/projectDetection.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* projectDetection.ts - Client-side project detection utilities
|
||||
*
|
||||
* Provides utilities for scanning and detecting kb projects.
|
||||
* These functions prepare data for API calls rather than accessing
|
||||
* the filesystem directly (which is not possible from browser).
|
||||
*/
|
||||
|
||||
import type { DetectedProject } from "../api";
|
||||
|
||||
export interface DetectionOptions {
|
||||
/** Maximum depth to scan (default: 3) */
|
||||
maxDepth?: number;
|
||||
/** Base path to scan from */
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a scan request for projects.
|
||||
* This function returns the configuration for a scan - the actual
|
||||
* scanning happens server-side via the detectProjects API.
|
||||
*/
|
||||
export function prepareProjectScan(
|
||||
basePath: string = "",
|
||||
maxDepth: number = 3
|
||||
): { basePath: string; maxDepth: number } {
|
||||
return {
|
||||
basePath: basePath || getDefaultScanPath(),
|
||||
maxDepth: Math.min(Math.max(maxDepth, 1), 5), // Clamp between 1-5
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters detected projects to exclude already registered ones.
|
||||
*/
|
||||
export function filterNewProjects(
|
||||
detected: DetectedProject[],
|
||||
registered: { path: string }[]
|
||||
): DetectedProject[] {
|
||||
const registeredPaths = new Set(registered.map((p) => normalizePath(p.path)));
|
||||
return detected.filter((p) => !registeredPaths.has(normalizePath(p.path)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a project path client-side.
|
||||
* Returns validation result - server does final validation.
|
||||
*/
|
||||
export function validateProjectPath(path: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!path || path.trim().length === 0) {
|
||||
return { valid: false, error: "Path is required" };
|
||||
}
|
||||
|
||||
if (path.includes("..") || path.includes("~")) {
|
||||
return { valid: false, error: "Path cannot contain .. or ~" };
|
||||
}
|
||||
|
||||
// Must be absolute path
|
||||
if (!path.startsWith("/") && !path.match(/^[A-Za-z]:/)) {
|
||||
return { valid: false, error: "Path must be absolute" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a suggested project name from a path.
|
||||
*/
|
||||
export function suggestProjectName(path: string): string {
|
||||
// Get the last non-empty segment of the path
|
||||
const segments = path.split(/[/\\]/).filter((s) => s.length > 0);
|
||||
const name = segments[segments.length - 1] || "My Project";
|
||||
|
||||
// Clean up the name
|
||||
return name
|
||||
.replace(/[^a-zA-Z0-9-_]/g, "-") // Replace special chars with hyphens
|
||||
.replace(/-+/g, "-") // Collapse multiple hyphens
|
||||
.replace(/^-|-$/g, "") // Trim leading/trailing hyphens
|
||||
|| "My Project";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a project name.
|
||||
*/
|
||||
export function validateProjectName(
|
||||
name: string,
|
||||
existingProjects?: { name: string }[]
|
||||
): { valid: boolean; error?: string } {
|
||||
if (!name || name.trim().length === 0) {
|
||||
return { valid: false, error: "Name is required" };
|
||||
}
|
||||
|
||||
if (name.length < 1 || name.length > 64) {
|
||||
return { valid: false, error: "Name must be between 1 and 64 characters" };
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(name)) {
|
||||
return { valid: false, error: "Name can only contain letters, numbers, hyphens, and underscores" };
|
||||
}
|
||||
|
||||
if (existingProjects) {
|
||||
const exists = existingProjects.some(
|
||||
(p) => p.name.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
if (exists) {
|
||||
return { valid: false, error: "A project with this name already exists" };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a path for comparison.
|
||||
*/
|
||||
function normalizePath(path: string): string {
|
||||
return path
|
||||
.replace(/\\/g, "/") // Convert backslashes to forward slashes
|
||||
.replace(/\/$/, "") // Remove trailing slash
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default path for scanning.
|
||||
* In browser context, this returns a sensible default.
|
||||
*/
|
||||
function getDefaultScanPath(): string {
|
||||
// In a browser, we can't determine the user's home directory
|
||||
// The server will handle this if empty
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts detected projects by likelihood of being a kb project.
|
||||
* Projects with .kb/kb.db are ranked higher.
|
||||
*/
|
||||
export function sortDetectedProjects(projects: DetectedProject[]): DetectedProject[] {
|
||||
return [...projects].sort((a, b) => {
|
||||
// Existing projects (with kb.db) come first
|
||||
if (a.existing && !b.existing) return -1;
|
||||
if (!a.existing && b.existing) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user