feat(KB-502): add dashboard multi-project UX with overview, drill-down, and setup wizard
- Add Project Overview page with responsive grid and health metrics - Add Project selector dropdown for quick context switching - Add Project drill-down with back navigation to overview - Add Setup wizard with auto-detection and manual entry flows - Add Global activity feed with project attribution badges - Add Project health polling (active tasks, agents, completion counts) - Add Empty states and loading skeletons for better UX - Add Keyboard navigation support (arrow keys, enter, escape) - Add LocalStorage persistence for wizard state and view preferences
This commit is contained in:
15
.changeset/dashboard-multi-project-ux.md
Normal file
15
.changeset/dashboard-multi-project-ux.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
"@fusion/dashboard": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
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
|
||||||
|
- Project selector dropdown in header for quick context switching (appears when 2+ projects)
|
||||||
|
- Project drill-down into per-project task views with back navigation to overview
|
||||||
|
- Setup wizard for first-run project registration with auto-detection and manual entry flows
|
||||||
|
- Global activity feed with project attribution badges when viewing all projects
|
||||||
|
- Project health polling with active tasks, running agents, and completion counts
|
||||||
|
- Empty states and loading skeletons for better UX during data fetching
|
||||||
|
- Keyboard navigation support in project selector (arrow keys, enter, escape)
|
||||||
|
- LocalStorage persistence for wizard state (resume capability) and view preferences
|
||||||
83
AGENTS.md
83
AGENTS.md
@@ -535,6 +535,89 @@ kb task list --project api-service
|
|||||||
kb task list --project web-ui
|
kb task list --project web-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multi-Project Dashboard
|
||||||
|
|
||||||
|
The kb dashboard provides a visual interface for managing multiple projects simultaneously. When multiple projects are registered, the dashboard shows a Project Overview page as the home view, with drill-down capability into individual project task boards.
|
||||||
|
|
||||||
|
### Project Overview Page
|
||||||
|
|
||||||
|
The Project Overview (`ProjectOverview` component) displays all registered projects in a responsive grid:
|
||||||
|
|
||||||
|
- **Quick Stats Header**: Total projects, active projects, active tasks across all projects, total tasks
|
||||||
|
- **Filter & Sort**: Filter by status (all, active, paused, errored, initializing); sort by name, last activity, or status
|
||||||
|
- **Project Cards**: Each card shows:
|
||||||
|
- Project name and truncated path
|
||||||
|
- Status badge (color-coded: green=active, yellow=paused, red=errored, blue=initializing)
|
||||||
|
- Health metrics: active task count, running agents, completed tasks
|
||||||
|
- Last activity timestamp (relative, e.g., "5m ago")
|
||||||
|
- Actions: Open, Pause/Resume, Remove
|
||||||
|
|
||||||
|
**Empty State**: When no projects exist, shows a welcome prompt to add the first project or run the setup wizard.
|
||||||
|
|
||||||
|
### Project Selector
|
||||||
|
|
||||||
|
The `ProjectSelector` component appears in the header when 2+ projects exist:
|
||||||
|
|
||||||
|
- **Trigger Button**: Shows current project name with status dot indicator
|
||||||
|
- **Dropdown Menu**:
|
||||||
|
- "All Projects" option to return to overview
|
||||||
|
- Project list sorted by status (active first) then alphabetically
|
||||||
|
- Status badges next to each project name
|
||||||
|
- "Add Project..." and "Manage Projects..." shortcuts
|
||||||
|
- **Keyboard Navigation**: Arrow keys, Enter to select, Escape to close
|
||||||
|
|
||||||
|
**Single-Project Mode**: When only one project exists, the selector is hidden for a cleaner UI.
|
||||||
|
|
||||||
|
### Project Drill-Down
|
||||||
|
|
||||||
|
Clicking a project card opens that project's task view:
|
||||||
|
|
||||||
|
- **Context Preservation**: The Board or ListView shows only that project's tasks
|
||||||
|
- **Back Navigation**: "Back to All Projects" button returns to overview
|
||||||
|
- **View Preference**: Board vs List preference is persisted per project in localStorage
|
||||||
|
- **Project Context**: TaskDetailModal shows project breadcrumb in header
|
||||||
|
|
||||||
|
### Setup Wizard
|
||||||
|
|
||||||
|
The `SetupWizardModal` provides a first-run experience for new users:
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
1. **Welcome**: Introduction to multi-project mode
|
||||||
|
2. **Auto-detect**: Scans home directory for `.fusion/kb.db` files
|
||||||
|
3. **Review**: Shows detected projects with checkboxes for selection, editable names
|
||||||
|
4. **Manual**: Option to add project by path if auto-detect finds nothing
|
||||||
|
5. **Complete**: Summary of registered projects
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Auto-opens when `fetchFirstRunStatus()` returns `hasProjects: false`
|
||||||
|
- Persists state to localStorage (`kb-setup-wizard-state`) for resume capability
|
||||||
|
- Bulk registration of selected detected projects
|
||||||
|
- Already-registered projects shown as disabled in review list
|
||||||
|
|
||||||
|
### Project Health Indicators
|
||||||
|
|
||||||
|
Project health is polled every 10 seconds when the overview is visible:
|
||||||
|
|
||||||
|
| Indicator | Meaning |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Active Tasks** | Tasks currently in non-terminal columns (todo, in-progress, in-review) |
|
||||||
|
| **Agents** | Currently running executor/reviewer agents for this project |
|
||||||
|
| **Completed** | Cumulative count of tasks moved to "done" |
|
||||||
|
| **Last Activity** | Timestamp of last task movement, creation, or update |
|
||||||
|
| **Status Badge** | Project state: active (healthy), paused (suspended), errored (failed), initializing (starting up) |
|
||||||
|
|
||||||
|
Health data comes from `useProjectHealth(projectId)` hook which calls `/api/projects/:id/health`.
|
||||||
|
|
||||||
|
### Global Activity Feed
|
||||||
|
|
||||||
|
The Activity Log (`ActivityLogModal`) shows events across all projects when viewing from the overview:
|
||||||
|
|
||||||
|
- **Project Badges**: Each entry shows a folder icon with project name when viewing global activity
|
||||||
|
- **Project Filter**: Dropdown to filter by specific project (only shown when 2+ projects)
|
||||||
|
- **Event Types**: Same events as single-project mode (task:created, task:moved, etc.)
|
||||||
|
|
||||||
|
When viewing a specific project's task board, the activity log is automatically filtered to that project.
|
||||||
|
|
||||||
## Pi Extension (`packages/cli/src/extension.ts`)
|
## Pi Extension (`packages/cli/src/extension.ts`)
|
||||||
|
|
||||||
The pi extension provides tools and a `/kb` command for interacting with kb from within a pi session. It ships as part of `@gsxdsm/fusion` — one `pi install` gives you both the CLI and the extension.
|
The pi extension provides tools and a `/kb` command for interacting with kb from within a pi session. It ships as part of `@gsxdsm/fusion` — one `pi install` gives you both the CLI and the extension.
|
||||||
|
|||||||
@@ -1845,6 +1845,37 @@ export function resumeProject(id: string): Promise<ProjectInfo> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch a specific project by ID */
|
||||||
|
export function fetchProject(id: string): Promise<ProjectInfo> {
|
||||||
|
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a project */
|
||||||
|
export function updateProject(
|
||||||
|
id: string,
|
||||||
|
updates: { name?: string; isolationMode?: "in-process" | "child-process"; status?: "active" | "paused" | "errored" | "initializing" }
|
||||||
|
): Promise<ProjectInfo> {
|
||||||
|
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detected project from auto-scan */
|
||||||
|
export interface DetectedProject {
|
||||||
|
path: string;
|
||||||
|
suggestedName: string;
|
||||||
|
existing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-detect kb projects in a given base path */
|
||||||
|
export function detectProjects(basePath?: string): Promise<{ projects: DetectedProject[] }> {
|
||||||
|
return api<{ projects: DetectedProject[] }>("/projects/detect", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ basePath }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch first run status to detect if user needs setup wizard */
|
/** Fetch first run status to detect if user needs setup wizard */
|
||||||
export function fetchFirstRunStatus(): Promise<FirstRunStatus> {
|
export function fetchFirstRunStatus(): Promise<FirstRunStatus> {
|
||||||
return api<FirstRunStatus>("/first-run-status");
|
return api<FirstRunStatus>("/first-run-status");
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ function truncatePath(path: string, maxLength: number = 40): string {
|
|||||||
return `${start}...${end}`;
|
return `${start}...${end}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two sets of ProjectCardProps for memo equality.
|
||||||
|
* Checks project properties and health metrics to determine if re-render is needed.
|
||||||
|
*/
|
||||||
function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardProps): boolean {
|
function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardProps): boolean {
|
||||||
if (previous.project.id !== next.project.id) return false;
|
if (previous.project.id !== next.project.id) return false;
|
||||||
if (previous.project.status !== next.project.status) return false;
|
if (previous.project.status !== next.project.status) return false;
|
||||||
@@ -66,6 +70,28 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual project card component showing project status, health metrics, and actions.
|
||||||
|
*
|
||||||
|
* Displays:
|
||||||
|
* - Project name and truncated path
|
||||||
|
* - Status badge (active, paused, errored, initializing)
|
||||||
|
* - Health metrics: active tasks, running agents, completed tasks
|
||||||
|
* - Last activity timestamp
|
||||||
|
* - Action buttons: Open, Pause/Resume, Remove
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ProjectCard
|
||||||
|
* project={registeredProject}
|
||||||
|
* health={projectHealth}
|
||||||
|
* onSelect={(p) => setCurrentProject(p)}
|
||||||
|
* onPause={(p) => pauseProject(p.id)}
|
||||||
|
* onResume={(p) => resumeProject(p.id)}
|
||||||
|
* onRemove={(p) => unregisterProject(p.id)}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
function ProjectCardInner({
|
function ProjectCardInner({
|
||||||
project,
|
project,
|
||||||
health,
|
health,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<<<<<<< HEAD
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { SetupWizard } from "./SetupWizard";
|
import { SetupWizard } from "./SetupWizard";
|
||||||
@@ -26,10 +27,283 @@ export function SetupWizardModal({
|
|||||||
const handleProjectCreated = useCallback((project: ProjectInfo) => {
|
const handleProjectCreated = useCallback((project: ProjectInfo) => {
|
||||||
onComplete(project);
|
onComplete(project);
|
||||||
}, [onComplete]);
|
}, [onComplete]);
|
||||||
|
=======
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { X, Loader2, FolderPlus, Search, CheckCircle, ArrowRight, ArrowLeft } from "lucide-react";
|
||||||
|
import type { ProjectInfo, ProjectCreateInput } from "../api";
|
||||||
|
import { fetchFirstRunStatus, detectProjects, registerProject } from "../api";
|
||||||
|
import { ProjectDetectionResults, type SelectedProject } from "./ProjectDetectionResults";
|
||||||
|
import { scanForProjects } from "../utils/projectDetection";
|
||||||
|
|
||||||
|
export interface SetupWizardModalProps {
|
||||||
|
/** Called when a single project is registered */
|
||||||
|
onProjectRegistered: (project: ProjectInfo) => void;
|
||||||
|
/** Called when multiple projects are registered (bulk detection) */
|
||||||
|
onProjectsRegistered?: (projects: ProjectInfo[]) => void;
|
||||||
|
/** Called when wizard is closed (completed or cancelled) */
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WizardStep = "welcome" | "detecting" | "review" | "manual" | "complete";
|
||||||
|
|
||||||
|
interface WizardState {
|
||||||
|
step: WizardStep;
|
||||||
|
detectedProjects: SelectedProject[];
|
||||||
|
isDetecting: boolean;
|
||||||
|
detectError: string | null;
|
||||||
|
manualPath: string;
|
||||||
|
manualName: string;
|
||||||
|
manualIsolationMode: "in-process" | "child-process";
|
||||||
|
isRegistering: boolean;
|
||||||
|
registeredCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIZARD_STATE_KEY = "kb-setup-wizard-state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup wizard for first-run project registration.
|
||||||
|
*
|
||||||
|
* Provides a multi-step wizard for new users to:
|
||||||
|
* 1. Welcome - Introduction to multi-project mode
|
||||||
|
* 2. Auto-detect - Scan filesystem for existing kb projects
|
||||||
|
* 3. Review - Select which detected projects to register
|
||||||
|
* 4. Manual - Add projects manually by path
|
||||||
|
* 5. Complete - Summary and get started
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Auto-opens when no projects exist (uses fetchFirstRunStatus)
|
||||||
|
* - Persists state to localStorage for resume capability
|
||||||
|
* - Bulk registration of selected detected projects
|
||||||
|
* - Manual project registration as fallback
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <SetupWizardModal
|
||||||
|
* onProjectRegistered={(project) => console.log(`Registered ${project.name}`)}
|
||||||
|
* onProjectsRegistered={(projects) => console.log(`Registered ${projects.length} projects`)}
|
||||||
|
* onClose={() => setShowWizard(false)}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function SetupWizardModal({
|
||||||
|
onProjectRegistered,
|
||||||
|
onProjectsRegistered,
|
||||||
|
onClose,
|
||||||
|
}: SetupWizardModalProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [state, setState] = useState<WizardState>({
|
||||||
|
step: "welcome",
|
||||||
|
detectedProjects: [],
|
||||||
|
isDetecting: false,
|
||||||
|
detectError: null,
|
||||||
|
manualPath: "",
|
||||||
|
manualName: "",
|
||||||
|
manualIsolationMode: "in-process",
|
||||||
|
isRegistering: false,
|
||||||
|
registeredCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check first-run status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkFirstRun = async () => {
|
||||||
|
try {
|
||||||
|
const status = await fetchFirstRunStatus();
|
||||||
|
|
||||||
|
// Check for saved wizard state (resume capability)
|
||||||
|
const savedState = localStorage.getItem(WIZARD_STATE_KEY);
|
||||||
|
if (savedState) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedState);
|
||||||
|
if (parsed.inProgress) {
|
||||||
|
setIsOpen(true);
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: parsed.step || "welcome",
|
||||||
|
detectedProjects: parsed.detectedProjects || [],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid saved state, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-open if no projects exist
|
||||||
|
if (!status.hasProjects) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fail silently - don't auto-open on error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small delay to allow app to fully mount
|
||||||
|
const timer = setTimeout(checkFirstRun, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist wizard state for resume capability
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && state.step !== "complete") {
|
||||||
|
localStorage.setItem(
|
||||||
|
WIZARD_STATE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
inProgress: true,
|
||||||
|
step: state.step,
|
||||||
|
detectedProjects: state.detectedProjects,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (!isOpen || state.step === "complete") {
|
||||||
|
localStorage.removeItem(WIZARD_STATE_KEY);
|
||||||
|
}
|
||||||
|
}, [isOpen, state.step, state.detectedProjects]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
localStorage.removeItem(WIZARD_STATE_KEY);
|
||||||
|
onClose?.();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const startDetection = useCallback(async () => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: "detecting",
|
||||||
|
isDetecting: true,
|
||||||
|
detectError: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await scanForProjects();
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isDetecting: false,
|
||||||
|
detectError: result.error,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark all non-existing projects as selected by default
|
||||||
|
const selectedProjects: SelectedProject[] = result.projects.map((p) => ({
|
||||||
|
...p,
|
||||||
|
selected: !p.existing,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: "review",
|
||||||
|
isDetecting: false,
|
||||||
|
detectedProjects: selectedProjects,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback((selected: SelectedProject[]) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
detectedProjects: prev.detectedProjects.map((p) => ({
|
||||||
|
...p,
|
||||||
|
selected: selected.some((s) => s.path === p.path),
|
||||||
|
customName: selected.find((s) => s.path === p.path)?.customName,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRegisterDetected = useCallback(async () => {
|
||||||
|
const toRegister = state.detectedProjects.filter((p) => p.selected && !p.existing);
|
||||||
|
|
||||||
|
if (toRegister.length === 0) {
|
||||||
|
// No projects selected, skip to manual
|
||||||
|
setState((prev) => ({ ...prev, step: "manual" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isRegistering: true }));
|
||||||
|
|
||||||
|
const registered: ProjectInfo[] = [];
|
||||||
|
|
||||||
|
for (const project of toRegister) {
|
||||||
|
try {
|
||||||
|
const input: ProjectCreateInput = {
|
||||||
|
name: project.customName || project.suggestedName,
|
||||||
|
path: project.path,
|
||||||
|
isolationMode: "in-process",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await registerProject(input);
|
||||||
|
registered.push(result);
|
||||||
|
onProjectRegistered(result);
|
||||||
|
} catch (err) {
|
||||||
|
// Log error but continue with other projects
|
||||||
|
console.error(`Failed to register project at ${project.path}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProjectsRegistered && registered.length > 0) {
|
||||||
|
onProjectsRegistered(registered);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: "complete",
|
||||||
|
isRegistering: false,
|
||||||
|
registeredCount: registered.length,
|
||||||
|
}));
|
||||||
|
}, [state.detectedProjects, onProjectRegistered, onProjectsRegistered]);
|
||||||
|
|
||||||
|
const handleManualRegister = useCallback(async () => {
|
||||||
|
if (!state.manualPath || !state.manualName) return;
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isRegistering: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input: ProjectCreateInput = {
|
||||||
|
name: state.manualName,
|
||||||
|
path: state.manualPath,
|
||||||
|
isolationMode: state.manualIsolationMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await registerProject(input);
|
||||||
|
onProjectRegistered(result);
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: "complete",
|
||||||
|
isRegistering: false,
|
||||||
|
registeredCount: 1,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isRegistering: false,
|
||||||
|
detectError: err instanceof Error ? err.message : "Failed to register project",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [state.manualPath, state.manualName, state.manualIsolationMode, onProjectRegistered]);
|
||||||
|
|
||||||
|
const goToManual = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, step: "manual", detectError: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
switch (prev.step) {
|
||||||
|
case "detecting":
|
||||||
|
return { ...prev, step: "welcome" };
|
||||||
|
case "review":
|
||||||
|
return { ...prev, step: "welcome" };
|
||||||
|
case "manual":
|
||||||
|
return { ...prev, step: "review" };
|
||||||
|
default:
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
>>>>>>> kb/kb-502
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<<<<<<< HEAD
|
||||||
<div
|
<div
|
||||||
className="modal-overlay open"
|
className="modal-overlay open"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -64,6 +338,222 @@ export function SetupWizardModal({
|
|||||||
onProjectCreated={handleProjectCreated}
|
onProjectCreated={handleProjectCreated}
|
||||||
onRegisterProject={onRegisterProject}
|
onRegisterProject={onRegisterProject}
|
||||||
/>
|
/>
|
||||||
|
=======
|
||||||
|
<div className="modal-overlay open" role="dialog" aria-modal="true" aria-labelledby="wizard-title">
|
||||||
|
<div className="modal setup-wizard-modal">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="setup-wizard-header">
|
||||||
|
<h2 id="wizard-title" className="setup-wizard-title">
|
||||||
|
{state.step === "welcome" && "Welcome to kb"}
|
||||||
|
{state.step === "detecting" && "Detecting Projects..."}
|
||||||
|
{state.step === "review" && "Review Detected Projects"}
|
||||||
|
{state.step === "manual" && "Add Project Manually"}
|
||||||
|
{state.step === "complete" && "Setup Complete!"}
|
||||||
|
</h2>
|
||||||
|
{state.step !== "complete" && (
|
||||||
|
<button
|
||||||
|
className="modal-close"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close wizard"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="setup-wizard-content">
|
||||||
|
{/* Welcome Step */}
|
||||||
|
{state.step === "welcome" && (
|
||||||
|
<div className="setup-wizard-welcome">
|
||||||
|
<div className="welcome-icon">
|
||||||
|
<FolderPlus size={64} />
|
||||||
|
</div>
|
||||||
|
<p className="welcome-text">
|
||||||
|
Let's set up your kb workspace. We can automatically detect existing projects
|
||||||
|
on your system, or you can add them manually.
|
||||||
|
</p>
|
||||||
|
<div className="welcome-actions">
|
||||||
|
<button className="btn-primary" onClick={startDetection}>
|
||||||
|
<Search size={18} />
|
||||||
|
<span>Auto-detect Projects</span>
|
||||||
|
</button>
|
||||||
|
<button className="btn-secondary" onClick={goToManual}>
|
||||||
|
<FolderPlus size={18} />
|
||||||
|
<span>Add Manually</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detecting Step */}
|
||||||
|
{state.step === "detecting" && (
|
||||||
|
<div className="setup-wizard-detecting">
|
||||||
|
<Loader2 size={48} className="animate-spin" />
|
||||||
|
<p>Scanning your home directory for kb projects...</p>
|
||||||
|
<p className="detecting-hint">
|
||||||
|
This may take a moment. Looking for <code>.fusion/kb.db</code> files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Step */}
|
||||||
|
{state.step === "review" && (
|
||||||
|
<div className="setup-wizard-review">
|
||||||
|
<ProjectDetectionResults
|
||||||
|
projects={state.detectedProjects}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
isDetecting={false}
|
||||||
|
/>
|
||||||
|
{state.detectError && (
|
||||||
|
<div className="error-message">{state.detectError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual Step */}
|
||||||
|
{state.step === "manual" && (
|
||||||
|
<div className="setup-wizard-manual">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="project-path">Project Path</label>
|
||||||
|
<input
|
||||||
|
id="project-path"
|
||||||
|
type="text"
|
||||||
|
value={state.manualPath}
|
||||||
|
onChange={(e) =>
|
||||||
|
setState((prev) => ({ ...prev, manualPath: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="/path/to/your/project"
|
||||||
|
/>
|
||||||
|
<p className="form-hint">
|
||||||
|
Absolute path to your project directory (must contain .fusion/kb.db)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="project-name">Project Name</label>
|
||||||
|
<input
|
||||||
|
id="project-name"
|
||||||
|
type="text"
|
||||||
|
value={state.manualName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setState((prev) => ({ ...prev, manualName: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="my-project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="isolation-mode">Isolation Mode</label>
|
||||||
|
<select
|
||||||
|
id="isolation-mode"
|
||||||
|
value={state.manualIsolationMode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
manualIsolationMode: e.target.value as "in-process" | "child-process",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="in-process">In-Process (faster, default)</option>
|
||||||
|
<option value="child-process">Child-Process (isolated)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.detectError && (
|
||||||
|
<div className="error-message">{state.detectError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Complete Step */}
|
||||||
|
{state.step === "complete" && (
|
||||||
|
<div className="setup-wizard-complete">
|
||||||
|
<CheckCircle size={64} className="success-icon" />
|
||||||
|
<h3>All Set!</h3>
|
||||||
|
<p>
|
||||||
|
{state.registeredCount} project{state.registeredCount !== 1 ? "s" : ""}{" "}
|
||||||
|
registered successfully.
|
||||||
|
</p>
|
||||||
|
<p>You can add more projects anytime from the project overview.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="setup-wizard-footer">
|
||||||
|
{state.step !== "welcome" && state.step !== "complete" && (
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={goBack}
|
||||||
|
disabled={state.isRegistering}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="footer-spacer" />
|
||||||
|
|
||||||
|
{state.step === "review" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={goToManual}
|
||||||
|
disabled={state.isRegistering}
|
||||||
|
>
|
||||||
|
Skip to Manual
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleRegisterDetected}
|
||||||
|
disabled={
|
||||||
|
state.isRegistering ||
|
||||||
|
!state.detectedProjects.some((p) => p.selected && !p.existing)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{state.isRegistering ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
<span>Registering...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Register Selected</span>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.step === "manual" && (
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleManualRegister}
|
||||||
|
disabled={state.isRegistering || !state.manualPath || !state.manualName}
|
||||||
|
>
|
||||||
|
{state.isRegistering ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
<span>Registering...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Register Project</span>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.step === "complete" && (
|
||||||
|
<button className="btn-primary" onClick={handleClose}>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
<span>Get Started</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>>>>>>> kb/kb-502
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,284 +1,310 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { SetupWizard } from "../SetupWizard";
|
import { SetupWizardModal } from "../SetupWizardModal";
|
||||||
import type { ProjectInfo, ProjectCreateInput } from "../../api";
|
|
||||||
|
|
||||||
// Mock lucide-react
|
// Mock the API and utils
|
||||||
vi.mock("lucide-react", async () => {
|
const mockFetchFirstRunStatus = vi.fn();
|
||||||
const actual = await vi.importActual("lucide-react");
|
const mockDetectProjects = vi.fn();
|
||||||
return {
|
const mockRegisterProject = vi.fn();
|
||||||
...actual,
|
|
||||||
X: () => <span data-testid="close-icon">×</span>,
|
|
||||||
ChevronRight: () => <span data-testid="next-icon">→</span>,
|
|
||||||
ChevronLeft: () => <span data-testid="back-icon">←</span>,
|
|
||||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
|
||||||
Check: () => <span data-testid="check-icon">✓</span>,
|
|
||||||
Loader2: () => <span data-testid="loader-icon">⟳</span>,
|
|
||||||
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SetupWizard", () => {
|
vi.mock("../api", () => ({
|
||||||
it("does not render when isOpen is false", () => {
|
fetchFirstRunStatus: (...args: unknown[]) => mockFetchFirstRunStatus(...args),
|
||||||
|
detectProjects: (...args: unknown[]) => mockDetectProjects(...args),
|
||||||
|
registerProject: (...args: unknown[]) => mockRegisterProject(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../utils/projectDetection", () => ({
|
||||||
|
scanForProjects: (...args: unknown[]) => mockDetectProjects(...args),
|
||||||
|
suggestProjectName: (path: string) => path.split("/").pop() || "",
|
||||||
|
isValidProjectName: (name: string) => /^[a-zA-Z0-9_-]+$/.test(name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock lucide-react icons
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
X: () => <span data-testid="x-icon">×</span>,
|
||||||
|
Loader2: () => <span data-testid="loader-icon">⟳</span>,
|
||||||
|
FolderPlus: () => <span data-testid="folder-icon">📁</span>,
|
||||||
|
Search: () => <span data-testid="search-icon">🔍</span>,
|
||||||
|
CheckCircle: () => <span data-testid="check-icon">✓</span>,
|
||||||
|
ArrowRight: () => <span data-testid="arrow-right">→</span>,
|
||||||
|
ArrowLeft: () => <span data-testid="arrow-left">←</span>,
|
||||||
|
Folder: () => <span data-testid="folder-small">📂</span>,
|
||||||
|
Check: () => <span data-testid="check-small">✓</span>,
|
||||||
|
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
||||||
|
Pencil: () => <span data-testid="pencil-icon">✎</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
describe("SetupWizardModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
// Default: no projects, so wizard should auto-open
|
||||||
|
mockFetchFirstRunStatus.mockResolvedValue({ hasProjects: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-opens when no projects exist", async () => {
|
||||||
render(
|
render(
|
||||||
<SetupWizard
|
<SetupWizardModal
|
||||||
isOpen={false}
|
onProjectRegistered={noop}
|
||||||
onClose={vi.fn()}
|
onProjectsRegistered={noop}
|
||||||
onProjectCreated={vi.fn()}
|
onClose={noop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByText("Add New Project")).toBeNull();
|
// Wait for the effect to run
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchFirstRunStatus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should show welcome screen
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Welcome to kb")).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders when isOpen is true", () => {
|
it("does not auto-open when projects exist", async () => {
|
||||||
|
mockFetchFirstRunStatus.mockResolvedValue({ hasProjects: true });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<SetupWizard
|
<SetupWizardModal
|
||||||
isOpen={true}
|
onProjectRegistered={noop}
|
||||||
onClose={vi.fn()}
|
onProjectsRegistered={noop}
|
||||||
onProjectCreated={vi.fn()}
|
onClose={noop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Add New Project")).toBeDefined();
|
await waitFor(() => {
|
||||||
|
expect(mockFetchFirstRunStatus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not show welcome screen
|
||||||
|
expect(screen.queryByText("Welcome to kb")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts at directory step", () => {
|
it("shows welcome step with auto-detect and manual options", async () => {
|
||||||
render(
|
render(
|
||||||
<SetupWizard
|
<SetupWizardModal
|
||||||
isOpen={true}
|
onProjectRegistered={noop}
|
||||||
onClose={vi.fn()}
|
onProjectsRegistered={noop}
|
||||||
onProjectCreated={vi.fn()}
|
onClose={noop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Welcome to kb")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Auto-detect Projects")).toBeDefined();
|
||||||
|
expect(screen.getByText("Add Manually")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows step indicator with 5 steps", () => {
|
it("transitions to detecting step when auto-detect clicked", async () => {
|
||||||
|
mockDetectProjects.mockResolvedValue({ projects: [] });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<SetupWizard
|
<SetupWizardModal
|
||||||
isOpen={true}
|
onProjectRegistered={noop}
|
||||||
onClose={vi.fn()}
|
onProjectsRegistered={noop}
|
||||||
onProjectCreated={vi.fn()}
|
onClose={noop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Directory")).toBeDefined();
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Name")).toBeDefined();
|
expect(screen.getByText("Auto-detect Projects")).toBeDefined();
|
||||||
expect(screen.getByText("Mode")).toBeDefined();
|
});
|
||||||
expect(screen.getByText("Validate")).toBeDefined();
|
|
||||||
expect(screen.getByText("Confirm")).toBeDefined();
|
fireEvent.click(screen.getByText("Auto-detect Projects"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Detecting Projects...")).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables Next button when directory is empty", () => {
|
it("shows review step with detected projects", async () => {
|
||||||
|
mockDetectProjects.mockResolvedValue({
|
||||||
|
projects: [
|
||||||
|
{ path: "/home/user/project1", suggestedName: "project1", existing: false },
|
||||||
|
{ path: "/home/user/project2", suggestedName: "project2", existing: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<SetupWizard
|
<SetupWizardModal
|
||||||
isOpen={true}
|
onProjectRegistered={noop}
|
||||||
onClose={vi.fn()}
|
onProjectsRegistered={noop}
|
||||||
onProjectCreated={vi.fn()}
|
onClose={noop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
await waitFor(() => {
|
||||||
expect(nextButton).toBeDisabled();
|
expect(screen.getByText("Auto-detect Projects")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Auto-detect Projects"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Review Detected Projects")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should show detected projects
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("project1")).toBeDefined();
|
||||||
|
expect(screen.getByText("project2")).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables Next button when directory is filled", () => {
|
it("transitions to manual step when manual option clicked", async () => {
|
||||||
render(
|
render(
|
||||||
<SetupWizard
|
<SetupWizardModal
|
||||||
isOpen={true}
|
onProjectRegistered={noop}
|
||||||
onClose={vi.fn()}
|
onProjectsRegistered={noop}
|
||||||
onProjectCreated={vi.fn()}
|
onClose={noop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
await waitFor(() => {
|
||||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
expect(screen.getByText("Add Manually")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
fireEvent.click(screen.getByText("Add Manually"));
|
||||||
expect(nextButton).not.toBeDisabled();
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Add Project Manually")).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("navigates to next step when Next is clicked", () => {
|
it("allows entering project details in manual step", async () => {
|
||||||
render(
|
render(
|
||||||
<SetupWizard
|
<SetupWizardModal
|
||||||
isOpen={true}
|
onProjectRegistered={noop}
|
||||||
onClose={vi.fn()}
|
onProjectsRegistered={noop}
|
||||||
onProjectCreated={vi.fn()}
|
onClose={noop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
await waitFor(() => {
|
||||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
expect(screen.getByText("Add Manually")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
// Find the primary button (Next) in the actions area
|
fireEvent.click(screen.getByText("Add Manually"));
|
||||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
|
||||||
fireEvent.click(nextButton);
|
|
||||||
|
|
||||||
expect(screen.getByText("Project Name")).toBeDefined();
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("Project Path")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
const pathInput = screen.getByLabelText("Project Path");
|
||||||
|
fireEvent.change(pathInput, { target: { value: "/path/to/project" } });
|
||||||
|
|
||||||
|
expect(pathInput).toHaveValue("/path/to/project");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto-suggests name from directory path", () => {
|
it("calls onProjectRegistered when manual registration succeeds", async () => {
|
||||||
render(
|
const onProjectRegistered = vi.fn();
|
||||||
<SetupWizard
|
mockRegisterProject.mockResolvedValue({
|
||||||
isOpen={true}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onProjectCreated={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
|
||||||
fireEvent.change(input, { target: { value: "/home/user/my-awesome-project" } });
|
|
||||||
|
|
||||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
|
||||||
fireEvent.click(nextButton);
|
|
||||||
|
|
||||||
const nameInput = screen.getByPlaceholderText("My Project") as HTMLInputElement;
|
|
||||||
expect(nameInput.value).toBe("my-awesome-project");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows navigation back to previous step", () => {
|
|
||||||
render(
|
|
||||||
<SetupWizard
|
|
||||||
isOpen={true}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onProjectCreated={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Go to step 2
|
|
||||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
|
||||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
|
||||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
|
||||||
fireEvent.click(nextButton);
|
|
||||||
|
|
||||||
// Go back
|
|
||||||
const backButton = screen.getByRole("button", { name: /Back/i });
|
|
||||||
fireEvent.click(backButton);
|
|
||||||
|
|
||||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows isolation mode options", () => {
|
|
||||||
render(
|
|
||||||
<SetupWizard
|
|
||||||
isOpen={true}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onProjectCreated={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Navigate to step 3 (isolation)
|
|
||||||
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
|
|
||||||
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
|
|
||||||
|
|
||||||
// Go to name step
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
|
||||||
|
|
||||||
// Go to isolation step
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
|
||||||
|
|
||||||
expect(screen.getByText("In-Process (Default)")).toBeDefined();
|
|
||||||
expect(screen.getByText("Child Process (Isolated)")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onClose when Cancel is clicked", () => {
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(
|
|
||||||
<SetupWizard
|
|
||||||
isOpen={true}
|
|
||||||
onClose={onClose}
|
|
||||||
onProjectCreated={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelButton = screen.getByRole("button", { name: /Cancel/i });
|
|
||||||
fireEvent.click(cancelButton);
|
|
||||||
|
|
||||||
expect(onClose).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onClose when close icon is clicked", () => {
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(
|
|
||||||
<SetupWizard
|
|
||||||
isOpen={true}
|
|
||||||
onClose={onClose}
|
|
||||||
onProjectCreated={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeButton = screen.getByLabelText("Close");
|
|
||||||
fireEvent.click(closeButton);
|
|
||||||
|
|
||||||
expect(onClose).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits project data when created", async () => {
|
|
||||||
const mockRegisterProject = vi.fn().mockResolvedValue({
|
|
||||||
id: "proj_123",
|
id: "proj_123",
|
||||||
name: "My Project",
|
name: "Test Project",
|
||||||
path: "/home/user/project",
|
path: "/path/to/project",
|
||||||
status: "active",
|
status: "active",
|
||||||
isolationMode: "in-process",
|
isolationMode: "in-process",
|
||||||
} as ProjectInfo);
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z",
|
||||||
const onProjectCreated = vi.fn();
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<SetupWizard
|
<SetupWizardModal
|
||||||
isOpen={true}
|
onProjectRegistered={onProjectRegistered}
|
||||||
onClose={vi.fn()}
|
onProjectsRegistered={noop}
|
||||||
onProjectCreated={onProjectCreated}
|
onClose={noop}
|
||||||
onRegisterProject={mockRegisterProject}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fill directory
|
await waitFor(() => {
|
||||||
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
|
expect(screen.getByText("Add Manually")).toBeDefined();
|
||||||
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
|
});
|
||||||
|
|
||||||
// The wizard should be in directory step with a Next button
|
fireEvent.click(screen.getByText("Add Manually"));
|
||||||
expect(screen.getByRole("button", { name: /Next/i })).toBeDefined();
|
|
||||||
|
await waitFor(() => {
|
||||||
// Note: Full wizard flow testing would require more complex setup
|
expect(screen.getByLabelText("Project Path")).toBeDefined();
|
||||||
// including mocking the validation API call
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Project Path"), {
|
||||||
|
target: { value: "/path/to/project" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Project Name"), {
|
||||||
|
target: { value: "test-project" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Register Project"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onProjectRegistered).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets state when reopened", () => {
|
it("persists wizard state to localStorage", async () => {
|
||||||
const { rerender } = render(
|
mockDetectProjects.mockResolvedValue({
|
||||||
<SetupWizard
|
projects: [{ path: "/home/user/project1", suggestedName: "project1", existing: false }],
|
||||||
isOpen={true}
|
});
|
||||||
onClose={vi.fn()}
|
|
||||||
onProjectCreated={vi.fn()}
|
render(
|
||||||
|
<SetupWizardModal
|
||||||
|
onProjectRegistered={noop}
|
||||||
|
onProjectsRegistered={noop}
|
||||||
|
onClose={noop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fill some data
|
await waitFor(() => {
|
||||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
expect(screen.getByText("Auto-detect Projects")).toBeDefined();
|
||||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
});
|
||||||
|
|
||||||
// Close and reopen
|
fireEvent.click(screen.getByText("Auto-detect Projects"));
|
||||||
rerender(
|
|
||||||
<SetupWizard
|
await waitFor(() => {
|
||||||
isOpen={false}
|
expect(screen.getByText("Review Detected Projects")).toBeDefined();
|
||||||
onClose={vi.fn()}
|
});
|
||||||
onProjectCreated={vi.fn()}
|
|
||||||
|
// Check localStorage was updated
|
||||||
|
await waitFor(() => {
|
||||||
|
const saved = localStorage.getItem("kb-setup-wizard-state");
|
||||||
|
expect(saved).toBeTruthy();
|
||||||
|
const parsed = JSON.parse(saved!);
|
||||||
|
expect(parsed.inProgress).toBe(true);
|
||||||
|
expect(parsed.step).toBe("review");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears localStorage when closed", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
// Pre-populate localStorage
|
||||||
|
localStorage.setItem(
|
||||||
|
"kb-setup-wizard-state",
|
||||||
|
JSON.stringify({ inProgress: true, step: "review", detectedProjects: [] })
|
||||||
|
);
|
||||||
|
|
||||||
|
mockFetchFirstRunStatus.mockResolvedValue({ hasProjects: false });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SetupWizardModal
|
||||||
|
onProjectRegistered={noop}
|
||||||
|
onProjectsRegistered={noop}
|
||||||
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
rerender(
|
// Wait for modal to open
|
||||||
<SetupWizard
|
await waitFor(() => {
|
||||||
isOpen={true}
|
expect(screen.getByText("Review Detected Projects")).toBeDefined();
|
||||||
onClose={vi.fn()}
|
});
|
||||||
onProjectCreated={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should be back at step 1 with empty fields
|
// Close the modal
|
||||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
fireEvent.click(screen.getByLabelText("Close wizard"));
|
||||||
const newInput = screen.getByPlaceholderText("/path/to/your/project") as HTMLInputElement;
|
|
||||||
expect(newInput.value).toBe("");
|
expect(localStorage.getItem("kb-setup-wizard-state")).toBeNull();
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5995,6 +5995,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
* GET /api/projects
|
* GET /api/projects
|
||||||
* List all registered projects with their basic info.
|
* List all registered projects with their basic info.
|
||||||
* Returns: ProjectInfo[]
|
* Returns: ProjectInfo[]
|
||||||
|
* Gracefully returns empty array if CentralCore not available.
|
||||||
*/
|
*/
|
||||||
router.get("/projects", async (_req, res) => {
|
router.get("/projects", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -6006,6 +6007,134 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
await central.close();
|
await central.close();
|
||||||
|
|
||||||
res.json(projects);
|
res.json(projects);
|
||||||
|
} catch {
|
||||||
|
// Graceful fallback: return empty array if CentralCore unavailable
|
||||||
|
res.json([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/projects/:id
|
||||||
|
* Get a specific project by ID.
|
||||||
|
* Returns: ProjectInfo
|
||||||
|
*/
|
||||||
|
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's metadata.
|
||||||
|
* Body: { name?: string, isolationMode?: "in-process" | "child-process", status?: "active" | "paused" }
|
||||||
|
* Returns: Updated ProjectInfo
|
||||||
|
*/
|
||||||
|
router.patch("/projects/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, isolationMode, status } = req.body;
|
||||||
|
|
||||||
|
// Validate isolationMode if provided
|
||||||
|
if (isolationMode !== undefined && !["in-process", "child-process"].includes(isolationMode)) {
|
||||||
|
res.status(400).json({ error: "isolationMode must be 'in-process' or 'child-process'" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate status if provided
|
||||||
|
if (status !== undefined && !["active", "paused", "errored", "initializing"].includes(status)) {
|
||||||
|
res.status(400).json({ error: "status must be 'active', 'paused', 'errored', or 'initializing'" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: { name?: string; isolationMode?: "in-process" | "child-process"; status?: "active" | "paused" | "errored" | "initializing" } = {};
|
||||||
|
if (name !== undefined) updates.name = name;
|
||||||
|
if (isolationMode !== undefined) updates.isolationMode = isolationMode;
|
||||||
|
if (status !== undefined) updates.status = status;
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
res.json(project);
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err.message?.includes("not found") ? 404 : 500;
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/projects/detect
|
||||||
|
* Auto-detect kb projects in a given base path.
|
||||||
|
* Body: { basePath?: string } (defaults to home directory)
|
||||||
|
* Returns: Array of detected projects with path and suggested name
|
||||||
|
*/
|
||||||
|
router.post("/projects/detect", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { basePath } = req.body;
|
||||||
|
const { existsSync } = await import("node:fs");
|
||||||
|
const { join, basename } = await import("node:path");
|
||||||
|
const { readdir, stat } = await import("node:fs/promises");
|
||||||
|
const { homedir } = await import("node:os");
|
||||||
|
|
||||||
|
const searchPath = basePath || homedir();
|
||||||
|
|
||||||
|
if (!existsSync(searchPath)) {
|
||||||
|
res.status(400).json({ error: "Base path does not exist" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectedProjects: Array<{ path: string; suggestedName: string; existing: boolean }> = [];
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
|
||||||
|
// Get list of already registered paths to avoid duplicates
|
||||||
|
const registeredProjects = await central.listProjects();
|
||||||
|
const registeredPaths = new Set(registeredProjects.map(p => p.path));
|
||||||
|
await central.close();
|
||||||
|
|
||||||
|
// Scan immediate subdirectories for .fusion/kb.db
|
||||||
|
try {
|
||||||
|
const entries = await readdir(searchPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
|
||||||
|
const projectPath = join(searchPath, entry.name);
|
||||||
|
const fusionDir = join(projectPath, ".fusion");
|
||||||
|
const dbPath = join(fusionDir, "kb.db");
|
||||||
|
|
||||||
|
// Check if this directory has a .fusion/kb.db file
|
||||||
|
if (existsSync(dbPath)) {
|
||||||
|
detectedProjects.push({
|
||||||
|
path: projectPath,
|
||||||
|
suggestedName: entry.name,
|
||||||
|
existing: registeredPaths.has(projectPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors reading directories
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ projects: detectedProjects });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user