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:
5
.changeset/fix-blank-list-view-project-tasks.md
Normal file
5
.changeset/fix-blank-list-view-project-tasks.md
Normal 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.
|
||||||
96
AGENTS.md
96
AGENTS.md
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
// Sync with external projectId prop
|
||||||
|
|
||||||
const loadActivityLog = useCallback(async (since?: string) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const options: { limit: number; since?: string; type?: ActivityEventType } = {
|
|
||||||
limit: 100,
|
|
||||||
since,
|
|
||||||
};
|
|
||||||
if (filteredType !== "all") {
|
|
||||||
options.type = filteredType;
|
|
||||||
}
|
|
||||||
const data = await fetchActivityLog(options);
|
|
||||||
if (since) {
|
|
||||||
// Append older entries
|
|
||||||
setEntries((prev) => [...prev, ...data]);
|
|
||||||
} else {
|
|
||||||
// Replace with fresh entries
|
|
||||||
setEntries(data);
|
|
||||||
}
|
|
||||||
setHasMore(data.length === 100);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to load activity log");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [filteredType]);
|
|
||||||
|
|
||||||
// Initial load and filter change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
setFilteredProjectId(projectId || "all");
|
||||||
loadActivityLog();
|
}, [projectId]);
|
||||||
}
|
|
||||||
}, [isOpen, loadActivityLog]);
|
// 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,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
hasMore
|
||||||
|
} = useActivityLog({
|
||||||
|
projectId: activeProjectId,
|
||||||
|
type: activityType,
|
||||||
|
limit: 100,
|
||||||
|
autoRefresh: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-refresh every 30 seconds when modal is open
|
// Convert entries to ActivityLogEntry format for compatibility
|
||||||
useEffect(() => {
|
const convertedEntries: ActivityLogEntry[] = entries.map((entry: ActivityFeedEntry) => ({
|
||||||
if (isOpen) {
|
id: entry.id,
|
||||||
pollingRef.current = setInterval(() => {
|
timestamp: entry.timestamp,
|
||||||
loadActivityLog();
|
type: entry.type,
|
||||||
}, 30000);
|
taskId: entry.taskId,
|
||||||
}
|
taskTitle: entry.taskTitle,
|
||||||
return () => {
|
details: entry.details,
|
||||||
if (pollingRef.current) {
|
metadata: entry.metadata,
|
||||||
clearInterval(pollingRef.current);
|
projectId: entry.projectId,
|
||||||
pollingRef.current = null;
|
projectName: entry.projectName,
|
||||||
}
|
}));
|
||||||
};
|
|
||||||
}, [isOpen, loadActivityLog]);
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
if (entries.length > 0) {
|
|
||||||
const lastEntry = entries[entries.length - 1];
|
|
||||||
loadActivityLog(lastEntry.timestamp);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user