feat(FN-674): add multi-project dashboard support

- Add Project Overview page with responsive grid and health status cards
- Add Project Selector dropdown in header for quick context switching
- Add project drill-down navigation with task board/list views
- Add Setup Wizard components for first-run project registration
- Add global activity feed with project attribution and filtering
- Add project-aware task fetching to backend API
- Add project health polling and status badges
- Update ActivityLogModal with project filter and useActivityLog hook
This commit is contained in:
gsxdsm
2026-04-01 07:27:29 -07:00
parent d781a4f2f8
commit 31fbe031e7
14 changed files with 1042 additions and 191 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Fix blank list view in dashboard when a project is selected. The `/api/tasks` endpoint now handles the `projectId` query parameter by delegating to the appropriate project's TaskStore via CentralCore.

View File

@@ -1157,6 +1157,102 @@ When you add a template:
2. The new step is enabled by default 2. The new step is enabled by default
3. You can edit the step after creation to customize the prompt 3. You can edit the step after creation to customize the prompt
## Multi-Project Dashboard
The kb dashboard supports managing multiple projects simultaneously. This enables teams to track tasks across multiple repositories from a single dashboard view.
### Overview
The multi-project dashboard provides:
- **Project Overview page** — Responsive grid showing all registered projects with health status
- **Project Selector** — Quick context switching between projects via header dropdown
- **Project Drill-down** — Click any project to view its tasks in board or list view
- **Setup Wizard** — First-run experience for registering projects
- **Global Activity Feed** — Cross-project activity with project attribution
### Project Status
Projects have one of these statuses:
- **`active`** — Project is operational and accepting tasks (green badge)
- **`paused`** — Project temporarily suspended (yellow badge)
- **`errored`** — Project has encountered errors (red badge)
- **`initializing`** — Project just registered, not fully set up (blue badge)
### Navigation
**View all projects:**
- Navigate to the Project Overview page showing all registered projects in a responsive grid (1→2→3 columns based on screen size)
- Filter by status: All, Active, Paused, Errored
- Sort by: Name, Last Activity, Status
**Switch projects:**
- Use the Project Selector in the header (visible when 2+ projects registered)
- Shows current project name with dropdown menu
- Displays project status icons for quick health assessment
- "View All Projects" option in dropdown returns to overview
**Back to overview:**
- "Back to All Projects" button appears in header when viewing a specific project
- Clicking returns to Project Overview page
### First-Run Experience
When no projects are registered:
1. Setup wizard automatically opens on dashboard load
2. Manually enter project path and name
3. Or auto-detect projects in a base directory
4. Select projects to register from detection results
5. Projects are initialized with `in-process` execution mode by default
### Project Health
Each project card shows:
- **Active Tasks** — Number of tasks in non-terminal columns
- **Agents** — Currently running agents for this project
- **Completed** — Total tasks completed (from health metrics)
- **Last activity** — Relative timestamp of last project activity
Health is polled every 10 seconds while Project Overview is visible.
### Activity Log
The global activity feed shows events from all projects:
- Project name badge on each entry
- Filter dropdown to show only specific project
- Filter by event type (same options as single-project view)
- Cross-project task linking (opens task detail if task exists in current view)
### Storage
Projects are registered in the central database (`~/.pi/kb/kb-central.db`):
- `projects` table — Project registry with path, status, isolation mode
- `projectHealth` — Mutable health metrics (active tasks, agent counts, totals)
- `centralActivityLog` — Unified activity feed across all projects
### View Preferences
View state is persisted per scope:
- **Overview vs Project mode** — `kb-dashboard-view-mode` in localStorage
- **Board/List/Agents view** — `kb-dashboard-task-view` in localStorage
- **Recently accessed projects** — Last 3 projects stored for quick selector access
### API Integration
Multi-project components use these APIs:
- `GET /api/projects` — List all registered projects
- `POST /api/projects` — Register new project
- `PATCH /api/projects/:id` — Update project status/name
- `DELETE /api/projects/:id` — Unregister project
- `GET /api/projects/:id/health` — Fetch project health metrics
- `GET /api/activity-feed` — Global activity (supports `?projectId=` filter)
### Backward Compatibility
Single-project mode is automatically maintained:
- With only 1 project, Project Selector is hidden
- Dashboard behaves like existing single-project mode
- View preference falls back to "project" mode automatically
## Multi-Project Migration ## Multi-Project Migration
kb supports migrating from single-project mode to multi-project mode seamlessly. When you upgrade to a multi-project capable version, the system automatically detects existing projects and registers them in the central project registry. kb supports migrating from single-project mode to multi-project mode seamlessly. When you upgrade to a multi-project capable version, the system automatically detects existing projects and registers them in the central project registry.

View File

@@ -2520,6 +2520,18 @@ ${stepsSection}`;
} }
} }
/**
* Close the database connection and clean up resources.
* Call this when the store is no longer needed (e.g., short-lived per-request stores).
*/
close(): void {
this.stopWatching();
if (this._db) {
this._db.close();
this._db = null;
}
}
getRootDir(): string { getRootDir(): string {
return this.rootDir; return this.rootDir;
} }

View File

@@ -1,10 +1,12 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core"; import type { TaskDetail, TaskCreateInput, Task, ThemeMode, ProjectInfo } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail } from "./api"; import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail } from "./api";
import type { ModelInfo } from "./api"; import type { ModelInfo } from "./api";
import { Header } from "./components/Header"; import { Header } from "./components/Header";
import { Board } from "./components/Board"; import { Board } from "./components/Board";
import { ListView } from "./components/ListView"; import { ListView } from "./components/ListView";
import { ProjectOverview } from "./components/ProjectOverview";
import { SetupWizardModal } from "./components/SetupWizardModal";
import { TaskDetailModal } from "./components/TaskDetailModal"; import { TaskDetailModal } from "./components/TaskDetailModal";
import { TerminalModal } from "./components/TerminalModal"; import { TerminalModal } from "./components/TerminalModal";
import { FileBrowserModal } from "./components/FileBrowserModal"; import { FileBrowserModal } from "./components/FileBrowserModal";
@@ -23,6 +25,8 @@ import { WorkflowStepManager } from "./components/WorkflowStepManager";
import { AgentListModal } from "./components/AgentListModal"; import { AgentListModal } from "./components/AgentListModal";
import { AgentsView } from "./components/AgentsView"; import { AgentsView } from "./components/AgentsView";
import { useTasks } from "./hooks/useTasks"; import { useTasks } from "./hooks/useTasks";
import { useProjects } from "./hooks/useProjects";
import { useCurrentProject } from "./hooks/useCurrentProject";
import { ToastProvider, useToast } from "./hooks/useToast"; import { ToastProvider, useToast } from "./hooks/useToast";
import { useTheme } from "./hooks/useTheme"; import { useTheme } from "./hooks/useTheme";
@@ -50,24 +54,69 @@ function AppInner() {
const [autoMerge, setAutoMerge] = useState(true); const [autoMerge, setAutoMerge] = useState(true);
const [globalPaused, setGlobalPaused] = useState(false); const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = 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") { 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") { if (saved === "list" || saved === "board" || saved === "agents") {
return saved; return saved;
} }
} }
return "board"; return "board";
}); });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false); const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]); 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 // Theme management
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme(); 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 // Theme toggle handler: cycles Dark → Light → System → Dark
const handleToggleTheme = useCallback(() => { const handleToggleTheme = useCallback(() => {
const cycle: ThemeMode[] = ["dark", "light", "system"]; const cycle: ThemeMode[] = ["dark", "light", "system"];
@@ -130,15 +179,53 @@ function AppInner() {
}); });
}, []); }, []);
// Persist view preference to localStorage // Project selection handlers
useEffect(() => { const handleSelectProject = useCallback((project: ProjectInfo) => {
localStorage.setItem("kb-dashboard-view", view); setCurrentProject(project);
}, [view]); setViewMode("project");
}, [setCurrentProject]);
const handleChangeView = useCallback((newView: "board" | "list" | "agents") => { const handleViewAllProjects = useCallback(() => {
setView(newView); 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 handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []); const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
@@ -274,33 +361,38 @@ function AppInner() {
const handleOpenAgents = useCallback(() => setAgentsOpen(true), []); const handleOpenAgents = useCallback(() => setAgentsOpen(true), []);
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []); const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
return ( // Setup wizard complete handler
<> const handleSetupComplete = useCallback((project: ProjectInfo) => {
<Header setSetupWizardOpen(false);
onOpenSettings={() => setSettingsOpen(true)} setCurrentProject(project);
onOpenGitHubImport={() => setGitHubImportOpen(true)} setViewMode("project");
onOpenPlanning={handlePlanningOpen} addToast(`Project ${project.name} added successfully`, "success");
onOpenUsage={handleOpenUsage} }, [setCurrentProject, addToast]);
onOpenActivityLog={handleOpenActivityLog}
onOpenSchedules={handleOpenSchedules} // Determine which view to render
onOpenGitManager={handleOpenGitManager} const renderMainContent = () => {
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)} if (viewMode === "overview") {
onOpenAgents={handleOpenAgents} return (
onToggleTerminal={handleToggleTerminal} <ProjectOverview
onOpenFiles={handleOpenFiles} projects={projects}
filesOpen={filesOpen} loading={projectsLoading}
globalPaused={globalPaused} onSelectProject={handleSelectProject}
enginePaused={enginePaused} onAddProject={handleAddProject}
onToggleGlobalPause={handleToggleGlobalPause} onPauseProject={handlePauseProject}
onToggleEnginePause={handleToggleEnginePause} onResumeProject={handleResumeProject}
view={view} onRemoveProject={handleRemoveProject}
onChangeView={handleChangeView} onViewAllProjects={handleViewAllProjects}
searchQuery={searchQuery} />
onSearchChange={setSearchQuery} );
/> }
{view === "agents" ? (
<AgentsView addToast={addToast} /> // Project task view
) : view === "board" ? ( if (taskView === "agents") {
return <AgentsView addToast={addToast} />;
}
if (taskView === "board") {
return (
<Board <Board
tasks={tasks} tasks={tasks}
maxConcurrent={maxConcurrent} maxConcurrent={maxConcurrent}
@@ -321,22 +413,60 @@ function AppInner() {
searchQuery={searchQuery} searchQuery={searchQuery}
availableModels={availableModels} availableModels={availableModels}
onOpenFilesForTask={handleOpenFilesForTask} onOpenFilesForTask={handleOpenFilesForTask}
projectId={currentProject?.id}
projectName={currentProject?.name}
/> />
) : ( );
// List view now uses the same modal-based create flow as board view. }
<ListView
tasks={tasks} // List view
onMoveTask={moveTask} return (
onOpenDetail={handleDetailOpen} <ListView
addToast={addToast} tasks={tasks}
globalPaused={globalPaused} onMoveTask={moveTask}
onNewTask={handleNewTaskOpen} onOpenDetail={handleDetailOpen}
onQuickCreate={handleBoardQuickCreate} addToast={addToast}
onPlanningMode={handleNewTaskPlanningMode} globalPaused={globalPaused}
onSubtaskBreakdown={handleSubtaskBreakdown} onNewTask={handleNewTaskOpen}
availableModels={availableModels} 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 && ( {detailTask && (
<TaskDetailModal <TaskDetailModal
task={detailTask} task={detailTask}
@@ -443,6 +573,12 @@ function AppInner() {
onClose={handleCloseAgents} onClose={handleCloseAgents}
addToast={addToast} addToast={addToast}
/> />
<SetupWizardModal
isOpen={setupWizardOpen}
onClose={() => setSetupWizardOpen(false)}
onComplete={handleSetupComplete}
onRegisterProject={register}
/>
<ToastContainer toasts={toasts} onRemove={removeToast} /> <ToastContainer toasts={toasts} onRemove={removeToast} />
</> </>
); );

View File

@@ -1,13 +1,20 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback } from "react";
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2 } from "lucide-react"; import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2, Folder } from "lucide-react";
import { fetchActivityLog, clearActivityLog, type ActivityLogEntry, type ActivityEventType } from "../api"; import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
import type { Task } from "@fusion/core"; import { useActivityLog } from "../hooks/useActivityLog";
import type { Task, ProjectInfo } from "@fusion/core";
interface ActivityLogModalProps { interface ActivityLogModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
tasks: Task[]; tasks: Task[];
onOpenTaskDetail?: (taskId: string) => void; 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> = { const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
@@ -46,78 +53,72 @@ function formatTimestamp(timestamp: string): string {
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} }
export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: ActivityLogModalProps) { /**
const [entries, setEntries] = useState<ActivityLogEntry[]>([]); * 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 [filteredType, setFilteredType] = useState<ActivityEventType | "all">("all");
const [isLoading, setIsLoading] = useState(false); const [filteredProjectId, setFilteredProjectId] = useState<string | "all">(projectId || "all");
const [error, setError] = useState<string | null>(null);
const [showConfirmClear, setShowConfirmClear] = useState(false); const [showConfirmClear, setShowConfirmClear] = useState(false);
const [hasMore, setHasMore] = useState(false);
const pollingRef = useRef<NodeJS.Timeout | null>(null);
const loadActivityLog = useCallback(async (since?: string) => { // Sync with external projectId prop
try {
setIsLoading(true);
setError(null);
const options: { limit: number; since?: string; type?: ActivityEventType } = {
limit: 100,
since,
};
if (filteredType !== "all") {
options.type = filteredType;
}
const data = await fetchActivityLog(options);
if (since) {
// Append older entries
setEntries((prev) => [...prev, ...data]);
} else {
// Replace with fresh entries
setEntries(data);
}
setHasMore(data.length === 100);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load activity log");
} finally {
setIsLoading(false);
}
}, [filteredType]);
// Initial load and filter change
useEffect(() => { useEffect(() => {
if (isOpen) { setFilteredProjectId(projectId || "all");
loadActivityLog(); }, [projectId]);
}
}, [isOpen, loadActivityLog]);
// Auto-refresh every 30 seconds when modal is open // Convert filters to the format expected by useActivityLog
useEffect(() => { const activityType = filteredType === "all" ? undefined : filteredType;
if (isOpen) { const activeProjectId = filteredProjectId === "all" ? undefined : filteredProjectId;
pollingRef.current = setInterval(() => {
loadActivityLog();
}, 30000);
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [isOpen, loadActivityLog]);
const handleLoadMore = () => { // Use the hook for data fetching
if (entries.length > 0) { const {
const lastEntry = entries[entries.length - 1]; entries,
loadActivityLog(lastEntry.timestamp); loading: isLoading,
} error,
}; refresh,
hasMore
} = useActivityLog({
projectId: activeProjectId,
type: activityType,
limit: 100,
autoRefresh: isOpen,
});
// Convert entries to ActivityLogEntry format for compatibility
const convertedEntries: ActivityLogEntry[] = entries.map((entry: ActivityFeedEntry) => ({
id: entry.id,
timestamp: entry.timestamp,
type: entry.type,
taskId: entry.taskId,
taskTitle: entry.taskTitle,
details: entry.details,
metadata: entry.metadata,
projectId: entry.projectId,
projectName: entry.projectName,
}));
const handleClearLog = async () => { const handleClearLog = async () => {
try { try {
await clearActivityLog(); await clearActivityLog();
setEntries([]); refresh();
setShowConfirmClear(false); setShowConfirmClear(false);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to clear activity log"); // Error handled by hook
setShowConfirmClear(false);
} }
}; };
@@ -127,6 +128,11 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
} }
}; };
const handleProjectFilterChange = (value: string) => {
setFilteredProjectId(value);
onProjectFilterChange?.(value === "all" ? undefined : value);
};
// Handle escape key to close // Handle escape key to close
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
@@ -143,6 +149,9 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
return () => document.removeEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, onClose, showConfirmClear]); }, [isOpen, onClose, showConfirmClear]);
// Determine if any filter is active
const isFilterActive = filteredType !== "all" || filteredProjectId !== "all";
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@@ -161,7 +170,27 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
<span>Activity Log</span> <span>Activity Log</span>
</div> </div>
<div className="activity-log-actions"> <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"> <div className="activity-log-filter">
<Filter size={14} /> <Filter size={14} />
<select <select
@@ -182,7 +211,7 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
{/* Refresh button */} {/* Refresh button */}
<button <button
className="activity-log-refresh" className="activity-log-refresh"
onClick={() => loadActivityLog()} onClick={() => refresh()}
disabled={isLoading} disabled={isLoading}
title="Refresh" title="Refresh"
data-testid="activity-refresh" data-testid="activity-refresh"
@@ -191,7 +220,7 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
</button> </button>
{/* Clear button */} {/* Clear button */}
{entries.length > 0 && ( {convertedEntries.length > 0 && (
<button <button
className="activity-log-clear" className="activity-log-clear"
onClick={() => setShowConfirmClear(true)} onClick={() => setShowConfirmClear(true)}
@@ -214,6 +243,33 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
</div> </div>
</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 */} {/* Content */}
<div className="activity-log-content" data-testid="activity-log-content"> <div className="activity-log-content" data-testid="activity-log-content">
{error && ( {error && (
@@ -223,15 +279,31 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
</div> </div>
)} )}
{entries.length === 0 && !isLoading && !error && ( {convertedEntries.length === 0 && !isLoading && !error && (
<div className="activity-log-empty" data-testid="activity-empty"> <div className="activity-log-empty" data-testid="activity-empty">
<History size={48} className="activity-log-empty-icon" /> <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> </div>
)} )}
<div className="activity-log-list"> <div className="activity-log-list">
{entries.map((entry) => ( {convertedEntries.map((entry) => (
<div <div
key={entry.id} key={entry.id}
className="activity-log-entry" className="activity-log-entry"
@@ -245,6 +317,13 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
<span className="activity-log-entry-type"> <span className="activity-log-entry-type">
{EVENT_TYPE_LABELS[entry.type]} {EVENT_TYPE_LABELS[entry.type]}
</span> </span>
{/* Project name badge */}
{entry.projectName && (
<span className="activity-log-entry-project">
<Folder size={10} />
{entry.projectName}
</span>
)}
<span className="activity-log-entry-time"> <span className="activity-log-entry-time">
{formatTimestamp(entry.timestamp)} {formatTimestamp(entry.timestamp)}
</span> </span>
@@ -286,14 +365,14 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
{hasMore && !isLoading && ( {hasMore && !isLoading && (
<button <button
className="activity-log-load-more" className="activity-log-load-more"
onClick={handleLoadMore} onClick={refresh}
data-testid="activity-load-more" data-testid="activity-load-more"
> >
Load More Load More
</button> </button>
)} )}
{isLoading && entries.length > 0 && ( {isLoading && convertedEntries.length > 0 && (
<div className="activity-log-loading"> <div className="activity-log-loading">
<Loader2 size={20} className="spin" /> <Loader2 size={20} className="spin" />
</div> </div>

View File

@@ -4,6 +4,7 @@ import { Column } from "./Column";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch"; import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
import { Folder } from "lucide-react";
import type { ModelInfo } from "../api"; import type { ModelInfo } from "../api";
interface BoardProps { interface BoardProps {
@@ -35,6 +36,9 @@ interface BoardProps {
*/ */
onSubtaskBreakdown?: (description: string) => void; onSubtaskBreakdown?: (description: string) => void;
onOpenFilesForTask?: (taskId: string) => void; onOpenFilesForTask?: (taskId: string) => void;
/** Project context for multi-project mode */
projectId?: string;
projectName?: string;
} }
function sortTasksForColumn(tasks: Task[]): Task[] { function sortTasksForColumn(tasks: Task[]): Task[] {
@@ -53,7 +57,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
return previous.every((task, index) => task === next[index]); 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 [archivedCollapsed, setArchivedCollapsed] = useState(true);
const { fetchBatch } = useBatchBadgeFetch(); const { fetchBatch } = useBatchBadgeFetch();
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -142,30 +146,42 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
} }
}; };
}, [taskIdsWithBadges, fetchBatch]); }, [taskIdsWithBadges, fetchBatch]);
return ( return (
<main className="board" id="board"> <>
{COLUMNS.map((col) => ( {/* Project context badge */}
<Column {projectId && projectName && (
key={col} <div className="board-project-context">
column={col} <span className="board-project-badge">
tasks={tasksByColumn[col]} <Folder size={14} />
maxConcurrent={maxConcurrent} {projectName}
onMoveTask={onMoveTask} </span>
onOpenDetail={onOpenDetail} </div>
addToast={addToast} )}
globalPaused={globalPaused} <main className="board" id="board">
onUpdateTask={onUpdateTask} {COLUMNS.map((col) => (
onArchiveTask={onArchiveTask} <Column
onUnarchiveTask={onUnarchiveTask} key={col}
allTasks={filteredTasks} column={col}
availableModels={availableModels} tasks={tasksByColumn[col]}
onOpenFilesForTask={onOpenFilesForTask} maxConcurrent={maxConcurrent}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} onMoveTask={onMoveTask}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})} onOpenDetail={onOpenDetail}
{...(col === "done" ? { onArchiveAllDone } : {})} addToast={addToast}
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})} globalPaused={globalPaused}
/> onUpdateTask={onUpdateTask}
))} onArchiveTask={onArchiveTask}
</main> 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>
</>
); );
} }

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react"; 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 // GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
function GitHubLogo({ size = 16 }: { size?: number }) { function GitHubLogo({ size = 16 }: { size?: number }) {
@@ -16,7 +18,7 @@ function GitHubLogo({ size = 16 }: { size?: number }) {
); );
} }
interface HeaderProps { export interface HeaderProps {
onOpenSettings?: () => void; onOpenSettings?: () => void;
onOpenGitHubImport?: () => void; onOpenGitHubImport?: () => void;
onOpenPlanning?: () => void; onOpenPlanning?: () => void;
@@ -38,6 +40,11 @@ interface HeaderProps {
onChangeView?: (view: "board" | "list" | "agents") => void; onChangeView?: (view: "board" | "list" | "agents") => void;
searchQuery?: string; searchQuery?: string;
onSearchChange?: (query: string) => void; onSearchChange?: (query: string) => void;
/** Multi-project props */
projects?: ProjectInfo[];
currentProject?: ProjectInfo | null;
onSelectProject?: (project: ProjectInfo) => void;
onViewAllProjects?: () => void;
} }
function useIsMobile() { function useIsMobile() {
@@ -78,6 +85,10 @@ export function Header({
onChangeView, onChangeView,
searchQuery = "", searchQuery = "",
onSearchChange, onSearchChange,
projects = [],
currentProject,
onSelectProject,
onViewAllProjects,
}: HeaderProps) { }: HeaderProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); 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} /> <img src="/logo.svg" alt="Fusion logo" className="header-logo" width={24} height={24} />
<h1 className="logo">Fusion</h1> <h1 className="logo">Fusion</h1>
<span className="logo-sub">tasks</span> <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> </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"> <div className="header-actions">
{/* Desktop Search - only show in board view */} {/* Desktop Search - only show in board view */}
{onSearchChange && view === "board" && !isMobile && ( {onSearchChange && view === "board" && !isMobile && (

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react"; 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 type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core";
import { COLUMN_LABELS, COLUMNS } from "@fusion/core"; import { COLUMN_LABELS, COLUMNS } from "@fusion/core";
import { fetchTaskDetail, batchUpdateTaskModels } from "../api"; import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
@@ -48,6 +48,9 @@ interface ListViewProps {
* Allows parent to refresh task list or handle optimistically. * Allows parent to refresh task list or handle optimistically.
*/ */
onTasksUpdated?: (updatedTasks: Task[]) => void; onTasksUpdated?: (updatedTasks: Task[]) => void;
/** Project context for multi-project mode */
projectId?: string;
projectName?: string;
} }
function getStepProgress(steps: TaskStep[]): string { function getStepProgress(steps: TaskStep[]): string {
@@ -564,6 +567,15 @@ export function ListView({
return ( return (
<div className="list-view"> <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-toolbar">
<div className="list-filter"> <div className="list-filter">
<Search size={14} className="filter-icon" /> <Search size={14} className="filter-icon" />

View File

@@ -6,11 +6,11 @@ import type { ActivityLogEntry } from "@fusion/core";
// Mock the API module // Mock the API module
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
fetchActivityLog: vi.fn(), fetchActivityFeed: vi.fn(),
clearActivityLog: vi.fn(), clearActivityLog: vi.fn(),
})); }));
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog); const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
const mockClearActivityLog = vi.mocked(apiModule.clearActivityLog); const mockClearActivityLog = vi.mocked(apiModule.clearActivityLog);
describe("ActivityLogModal", () => { describe("ActivityLogModal", () => {
@@ -53,7 +53,7 @@ describe("ActivityLogModal", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockFetchActivityLog.mockResolvedValue(mockActivityEntries); mockFetchActivityFeed.mockResolvedValue(mockActivityEntries);
mockClearActivityLog.mockResolvedValue({ success: true }); mockClearActivityLog.mockResolvedValue({ success: true });
}); });
@@ -128,7 +128,7 @@ describe("ActivityLogModal", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(mockFetchActivityLog).toHaveBeenCalled(); expect(mockFetchActivityFeed).toHaveBeenCalled();
}); });
}); });
@@ -146,7 +146,7 @@ describe("ActivityLogModal", () => {
fireEvent.change(filterSelect, { target: { value: "task:created" } }); fireEvent.change(filterSelect, { target: { value: "task:created" } });
await waitFor(() => { await waitFor(() => {
expect(mockFetchActivityLog).toHaveBeenCalledWith( expect(mockFetchActivityFeed).toHaveBeenCalledWith(
expect.objectContaining({ type: "task:created" }) expect.objectContaining({ type: "task:created" })
); );
}); });
@@ -164,19 +164,19 @@ describe("ActivityLogModal", () => {
// Wait for initial load // Wait for initial load
await waitFor(() => { await waitFor(() => {
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1); expect(mockFetchActivityFeed).toHaveBeenCalledTimes(1);
}); });
const refreshButton = screen.getByTestId("activity-refresh"); const refreshButton = screen.getByTestId("activity-refresh");
fireEvent.click(refreshButton); fireEvent.click(refreshButton);
await waitFor(() => { await waitFor(() => {
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2); expect(mockFetchActivityFeed).toHaveBeenCalledTimes(2);
}); });
}); });
it("shows empty state when no entries", async () => { it("shows empty state when no entries", async () => {
mockFetchActivityLog.mockResolvedValue([]); mockFetchActivityFeed.mockResolvedValue([]);
render( render(
<ActivityLogModal <ActivityLogModal
@@ -193,7 +193,7 @@ describe("ActivityLogModal", () => {
}); });
it("shows error state when API fails", async () => { it("shows error state when API fails", async () => {
mockFetchActivityLog.mockRejectedValue(new Error("API Error")); mockFetchActivityFeed.mockRejectedValue(new Error("API Error"));
render( render(
<ActivityLogModal <ActivityLogModal
@@ -250,4 +250,100 @@ describe("ActivityLogModal", () => {
// Check that confirmation dialog appears // Check that confirmation dialog appears
expect(screen.getByText(/Clear Activity Log/i)).toBeTruthy(); 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();
});
});
}); });

View File

@@ -547,4 +547,107 @@ describe("Header", () => {
const btn = screen.getByTestId("agents-btn"); const btn = screen.getByTestId("agents-btn");
expect(btn).toBeDefined(); 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();
});
}); });

View File

@@ -24,7 +24,16 @@ function compareTimestamps(a: string | undefined, b: string | undefined): number
return a.localeCompare(b); 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 [tasks, setTasks] = useState<Task[]>([]);
const [connectionNonce, setConnectionNonce] = useState(0); const [connectionNonce, setConnectionNonce] = useState(0);
const tasksRef = useRef(tasks); const tasksRef = useRef(tasks);
@@ -34,10 +43,20 @@ export function useTasks() {
const lastVisibilityFetchRef = useRef<number>(0); const lastVisibilityFetchRef = useRef<number>(0);
const VISIBILITY_FETCH_DEBOUNCE_MS = 1000; 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 // Fetch initial tasks
useEffect(() => { 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 // Visibility change listener - refresh tasks when tab becomes visible
useEffect(() => { useEffect(() => {
@@ -49,7 +68,7 @@ export function useTasks() {
// Debounce: only fetch if at least 1 second has passed since last visibility fetch // Debounce: only fetch if at least 1 second has passed since last visibility fetch
if (timeSinceLastFetch >= VISIBILITY_FETCH_DEBOUNCE_MS) { if (timeSinceLastFetch >= VISIBILITY_FETCH_DEBOUNCE_MS) {
lastVisibilityFetchRef.current = now; lastVisibilityFetchRef.current = now;
api.fetchTasks() fetchTasksFn()
.then((tasks) => setTasks(tasks.map(normalizeTask))) .then((tasks) => setTasks(tasks.map(normalizeTask)))
.catch(() => { .catch(() => {
// Silently ignore fetch errors on visibility change // Silently ignore fetch errors on visibility change
@@ -63,9 +82,12 @@ export function useTasks() {
return () => { return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, []); }, [fetchTasksFn]);
// SSE live updates // 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(() => { useEffect(() => {
let closedByCleanup = false; let closedByCleanup = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -73,12 +95,17 @@ export function useTasks() {
const handleCreated = (e: MessageEvent) => { const handleCreated = (e: MessageEvent) => {
const task = normalizeTask(JSON.parse(e.data) as Task); 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) => { 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 { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
const normalizedTask = normalizeTask(task); const normalizedTask = normalizeTask(task);
setTasks((prev) => setTasks((prev) =>
@@ -94,35 +121,24 @@ export function useTasks() {
prev.map((t) => { prev.map((t) => {
if (t.id !== incoming.id) return t; if (t.id !== incoming.id) return t;
// First check overall freshness using updatedAt
const updatedAtCompare = compareTimestamps(incoming.updatedAt, t.updatedAt); const updatedAtCompare = compareTimestamps(incoming.updatedAt, t.updatedAt);
// If incoming is older overall, skip the update
if (updatedAtCompare < 0) { if (updatedAtCompare < 0) {
return t; return t;
} }
// If columns are the same, no conflict - accept the incoming update
if (t.column === incoming.column) { if (t.column === incoming.column) {
return incoming; return incoming;
} }
// Columns differ - need to check columnMovedAt to resolve conflict
const columnTimestampCompare = compareTimestamps(t.columnMovedAt, incoming.columnMovedAt); 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) { if (t.columnMovedAt && !incoming.columnMovedAt) {
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt }; return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
} }
// If current state has a newer columnMovedAt, reject the column change
if (columnTimestampCompare > 0) { if (columnTimestampCompare > 0) {
// Current state is newer - preserve column, merge other fields
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt }; return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
} }
// Incoming has newer or equal columnMovedAt, accept the update
return incoming; return incoming;
}) })
); );
@@ -134,13 +150,10 @@ export function useTasks() {
}; };
const handleMerged = (e: MessageEvent) => { 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 { task }: { task: Task } = JSON.parse(e.data);
const normalizedTask = normalizeTask(task); const normalizedTask = normalizeTask(task);
setTasks((prev) => setTasks((prev) =>
prev.map((t) => 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 t.id === normalizedTask.id ? { ...normalizedTask, column: "done" as Column } : t
) )
); );
@@ -211,7 +224,6 @@ export function useTasks() {
id: string, id: string,
updates: { title?: string; description?: string; dependencies?: string[] } updates: { title?: string; description?: string; dependencies?: string[] }
): Promise<Task> => { ): Promise<Task> => {
// Optimistic update: apply changes immediately
const previousTask = tasksRef.current.find((t) => t.id === id); const previousTask = tasksRef.current.find((t) => t.id === id);
const optimisticTask = previousTask const optimisticTask = previousTask
? { ...previousTask, ...updates, updatedAt: new Date().toISOString() } ? { ...previousTask, ...updates, updatedAt: new Date().toISOString() }
@@ -225,13 +237,11 @@ export function useTasks() {
try { try {
const updatedTask = normalizeTask(await api.updateTask(id, updates)); const updatedTask = normalizeTask(await api.updateTask(id, updates));
// Replace with server response
setTasks((prev) => setTasks((prev) =>
prev.map((t) => (t.id === id ? updatedTask : t)) prev.map((t) => (t.id === id ? updatedTask : t))
); );
return updatedTask; return updatedTask;
} catch (err) { } catch (err) {
// Rollback on error: restore previous state
if (previousTask) { if (previousTask) {
setTasks((prev) => setTasks((prev) =>
prev.map((t) => (t.id === id ? previousTask : t)) prev.map((t) => (t.id === id ? previousTask : t))
@@ -260,7 +270,6 @@ export function useTasks() {
const archiveAllDone = useCallback(async (): Promise<Task[]> => { const archiveAllDone = useCallback(async (): Promise<Task[]> => {
const archived = await api.archiveAllDone(); const archived = await api.archiveAllDone();
const normalized = archived.map(normalizeTask); const normalized = archived.map(normalizeTask);
// Update local state by mapping over tasks and updating archived ones
setTasks((prev) => setTasks((prev) =>
prev.map((t) => { prev.map((t) => {
const updated = normalized.find((archived) => archived.id === t.id); const updated = normalized.find((archived) => archived.id === t.id);

View File

@@ -72,6 +72,8 @@ import {
fetchActivityFeed, fetchActivityFeed,
fetchFirstRunStatus, fetchFirstRunStatus,
fetchGlobalConcurrency, fetchGlobalConcurrency,
fetchProjectTasks,
fetchTasks,
type ProjectInfo, type ProjectInfo,
type DetectedProject, type DetectedProject,
} from "../../app/api"; } from "../../app/api";
@@ -344,4 +346,90 @@ describe("Project Routes API Functions", () => {
expect(result.currentlyActive).toBe(2); expect(result.currentlyActive).toBe(2);
}); });
}); });
describe("fetchProjectTasks", () => {
it("sends projectId as query parameter to /api/tasks", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
await fetchProjectTasks("proj_abc");
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/tasks"),
expect.any(Object)
);
const url: string = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(url).toContain("projectId=proj_abc");
});
it("returns tasks from the project's store when projectId is provided", async () => {
const mockTasks = [
{
id: "FN-001",
description: "Fix the bug",
column: "todo",
dependencies: [],
steps: [],
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockTasks));
const result = await fetchProjectTasks("proj_abc");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("FN-001");
});
it("returns 404 when project is not found", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Project not found" }, 404)
);
await expect(fetchProjectTasks("nonexistent_proj")).rejects.toThrow();
});
it("returns empty array on graceful degradation when backend error occurs", async () => {
// Backend returns 200 with [] for CentralCore unavailability
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
const result = await fetchProjectTasks("proj_abc");
expect(result).toEqual([]);
});
it("supports limit and offset parameters alongside projectId", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
await fetchProjectTasks("proj_abc", 10, 20);
const url: string = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(url).toContain("projectId=proj_abc");
expect(url).toContain("limit=10");
expect(url).toContain("offset=20");
});
});
describe("fetchTasks (default store - no projectId)", () => {
it("fetches from /api/tasks without projectId when no project is specified", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
await fetchTasks();
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/tasks",
expect.any(Object)
);
});
it("does not include projectId parameter in default fetchTasks call", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
await fetchTasks();
const url: string = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(url).not.toContain("projectId");
});
});
}); });

View File

@@ -25,12 +25,24 @@ vi.mock("@fusion/core", async () => {
return { return {
...actual, ...actual,
isGhAuthenticated: vi.fn(), isGhAuthenticated: vi.fn(),
CentralCore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProject: vi.fn().mockResolvedValue(null),
})),
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
close: vi.fn(),
})),
}; };
}); });
import { isGhAuthenticated } from "@fusion/core"; import { isGhAuthenticated, CentralCore, TaskStore as TaskStoreClass } from "@fusion/core";
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated); const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
const MockCentralCore = vi.mocked(CentralCore);
const MockTaskStoreClass = vi.mocked(TaskStoreClass);
function createMockGlobalSettingsStore() { function createMockGlobalSettingsStore() {
return { return {
@@ -173,6 +185,118 @@ describe("GET /tasks", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.error).toContain("limit"); expect(res.body.error).toContain("limit");
}); });
describe("with projectId query parameter", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 404 when project is not found", async () => {
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProject: vi.fn().mockResolvedValue(null),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const res = await GET(buildApp(), "/api/tasks?projectId=nonexistent");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Project not found");
});
it("returns tasks from the project store when project is found", async () => {
const projectPath = "/test/project/path";
const projectTasks = [FAKE_TASK_DETAIL];
const mockProjectStoreInstance = {
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue(projectTasks),
close: vi.fn(),
};
MockTaskStoreClass.mockImplementation(() => mockProjectStoreInstance as any);
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProject: vi.fn().mockResolvedValue({
id: "proj_abc",
name: "Test Project",
path: projectPath,
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe("FN-001");
// Verify project store was initialized with correct path
expect(MockTaskStoreClass).toHaveBeenCalledWith(projectPath);
expect(mockProjectStoreInstance.init).toHaveBeenCalled();
expect(mockProjectStoreInstance.listTasks).toHaveBeenCalledWith({ limit: undefined, offset: undefined });
expect(mockProjectStoreInstance.close).toHaveBeenCalled();
// Verify CentralCore was properly closed
expect(mockCentralInstance.close).toHaveBeenCalled();
});
it("passes limit and offset to the project store", async () => {
const mockProjectStoreInstance = {
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
close: vi.fn(),
};
MockTaskStoreClass.mockImplementation(() => mockProjectStoreInstance as any);
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProject: vi.fn().mockResolvedValue({
id: "proj_abc",
name: "Test Project",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc&limit=5&offset=10");
expect(res.status).toBe(200);
expect(mockProjectStoreInstance.listTasks).toHaveBeenCalledWith({ limit: 5, offset: 10 });
});
it("returns 200 with empty array on graceful degradation when CentralCore is unavailable", async () => {
MockCentralCore.mockImplementation(() => {
throw new Error("CentralCore unavailable");
});
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("still uses default store when projectId is not provided", async () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
const res = await GET(buildApp(), "/api/tasks");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(store.listTasks).toHaveBeenCalled();
// CentralCore should not be used when no projectId
expect(MockCentralCore).not.toHaveBeenCalled();
});
});
}); });
describe("GET /tasks/:id", () => { describe("GET /tasks/:id", () => {

View File

@@ -1442,6 +1442,42 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return; return;
} }
// When projectId is provided, delegate to that project's TaskStore via CentralCore
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
if (projectId) {
try {
const { CentralCore, TaskStore: ProjectTaskStore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
let project;
try {
project = await central.getProject(projectId);
} finally {
await central.close();
}
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
const projectStore = new ProjectTaskStore(project.path);
await projectStore.init();
try {
const tasks = await projectStore.listTasks({ limit, offset });
res.json(tasks);
} finally {
projectStore.close();
}
} catch (err: any) {
if (res.headersSent) return;
// Graceful degradation: CentralCore unavailable or other error → empty array
res.json([]);
}
return;
}
const tasks = await store.listTasks({ limit, offset }); const tasks = await store.listTasks({ limit, offset });
res.json(tasks); res.json(tasks);
} catch (err: any) { } catch (err: any) {