feat(KB-502): implement dashboard multi-project UX

- Add project selector, health badge, and overview components to dashboard
- Add multi-project API functions (api.ts) for cross-project operations
- Update App.tsx with project context and navigation improvements
- Add script-store.ts for centralized project script management
- Extend types with multi-project support: SetupState, MigrationOptions, etc.
- Add project runtime fields: baseCommitSha, modifiedFiles, missionId, sliceId
- Add setupComplete to global settings for first-run wizard tracking
- Add project-level settings: scripts, setupScript, ntfyDashboardHost
- Add backward-compatible addSteeringComment method to TaskStore
This commit is contained in:
gsxdsm
2026-04-01 16:41:11 -07:00
parent c4b611605e
commit dac68ab2f7
9 changed files with 647 additions and 94 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from "react";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode, ProjectInfo } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail } from "./api";
import type { ModelInfo } from "./api";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api";
import type { ModelInfo, ProjectInfo } from "./api";
import { Header } from "./components/Header";
import { Board } from "./components/Board";
import { ListView } from "./components/ListView";
@@ -25,13 +25,49 @@ import { ActivityLogModal } from "./components/ActivityLogModal";
import { WorkflowStepManager } from "./components/WorkflowStepManager";
import { AgentListModal } from "./components/AgentListModal";
import { AgentsView } from "./components/AgentsView";
import { ScriptsModal } from "./components/ScriptsModal";
import { useTasks } from "./hooks/useTasks";
import { useProjects } from "./hooks/useProjects";
import { useCurrentProject } from "./hooks/useCurrentProject";
import { ToastProvider, useToast } from "./hooks/useToast";
import { useTheme } from "./hooks/useTheme";
type ViewMode = "overview" | "project";
type TaskView = "board" | "list" | "agents";
function AppInner() {
const { toasts, addToast, removeToast } = useToast();
// Project management hooks - MUST be called before any conditional logic
const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects, register: registerProject, update: updateProjectHook, unregister: unregisterProjectHook } = useProjects();
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
// Tasks hook with project context
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks(
currentProject ? { projectId: currentProject.id } : undefined
);
// Theme management
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
// View state
const [viewMode, setViewMode] = useState<ViewMode>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-view-mode");
if (saved === "overview" || saved === "project") return saved;
}
return "overview";
});
const [taskView, setTaskView] = useState<TaskView>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-task-view");
if (saved === "board" || saved === "list" || saved === "agents") return saved;
}
return "board";
});
// Modal states
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
@@ -53,36 +89,38 @@ function AppInner() {
const [scriptsOpen, setScriptsOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
// Settings state
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [rootDir, setRootDir] = useState<string>(".");
const [autoMerge, setAutoMerge] = useState(true);
const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = useState(false);
const [view, setView] = useState<"board" | "list" | "agents">(() => {
// Initialize from localStorage if available
if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-view");
if (saved === "list" || saved === "board" || saved === "agents") {
return saved;
}
}
return "board";
});
const [searchQuery, setSearchQuery] = useState("");
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
// Setup wizard state
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
// Persist view mode
useEffect(() => {
localStorage.setItem("kb-dashboard-view-mode", viewMode);
}, [viewMode]);
// Tasks hook with project context
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks(
currentProject ? { projectId: currentProject.id } : undefined
);
// Persist task view
useEffect(() => {
localStorage.setItem("kb-dashboard-task-view", taskView);
}, [taskView]);
// Theme management
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
// Sync view mode when current project is restored from localStorage
useEffect(() => {
// Wait for both loading states to complete before syncing
if (projectsLoading || currentProjectLoading) return;
// If we have a restored current project but viewMode is overview, sync to project view
if (currentProject && viewMode === "overview") {
setViewMode("project");
}
}, [projectsLoading, currentProjectLoading, currentProject, viewMode]);
// Auto-open setup wizard on first run (no projects)
useEffect(() => {
@@ -93,7 +131,6 @@ function AppInner() {
if (setupWizardOpen) return;
// Don't open if we have projects OR a saved current project
// (currentProject from localStorage means user was previously viewing a project)
if (projects.length > 0 || currentProject) return;
// Only open when truly no projects exist and no project is being restored
@@ -103,29 +140,6 @@ function AppInner() {
return () => clearTimeout(timer);
}, [projectsLoading, projects.length, currentProjectLoading, currentProject, setupWizardOpen]);
// Persist view mode
useEffect(() => {
localStorage.setItem("kb-dashboard-view-mode", viewMode);
}, [viewMode]);
// Sync view mode when current project is restored from localStorage
// This ensures that if the user refreshed while viewing a specific project,
// they return to project view instead of the overview
useEffect(() => {
// Wait for both loading states to complete before syncing
if (projectsLoading || currentProjectLoading) return;
// If we have a restored current project but viewMode is overview, sync to project view
if (currentProject && viewMode === "overview") {
setViewMode("project");
}
}, [projectsLoading, currentProjectLoading, currentProject]); // intentionally NOT depending on viewMode
// Persist task view
useEffect(() => {
localStorage.setItem("kb-dashboard-task-view", taskView);
}, [taskView]);
// Theme toggle handler: cycles Dark → Light → System → Dark
const handleToggleTheme = useCallback(() => {
const cycle: ThemeMode[] = ["dark", "light", "system"];
@@ -134,6 +148,7 @@ function AppInner() {
setThemeMode(nextMode);
}, [themeMode, setThemeMode]);
// Initial data fetch
useEffect(() => {
fetchConfig()
.then((cfg) => {
@@ -156,7 +171,7 @@ function AppInner() {
setSettingsInitialSection("authentication");
}
})
.catch(() => {/* fail silently — do not auto-open */});
.catch(() => {/* fail silently */});
}, []);
// Fetch available models
@@ -165,7 +180,6 @@ function AppInner() {
.then((models) => setAvailableModels(models))
.catch(() => {/* keep empty array on failure */});
}, []);
const { toasts, addToast, removeToast } = useToast();
// Handle deep link to task on mount
useEffect(() => {
@@ -173,8 +187,18 @@ function AppInner() {
const taskId = params.get("task");
if (!taskId) return;
const handleChangeView = useCallback((newView: "board" | "list" | "agents") => {
setView(newView);
fetchTaskDetail(taskId)
.then((detail) => {
setDetailTask(detail);
})
.catch(() => {
addToast(`Task ${taskId} not found`, "error");
});
}, [addToast]);
// View change handlers
const handleChangeTaskView = useCallback((newView: TaskView) => {
setTaskView(newView);
}, []);
// Project selection handlers
@@ -192,23 +216,33 @@ function AppInner() {
setSetupWizardOpen(true);
}, []);
const handleSetupComplete = useCallback((project: ProjectInfo) => {
setSetupWizardOpen(false);
setCurrentProject(project);
setViewMode("project");
addToast(`Project ${project.name} registered successfully`, "success");
refreshProjects();
}, [setCurrentProject, addToast, refreshProjects]);
const handlePauseProject = useCallback(async (project: ProjectInfo) => {
try {
await updateProject(project.id, { status: "paused" });
addToast(`Project ${project.name} paused`, "success");
refreshProjects();
} catch {
addToast(`Failed to pause project ${project.name}`, "error");
}
}, [updateProject, addToast]);
}, [addToast, refreshProjects]);
const handleResumeProject = useCallback(async (project: ProjectInfo) => {
try {
await updateProject(project.id, { status: "active" });
addToast(`Project ${project.name} resumed`, "success");
refreshProjects();
} catch {
addToast(`Failed to resume project ${project.name}`, "error");
}
}, [updateProject, addToast]);
}, [addToast, refreshProjects]);
const handleRemoveProject = useCallback(async (project: ProjectInfo) => {
try {
@@ -219,11 +253,13 @@ function AppInner() {
clearCurrentProject();
setViewMode("overview");
}
refreshProjects();
} catch {
addToast(`Failed to remove project ${project.name}`, "error");
}
}, [unregisterProject, currentProject, clearCurrentProject, addToast]);
}, [unregisterProject, currentProject, clearCurrentProject, addToast, refreshProjects]);
// Task handlers
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
@@ -358,33 +394,44 @@ function AppInner() {
const handleOpenAgents = useCallback(() => setAgentsOpen(true), []);
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
return (
<>
<Header
onOpenSettings={() => setSettingsOpen(true)}
onOpenGitHubImport={() => setGitHubImportOpen(true)}
onOpenPlanning={handlePlanningOpen}
onOpenUsage={handleOpenUsage}
onOpenActivityLog={handleOpenActivityLog}
onOpenSchedules={handleOpenSchedules}
onOpenGitManager={handleOpenGitManager}
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
onOpenAgents={handleOpenAgents}
onToggleTerminal={handleToggleTerminal}
onOpenFiles={handleOpenFiles}
filesOpen={filesOpen}
globalPaused={globalPaused}
enginePaused={enginePaused}
onToggleGlobalPause={handleToggleGlobalPause}
onToggleEnginePause={handleToggleEnginePause}
view={view}
onChangeView={handleChangeView}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
{view === "agents" ? (
<AgentsView addToast={addToast} />
) : view === "board" ? (
// Scripts handlers
const handleOpenScripts = useCallback(() => setScriptsOpen(true), []);
const handleCloseScripts = useCallback(() => setScriptsOpen(false), []);
const handleRunScript = useCallback((name: string, command: string) => {
setTerminalInitialCommand(command);
setTerminalOpen(true);
addToast(`Running script: ${name}`, "info");
}, [addToast]);
// Terminal close handler
const handleTerminalClose = useCallback(() => {
setTerminalOpen(false);
setTerminalInitialCommand(undefined);
}, []);
// Render main content based on view mode
const renderMainContent = () => {
if (viewMode === "overview") {
return (
<ProjectOverview
projects={projects}
loading={projectsLoading}
onSelectProject={handleSelectProject}
onAddProject={handleAddProject}
onPauseProject={handlePauseProject}
onResumeProject={handleResumeProject}
onRemoveProject={handleRemoveProject}
/>
);
}
// Project view
if (taskView === "agents") {
return <AgentsView addToast={addToast} />;
}
if (taskView === "board") {
return (
<Board
tasks={tasks}
maxConcurrent={maxConcurrent}
@@ -448,7 +495,7 @@ function AppInner() {
onToggleGlobalPause={handleToggleGlobalPause}
onToggleEnginePause={handleToggleEnginePause}
view={taskView}
onChangeView={setTaskView}
onChangeView={handleChangeTaskView}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
projects={projects}

View File

@@ -1868,3 +1868,100 @@ export function fetchProjectTasks(projectId: string, limit?: number, offset?: nu
export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> {
return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`);
}
/** Detected project information */
export interface DetectedProject {
path: string;
suggestedName: string;
existing: boolean;
}
/** Detect projects in a base path */
export function detectProjects(basePath?: string): Promise<{ projects: DetectedProject[] }> {
return api<{ projects: DetectedProject[] }>("/projects/detect", {
method: "POST",
body: JSON.stringify({ basePath }),
});
}
/** Fetch a single project by ID */
export function fetchProject(id: string): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`);
}
/** Update an existing project */
export function updateProject(id: string, updates: Partial<ProjectInfo>): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
// ── Scripts API ──────────────────────────────────────────────────────────
/** Fetch all saved scripts */
export function fetchScripts(): Promise<Record<string, string>> {
return api<Record<string, string>>("/scripts");
}
/** Add or update a script */
export function addScript(name: string, command: string): Promise<Record<string, string>> {
return api<Record<string, string>>("/scripts", {
method: "POST",
body: JSON.stringify({ name, command }),
});
}
/** Remove a script */
export function removeScript(name: string): Promise<Record<string, string>> {
return api<Record<string, string>>(`/scripts/${encodeURIComponent(name)}`, {
method: "DELETE",
});
}
// ── Task Diff API ──────────────────────────────────────────────────────────
/** Task diff information */
export interface TaskDiff {
files: Array<{
path: string;
status: "added" | "modified" | "deleted";
additions: number;
deletions: number;
patch: string;
}>;
stats: {
filesChanged: number;
additions: number;
deletions: number;
};
}
/** Fetch diff for a task's changes */
export function fetchTaskDiff(taskId: string, worktree?: string): Promise<TaskDiff> {
const params = new URLSearchParams();
if (worktree) params.set("worktree", worktree);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff${query}`);
}
/** Individual file diff */
export interface TaskFileDiff {
path: string;
status: "added" | "modified" | "deleted";
additions: number;
deletions: number;
patch: string;
}
/** Fetch file diffs for a task */
export function fetchTaskFileDiffs(taskId: string, worktree?: string): Promise<TaskFileDiff[]> {
const params = new URLSearchParams();
if (worktree) params.set("worktree", worktree);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<TaskFileDiff[]>(`/tasks/${encodeURIComponent(taskId)}/file-diffs${query}`);
}

View File

@@ -1,6 +1,6 @@
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, ChevronLeft } from "lucide-react";
import type { ProjectInfo } from "@fusion/core";
import type { ProjectInfo } from "../api";
import { ProjectSelector } from "./ProjectSelector";
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from "react";
import { Play, Pause, AlertCircle, Loader2 } from "lucide-react";
import type { ProjectStatus, ProjectHealth } from "@fusion/core";
import type { ProjectStatus } from "@fusion/core";
import type { ProjectHealth } from "../api";
export interface ProjectHealthBadgeProps {
status: ProjectStatus;

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox } from "lucide-react";
import type { ProjectInfo, ProjectHealth, ProjectStatus } from "@fusion/core";
import type { ProjectInfo, ProjectHealth, ProjectStatus } from "../api";
import { ProjectCard } from "./ProjectCard";
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
import { useProjectHealth } from "../hooks/useProjectHealth";

View File

@@ -12,7 +12,7 @@ import {
Loader2,
X,
} from "lucide-react";
import type { ProjectInfo, ProjectStatus } from "@fusion/core";
import type { ProjectInfo, ProjectStatus } from "../api";
export interface ProjectSelectorProps {
projects: ProjectInfo[];