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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user