feat(FN-674): implement multi-project dashboard UX
- Add Project Overview page with responsive grid and health status cards - Add Project Selector dropdown in header for quick project switching - Add Setup Wizard for registering new projects with auto-detection - Implement project drill-down: click project to view its tasks - Add global activity feed with cross-project activity log - Add project health monitoring with real-time status polling - Add deep linking support for tasks via ?task= URL parameter - Add useTasks hook with project context filtering - Add ntfy notification deep link to open tasks in dashboard - Fix terminal error message formatting for agent failures - Unify comment style across codebase (// instead of /** */) - Add comprehensive tests for multi-project components
This commit is contained in:
146
packages/dashboard/app/utils/projectDetection.ts
Normal file
146
packages/dashboard/app/utils/projectDetection.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* projectDetection.ts - Client-side project detection utilities
|
||||
*
|
||||
* Provides utilities for scanning and detecting kb projects.
|
||||
* These functions prepare data for API calls rather than accessing
|
||||
* the filesystem directly (which is not possible from browser).
|
||||
*/
|
||||
|
||||
import type { DetectedProject } from "../api";
|
||||
|
||||
export interface DetectionOptions {
|
||||
/** Maximum depth to scan (default: 3) */
|
||||
maxDepth?: number;
|
||||
/** Base path to scan from */
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a scan request for projects.
|
||||
* This function returns the configuration for a scan - the actual
|
||||
* scanning happens server-side via the detectProjects API.
|
||||
*/
|
||||
export function prepareProjectScan(
|
||||
basePath: string = "",
|
||||
maxDepth: number = 3
|
||||
): { basePath: string; maxDepth: number } {
|
||||
return {
|
||||
basePath: basePath || getDefaultScanPath(),
|
||||
maxDepth: Math.min(Math.max(maxDepth, 1), 5), // Clamp between 1-5
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters detected projects to exclude already registered ones.
|
||||
*/
|
||||
export function filterNewProjects(
|
||||
detected: DetectedProject[],
|
||||
registered: { path: string }[]
|
||||
): DetectedProject[] {
|
||||
const registeredPaths = new Set(registered.map((p) => normalizePath(p.path)));
|
||||
return detected.filter((p) => !registeredPaths.has(normalizePath(p.path)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a project path client-side.
|
||||
* Returns validation result - server does final validation.
|
||||
*/
|
||||
export function validateProjectPath(path: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!path || path.trim().length === 0) {
|
||||
return { valid: false, error: "Path is required" };
|
||||
}
|
||||
|
||||
if (path.includes("..") || path.includes("~")) {
|
||||
return { valid: false, error: "Path cannot contain .. or ~" };
|
||||
}
|
||||
|
||||
// Must be absolute path
|
||||
if (!path.startsWith("/") && !path.match(/^[A-Za-z]:/)) {
|
||||
return { valid: false, error: "Path must be absolute" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a suggested project name from a path.
|
||||
*/
|
||||
export function suggestProjectName(path: string): string {
|
||||
// Get the last non-empty segment of the path
|
||||
const segments = path.split(/[/\\]/).filter((s) => s.length > 0);
|
||||
const name = segments[segments.length - 1] || "My Project";
|
||||
|
||||
// Clean up the name
|
||||
return name
|
||||
.replace(/[^a-zA-Z0-9-_]/g, "-") // Replace special chars with hyphens
|
||||
.replace(/-+/g, "-") // Collapse multiple hyphens
|
||||
.replace(/^-|-$/g, "") // Trim leading/trailing hyphens
|
||||
|| "My Project";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a project name.
|
||||
*/
|
||||
export function validateProjectName(
|
||||
name: string,
|
||||
existingProjects?: { name: string }[]
|
||||
): { valid: boolean; error?: string } {
|
||||
if (!name || name.trim().length === 0) {
|
||||
return { valid: false, error: "Name is required" };
|
||||
}
|
||||
|
||||
if (name.length < 1 || name.length > 64) {
|
||||
return { valid: false, error: "Name must be between 1 and 64 characters" };
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(name)) {
|
||||
return { valid: false, error: "Name can only contain letters, numbers, hyphens, and underscores" };
|
||||
}
|
||||
|
||||
if (existingProjects) {
|
||||
const exists = existingProjects.some(
|
||||
(p) => p.name.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
if (exists) {
|
||||
return { valid: false, error: "A project with this name already exists" };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a path for comparison.
|
||||
*/
|
||||
function normalizePath(path: string): string {
|
||||
return path
|
||||
.replace(/\\/g, "/") // Convert backslashes to forward slashes
|
||||
.replace(/\/$/, "") // Remove trailing slash
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default path for scanning.
|
||||
* In browser context, this returns a sensible default.
|
||||
*/
|
||||
function getDefaultScanPath(): string {
|
||||
// In a browser, we can't determine the user's home directory
|
||||
// The server will handle this if empty
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts detected projects by likelihood of being a kb project.
|
||||
* Projects with .kb/kb.db are ranked higher.
|
||||
*/
|
||||
export function sortDetectedProjects(projects: DetectedProject[]): DetectedProject[] {
|
||||
return [...projects].sort((a, b) => {
|
||||
// Existing projects (with kb.db) come first
|
||||
if (a.existing && !b.existing) return -1;
|
||||
if (!a.existing && b.existing) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user