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:
@@ -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}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
74
packages/dashboard/src/script-store.ts
Normal file
74
packages/dashboard/src/script-store.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user