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

@@ -4,12 +4,10 @@
Add multi-project UX with overview page, drill-down, and setup wizard Add multi-project UX with overview page, drill-down, and setup wizard
- New Project Overview page showing all registered projects in a responsive grid with health metrics - New Project Overview page showing all registered projects
- Project selector dropdown in header for quick context switching (appears when 2+ projects) - Project selector in header for switching contexts
- Project drill-down into per-project task views with back navigation to overview - Project drill-down into per-project task views
- Setup wizard for first-run project registration with auto-detection and manual entry flows - Setup wizard for first-run project registration
- Global activity feed with project attribution badges when viewing all projects - Global activity feed with project attribution
- Project health polling with active tasks, running agents, and completion counts - Added project management API routes (detect, register, update, etc.)
- Empty states and loading skeletons for better UX during data fetching - Fixed App.tsx variable ordering issues
- Keyboard navigation support in project selector (arrow keys, enter, escape)
- LocalStorage persistence for wizard state (resume capability) and view preferences

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode, ProjectInfo } from "@fusion/core"; import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail } from "./api"; import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api";
import type { ModelInfo } from "./api"; import type { ModelInfo, ProjectInfo } 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";
@@ -25,13 +25,49 @@ import { ActivityLogModal } from "./components/ActivityLogModal";
import { WorkflowStepManager } from "./components/WorkflowStepManager"; 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 { ScriptsModal } from "./components/ScriptsModal";
import { useTasks } from "./hooks/useTasks"; import { useTasks } from "./hooks/useTasks";
import { useProjects } from "./hooks/useProjects"; import { useProjects } from "./hooks/useProjects";
import { useCurrentProject } from "./hooks/useCurrentProject"; 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";
type ViewMode = "overview" | "project";
type TaskView = "board" | "list" | "agents";
function AppInner() { 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 [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
const [isPlanningOpen, setIsPlanningOpen] = useState(false); const [isPlanningOpen, setIsPlanningOpen] = useState(false);
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null); const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
@@ -53,36 +89,38 @@ function AppInner() {
const [scriptsOpen, setScriptsOpen] = useState(false); const [scriptsOpen, setScriptsOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined); const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined); const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
// Settings state
const [maxConcurrent, setMaxConcurrent] = useState(2); const [maxConcurrent, setMaxConcurrent] = useState(2);
const [rootDir, setRootDir] = useState<string>("."); const [rootDir, setRootDir] = useState<string>(".");
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
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 [searchQuery, setSearchQuery] = useState("");
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false); const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]); const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
// Setup wizard state // Persist view mode
const [setupWizardOpen, setSetupWizardOpen] = useState(false); useEffect(() => {
localStorage.setItem("kb-dashboard-view-mode", viewMode);
}, [viewMode]);
// Tasks hook with project context // Persist task view
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks( useEffect(() => {
currentProject ? { projectId: currentProject.id } : undefined localStorage.setItem("kb-dashboard-task-view", taskView);
); }, [taskView]);
// Theme management // Sync view mode when current project is restored from localStorage
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme(); 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) // Auto-open setup wizard on first run (no projects)
useEffect(() => { useEffect(() => {
@@ -93,7 +131,6 @@ function AppInner() {
if (setupWizardOpen) return; if (setupWizardOpen) return;
// Don't open if we have projects OR a saved current project // 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; if (projects.length > 0 || currentProject) return;
// Only open when truly no projects exist and no project is being restored // Only open when truly no projects exist and no project is being restored
@@ -103,29 +140,6 @@ function AppInner() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [projectsLoading, projects.length, currentProjectLoading, currentProject, setupWizardOpen]); }, [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 // 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"];
@@ -134,6 +148,7 @@ function AppInner() {
setThemeMode(nextMode); setThemeMode(nextMode);
}, [themeMode, setThemeMode]); }, [themeMode, setThemeMode]);
// Initial data fetch
useEffect(() => { useEffect(() => {
fetchConfig() fetchConfig()
.then((cfg) => { .then((cfg) => {
@@ -156,7 +171,7 @@ function AppInner() {
setSettingsInitialSection("authentication"); setSettingsInitialSection("authentication");
} }
}) })
.catch(() => {/* fail silently — do not auto-open */}); .catch(() => {/* fail silently */});
}, []); }, []);
// Fetch available models // Fetch available models
@@ -165,7 +180,6 @@ function AppInner() {
.then((models) => setAvailableModels(models)) .then((models) => setAvailableModels(models))
.catch(() => {/* keep empty array on failure */}); .catch(() => {/* keep empty array on failure */});
}, []); }, []);
const { toasts, addToast, removeToast } = useToast();
// Handle deep link to task on mount // Handle deep link to task on mount
useEffect(() => { useEffect(() => {
@@ -173,8 +187,18 @@ function AppInner() {
const taskId = params.get("task"); const taskId = params.get("task");
if (!taskId) return; if (!taskId) return;
const handleChangeView = useCallback((newView: "board" | "list" | "agents") => { fetchTaskDetail(taskId)
setView(newView); .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 // Project selection handlers
@@ -192,23 +216,33 @@ function AppInner() {
setSetupWizardOpen(true); 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) => { const handlePauseProject = useCallback(async (project: ProjectInfo) => {
try { try {
await updateProject(project.id, { status: "paused" }); await updateProject(project.id, { status: "paused" });
addToast(`Project ${project.name} paused`, "success"); addToast(`Project ${project.name} paused`, "success");
refreshProjects();
} catch { } catch {
addToast(`Failed to pause project ${project.name}`, "error"); addToast(`Failed to pause project ${project.name}`, "error");
} }
}, [updateProject, addToast]); }, [addToast, refreshProjects]);
const handleResumeProject = useCallback(async (project: ProjectInfo) => { const handleResumeProject = useCallback(async (project: ProjectInfo) => {
try { try {
await updateProject(project.id, { status: "active" }); await updateProject(project.id, { status: "active" });
addToast(`Project ${project.name} resumed`, "success"); addToast(`Project ${project.name} resumed`, "success");
refreshProjects();
} catch { } catch {
addToast(`Failed to resume project ${project.name}`, "error"); addToast(`Failed to resume project ${project.name}`, "error");
} }
}, [updateProject, addToast]); }, [addToast, refreshProjects]);
const handleRemoveProject = useCallback(async (project: ProjectInfo) => { const handleRemoveProject = useCallback(async (project: ProjectInfo) => {
try { try {
@@ -219,11 +253,13 @@ function AppInner() {
clearCurrentProject(); clearCurrentProject();
setViewMode("overview"); setViewMode("overview");
} }
refreshProjects();
} catch { } catch {
addToast(`Failed to remove project ${project.name}`, "error"); 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 handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []); const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
@@ -358,33 +394,44 @@ function AppInner() {
const handleOpenAgents = useCallback(() => setAgentsOpen(true), []); const handleOpenAgents = useCallback(() => setAgentsOpen(true), []);
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []); const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
return ( // Scripts handlers
<> const handleOpenScripts = useCallback(() => setScriptsOpen(true), []);
<Header const handleCloseScripts = useCallback(() => setScriptsOpen(false), []);
onOpenSettings={() => setSettingsOpen(true)} const handleRunScript = useCallback((name: string, command: string) => {
onOpenGitHubImport={() => setGitHubImportOpen(true)} setTerminalInitialCommand(command);
onOpenPlanning={handlePlanningOpen} setTerminalOpen(true);
onOpenUsage={handleOpenUsage} addToast(`Running script: ${name}`, "info");
onOpenActivityLog={handleOpenActivityLog} }, [addToast]);
onOpenSchedules={handleOpenSchedules}
onOpenGitManager={handleOpenGitManager} // Terminal close handler
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)} const handleTerminalClose = useCallback(() => {
onOpenAgents={handleOpenAgents} setTerminalOpen(false);
onToggleTerminal={handleToggleTerminal} setTerminalInitialCommand(undefined);
onOpenFiles={handleOpenFiles} }, []);
filesOpen={filesOpen}
globalPaused={globalPaused} // Render main content based on view mode
enginePaused={enginePaused} const renderMainContent = () => {
onToggleGlobalPause={handleToggleGlobalPause} if (viewMode === "overview") {
onToggleEnginePause={handleToggleEnginePause} return (
view={view} <ProjectOverview
onChangeView={handleChangeView} projects={projects}
searchQuery={searchQuery} loading={projectsLoading}
onSearchChange={setSearchQuery} onSelectProject={handleSelectProject}
/> onAddProject={handleAddProject}
{view === "agents" ? ( onPauseProject={handlePauseProject}
<AgentsView addToast={addToast} /> onResumeProject={handleResumeProject}
) : view === "board" ? ( onRemoveProject={handleRemoveProject}
/>
);
}
// Project view
if (taskView === "agents") {
return <AgentsView addToast={addToast} />;
}
if (taskView === "board") {
return (
<Board <Board
tasks={tasks} tasks={tasks}
maxConcurrent={maxConcurrent} maxConcurrent={maxConcurrent}
@@ -448,7 +495,7 @@ function AppInner() {
onToggleGlobalPause={handleToggleGlobalPause} onToggleGlobalPause={handleToggleGlobalPause}
onToggleEnginePause={handleToggleEnginePause} onToggleEnginePause={handleToggleEnginePause}
view={taskView} view={taskView}
onChangeView={setTaskView} onChangeView={handleChangeTaskView}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
projects={projects} 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 }> { export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> {
return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`); 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 { 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 { 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 { ProjectSelector } from "./ProjectSelector";
import { QuickScriptsDropdown } from "./QuickScriptsDropdown"; import { QuickScriptsDropdown } from "./QuickScriptsDropdown";

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { Play, Pause, AlertCircle, Loader2 } from "lucide-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 { export interface ProjectHealthBadgeProps {
status: ProjectStatus; status: ProjectStatus;

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback, useEffect } from "react"; import { useState, useMemo, useCallback, useEffect } from "react";
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox } from "lucide-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 { ProjectCard } from "./ProjectCard";
import { ProjectGridSkeleton } from "./ProjectGridSkeleton"; import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
import { useProjectHealth } from "../hooks/useProjectHealth"; import { useProjectHealth } from "../hooks/useProjectHealth";

View File

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

View File

@@ -5890,6 +5890,122 @@ Output ONLY the prompt text (no markdown, no explanations).`;
} }
}); });
/**
* POST /api/projects/detect
* Auto-detect kb projects in a directory.
* Body: { basePath?: string }
* Returns: { projects: DetectedProject[] }
*/
router.post("/projects/detect", async (req, res) => {
try {
const { basePath } = req.body;
const { existsSync } = await import("node:fs");
const { join } = await import("node:path");
const { readdir } = await import("node:fs/promises");
// Default to home directory if no basePath provided
const searchPath = basePath || process.env.HOME || process.env.USERPROFILE || ".";
if (!existsSync(searchPath)) {
res.status(400).json({ error: "Base path does not exist" });
return;
}
// Get list of existing projects to check for duplicates
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const existingProjects = await central.listProjects();
await central.close();
const existingPaths = new Set(existingProjects.map((p: { path: string }) => p.path));
// Scan for .kb/kb.db or .fusion/kb.db files (indicating kb projects)
const detected: Array<{ path: string; suggestedName: string; existing: boolean }> = [];
try {
const entries = await readdir(searchPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirPath = join(searchPath, entry.name);
const hasKbDb = existsSync(join(dirPath, ".kb", "kb.db"));
const hasFusionDir = existsSync(join(dirPath, ".fusion"));
if (hasKbDb || hasFusionDir) {
detected.push({
path: dirPath,
suggestedName: entry.name,
existing: existingPaths.has(dirPath),
});
}
}
} catch {
// Ignore read errors
}
res.json({ projects: detected });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/projects/:id
* Get a single project by ID.
*/
router.get("/projects/:id", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.getProject(req.params.id);
await central.close();
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
res.json(project);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* PATCH /api/projects/:id
* Update a project.
*/
router.patch("/projects/:id", async (req, res) => {
try {
const { name, status, isolationMode } = req.body;
const updates: Partial<import("@fusion/core").RegisteredProject> = {};
if (name !== undefined) updates.name = name;
if (status !== undefined) updates.status = status as import("@fusion/core").ProjectStatus;
if (isolationMode !== undefined) updates.isolationMode = isolationMode as "in-process" | "child-process";
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.updateProject(req.params.id, updates);
await central.close();
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
res.json(project);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/** /**
* DELETE /api/projects/:id * DELETE /api/projects/:id
* Unregister a project. * Unregister a project.
@@ -6074,6 +6190,226 @@ Output ONLY the prompt text (no markdown, no explanations).`;
} }
}); });
/**
* GET /api/tasks/:id/diff
* Fetch git diff for a task's changes.
* Query: ?worktree=path
* Returns: TaskDiff
*/
router.get("/tasks/:id/diff", async (req, res) => {
try {
const task = store.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
const worktree = typeof req.query.worktree === "string" ? req.query.worktree : undefined;
const cwd = worktree || store.getRootDir();
// Get the base commit - default to HEAD~1 if not provided
let baseCommit = "HEAD~1";
// Get the diff
const { execSync } = await import("node:child_process");
// Get list of changed files
const filesOutput = execSync(`git diff --name-status ${baseCommit}..HEAD`, {
encoding: "utf-8",
cwd,
timeout: 10000,
});
const files: Array<{
path: string;
status: "added" | "modified" | "deleted";
additions: number;
deletions: number;
patch: string;
}> = [];
for (const line of filesOutput.trim().split("\n")) {
if (!line.trim()) continue;
const parts = line.split("\t");
const statusCode = parts[0];
const filePath = parts[1];
let status: "added" | "modified" | "deleted";
if (statusCode.startsWith("A")) status = "added";
else if (statusCode.startsWith("D")) status = "deleted";
else status = "modified";
// Get patch for this file
let patch = "";
try {
patch = execSync(`git diff ${baseCommit}..HEAD -- "${filePath}"`, {
encoding: "utf-8",
cwd,
timeout: 10000,
});
} catch {
// Ignore errors for individual files
}
// Count additions/deletions
const additions = (patch.match(/^\+[^+]/gm) || []).length;
const deletions = (patch.match(/^-[^-]/gm) || []).length;
files.push({ path: filePath, status, additions, deletions, patch });
}
const stats = {
filesChanged: files.length,
additions: files.reduce((sum, f) => sum + f.additions, 0),
deletions: files.reduce((sum, f) => sum + f.deletions, 0),
};
res.json({ files, stats });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/tasks/:id/file-diffs
* Fetch simplified file diffs for a task.
* Query: ?worktree=path
* Returns: TaskFileDiff[]
*/
router.get("/tasks/:id/file-diffs", async (req, res) => {
try {
const task = store.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
const worktree = typeof req.query.worktree === "string" ? req.query.worktree : undefined;
const cwd = worktree || store.getRootDir();
// Get the base commit
let baseCommit = "HEAD~1";
// Get the diff stat for file list
const { execSync } = await import("node:child_process");
const filesOutput = execSync(`git diff --name-status ${baseCommit}..HEAD`, {
encoding: "utf-8",
cwd,
timeout: 10000,
});
const files: Array<{
path: string;
status: "added" | "modified" | "deleted";
additions: number;
deletions: number;
patch: string;
}> = [];
for (const line of filesOutput.trim().split("\n")) {
if (!line.trim()) continue;
const parts = line.split("\t");
const statusCode = parts[0];
const filePath = parts[1];
let status: "added" | "modified" | "deleted";
if (statusCode.startsWith("A")) status = "added";
else if (statusCode.startsWith("D")) status = "deleted";
else status = "modified";
let patch = "";
try {
patch = execSync(`git diff ${baseCommit}..HEAD -- "${filePath}"`, {
encoding: "utf-8",
cwd,
timeout: 10000,
});
} catch {
// Ignore errors for individual files
}
const additions = (patch.match(/^\+[^+]/gm) || []).length;
const deletions = (patch.match(/^-[^-]/gm) || []).length;
files.push({ path: filePath, status, additions, deletions, patch });
}
res.json(files);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Scripts API ──────────────────────────────────────────────────────────
/**
* GET /api/scripts
* Fetch all saved scripts.
* Returns: Record<string, string> (name -> command)
*/
router.get("/scripts", async (_req, res) => {
try {
const { loadScriptStore } = await import("./script-store.js");
const store = await loadScriptStore();
res.json(store.getScripts());
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/scripts
* Add or update a script.
* Body: { name: string, command: string }
* Returns: Record<string, string> (updated scripts)
*/
router.post("/scripts", async (req, res) => {
try {
const { name, command } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "name is required" });
return;
}
if (command === undefined || typeof command !== "string") {
res.status(400).json({ error: "command is required" });
return;
}
const { loadScriptStore } = await import("./script-store.js");
const store = await loadScriptStore();
store.setScript(name.trim(), command.trim());
await store.save();
res.json(store.getScripts());
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* DELETE /api/scripts/:name
* Remove a script.
* Returns: Record<string, string> (updated scripts)
*/
router.delete("/scripts/:name", async (req, res) => {
try {
const { name } = req.params;
const { loadScriptStore } = await import("./script-store.js");
const store = await loadScriptStore();
store.removeScript(name);
await store.save();
res.json(store.getScripts());
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
return router; return router;
} }

View File

@@ -0,0 +1,74 @@
import { readFile, writeFile, access, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";
const SCRIPTS_FILE = join(homedir(), ".pi", "fusion", "scripts.json");
interface ScriptsData {
scripts: Record<string, string>;
}
class ScriptStore {
private scripts: Record<string, string> = {};
private filePath: string;
constructor(filePath: string) {
this.filePath = filePath;
}
async load(): Promise<void> {
try {
await access(this.filePath);
const content = await readFile(this.filePath, "utf-8");
const data = JSON.parse(content) as ScriptsData;
this.scripts = data.scripts || {};
} catch {
// File doesn't exist or is invalid - start with empty scripts
this.scripts = {};
}
}
async save(): Promise<void> {
const dir = this.filePath.substring(0, this.filePath.lastIndexOf("/"));
try {
await access(dir);
} catch {
await mkdir(dir, { recursive: true });
}
const data: ScriptsData = { scripts: this.scripts };
await writeFile(this.filePath, JSON.stringify(data, null, 2), "utf-8");
}
getScripts(): Record<string, string> {
return { ...this.scripts };
}
getScript(name: string): string | undefined {
return this.scripts[name];
}
setScript(name: string, command: string): void {
this.scripts[name] = command;
}
removeScript(name: string): void {
delete this.scripts[name];
}
}
let storeInstance: ScriptStore | null = null;
export async function loadScriptStore(): Promise<ScriptStore> {
if (!storeInstance) {
storeInstance = new ScriptStore(SCRIPTS_FILE);
await storeInstance.load();
}
return storeInstance;
}
export function resetScriptStore(): void {
storeInstance = null;
}
export type { ScriptStore };