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:
gsxdsm
2026-04-01 08:04:02 -07:00
parent 360af7d863
commit d8a591bd7f
7 changed files with 1015 additions and 215 deletions

View File

@@ -43,6 +43,10 @@ function truncatePath(path: string, maxLength: number = 40): string {
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 {
if (previous.project.id !== next.project.id) 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({
project,
health,

View File

@@ -1,3 +1,4 @@
<<<<<<< HEAD
import { useCallback } from "react";
import { X } from "lucide-react";
import { SetupWizard } from "./SetupWizard";
@@ -26,10 +27,283 @@ export function SetupWizardModal({
const handleProjectCreated = useCallback((project: ProjectInfo) => {
onComplete(project);
}, [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;
return (
<<<<<<< HEAD
<div
className="modal-overlay open"
onClick={(e) => {
@@ -64,6 +338,222 @@ export function SetupWizardModal({
onProjectCreated={handleProjectCreated}
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>

View File

@@ -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 { SetupWizard } from "../SetupWizard";
import type { ProjectInfo, ProjectCreateInput } from "../../api";
import { SetupWizardModal } from "../SetupWizardModal";
// Mock lucide-react
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...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>,
};
});
// Mock the API and utils
const mockFetchFirstRunStatus = vi.fn();
const mockDetectProjects = vi.fn();
const mockRegisterProject = vi.fn();
describe("SetupWizard", () => {
it("does not render when isOpen is false", () => {
vi.mock("../api", () => ({
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(
<SetupWizard
isOpen={false}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
<SetupWizardModal
onProjectRegistered={noop}
onProjectsRegistered={noop}
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(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
<SetupWizardModal
onProjectRegistered={noop}
onProjectsRegistered={noop}
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(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
<SetupWizardModal
onProjectRegistered={noop}
onProjectsRegistered={noop}
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(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
<SetupWizardModal
onProjectRegistered={noop}
onProjectsRegistered={noop}
onClose={noop}
/>
);
expect(screen.getByText("Directory")).toBeDefined();
expect(screen.getByText("Name")).toBeDefined();
expect(screen.getByText("Mode")).toBeDefined();
expect(screen.getByText("Validate")).toBeDefined();
expect(screen.getByText("Confirm")).toBeDefined();
await waitFor(() => {
expect(screen.getByText("Auto-detect Projects")).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(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
<SetupWizardModal
onProjectRegistered={noop}
onProjectsRegistered={noop}
onClose={noop}
/>
);
const nextButton = screen.getByRole("button", { name: /Next/i });
expect(nextButton).toBeDisabled();
await waitFor(() => {
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(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
<SetupWizardModal
onProjectRegistered={noop}
onProjectsRegistered={noop}
onClose={noop}
/>
);
const input = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(input, { target: { value: "/home/user/project" } });
await waitFor(() => {
expect(screen.getByText("Add Manually")).toBeDefined();
});
const nextButton = screen.getByRole("button", { name: /Next/i });
expect(nextButton).not.toBeDisabled();
fireEvent.click(screen.getByText("Add Manually"));
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(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
<SetupWizardModal
onProjectRegistered={noop}
onProjectsRegistered={noop}
onClose={noop}
/>
);
const input = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(input, { target: { value: "/home/user/project" } });
await waitFor(() => {
expect(screen.getByText("Add Manually")).toBeDefined();
});
// Find the primary button (Next) in the actions area
const nextButton = screen.getByRole("button", { name: /Next/i });
fireEvent.click(nextButton);
fireEvent.click(screen.getByText("Add Manually"));
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", () => {
render(
<SetupWizard
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({
it("calls onProjectRegistered when manual registration succeeds", async () => {
const onProjectRegistered = vi.fn();
mockRegisterProject.mockResolvedValue({
id: "proj_123",
name: "My Project",
path: "/home/user/project",
name: "Test Project",
path: "/path/to/project",
status: "active",
isolationMode: "in-process",
} as ProjectInfo);
const onProjectCreated = vi.fn();
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
});
render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={onProjectCreated}
onRegisterProject={mockRegisterProject}
<SetupWizardModal
onProjectRegistered={onProjectRegistered}
onProjectsRegistered={noop}
onClose={noop}
/>
);
// Fill directory
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
await waitFor(() => {
expect(screen.getByText("Add Manually")).toBeDefined();
});
// The wizard should be in directory step with a Next button
expect(screen.getByRole("button", { name: /Next/i })).toBeDefined();
// Note: Full wizard flow testing would require more complex setup
// including mocking the validation API call
fireEvent.click(screen.getByText("Add Manually"));
await waitFor(() => {
expect(screen.getByLabelText("Project Path")).toBeDefined();
});
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", () => {
const { rerender } = render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
it("persists wizard state to localStorage", async () => {
mockDetectProjects.mockResolvedValue({
projects: [{ path: "/home/user/project1", suggestedName: "project1", existing: false }],
});
render(
<SetupWizardModal
onProjectRegistered={noop}
onProjectsRegistered={noop}
onClose={noop}
/>
);
// Fill some data
const input = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(input, { target: { value: "/home/user/project" } });
await waitFor(() => {
expect(screen.getByText("Auto-detect Projects")).toBeDefined();
});
// Close and reopen
rerender(
<SetupWizard
isOpen={false}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
fireEvent.click(screen.getByText("Auto-detect Projects"));
await waitFor(() => {
expect(screen.getByText("Review Detected Projects")).toBeDefined();
});
// 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(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
// Wait for modal to open
await waitFor(() => {
expect(screen.getByText("Review Detected Projects")).toBeDefined();
});
// Should be back at step 1 with empty fields
expect(screen.getByText("Select Project Directory")).toBeDefined();
const newInput = screen.getByPlaceholderText("/path/to/your/project") as HTMLInputElement;
expect(newInput.value).toBe("");
// Close the modal
fireEvent.click(screen.getByLabelText("Close wizard"));
expect(localStorage.getItem("kb-setup-wizard-state")).toBeNull();
expect(onClose).toHaveBeenCalled();
});
});