"use client"; import { AlertCircle, ArrowRight, ArrowUpCircle, CheckCircle2, ChevronRight, CornerLeftUp, Folder, FolderOpen, FolderRoot, GitBranch, Layers, Loader2, Plus, Search, Sparkles, X, } from "lucide-react"; import Image from "next/image"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore, } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { authFetch } from "@/lib/auth"; import { useProjectStoreManager } from "@/lib/project-store-manager"; import { formatCost, getCurrentSlice, getLiveAutoDashboard, getLiveWorkspaceIndex, useSFWorkspaceState, } from "@/lib/sf-workspace-store"; import { cn } from "@/lib/utils"; // ─── Types (mirroring server-side ProjectMetadata) ───────────────────────── type ProjectDetectionKind = | "active-sf" | "empty-sf" | "v1-legacy" | "brownfield" | "blank"; interface ProjectDetectionSignals { hasSfFolder: boolean; hasPlanningFolder: boolean; hasGitRepo: boolean; hasPackageJson: boolean; fileCount: number; hasMilestones?: boolean; hasCargo?: boolean; hasGoMod?: boolean; hasPyproject?: boolean; isMonorepo?: boolean; } interface ProjectProgressInfo { activeMilestone: string | null; activeSlice: string | null; phase: string | null; milestonesCompleted: number; milestonesTotal: number; } interface ProjectMetadata { name: string; path: string; kind: ProjectDetectionKind; signals: ProjectDetectionSignals; lastModified: number; progress?: ProjectProgressInfo | null; } // ─── Kind style config ───────────────────────────────────────────────── const KIND_STYLE: Record< ProjectDetectionKind, { label: string; color: string; bgClass: string; icon: typeof Layers } > = { "active-sf": { label: "Active", color: "text-success", bgClass: "bg-success/10", icon: Layers, }, "empty-sf": { label: "Initialized", color: "text-info", bgClass: "bg-info/10", icon: FolderOpen, }, brownfield: { label: "Existing", color: "text-warning", bgClass: "bg-warning/10", icon: GitBranch, }, "v1-legacy": { label: "Legacy", color: "text-warning", bgClass: "bg-warning/10", icon: ArrowUpCircle, }, blank: { label: "New", color: "text-muted-foreground", bgClass: "bg-foreground/[0.04]", icon: Sparkles, }, }; function techStack(signals: ProjectDetectionSignals): string[] { const tags: string[] = []; if (signals.isMonorepo) tags.push("Monorepo"); if (signals.hasGitRepo) tags.push("Git"); if (signals.hasPackageJson) tags.push("Node.js"); if (signals.hasCargo) tags.push("Rust"); if (signals.hasGoMod) tags.push("Go"); if (signals.hasPyproject) tags.push("Python"); return tags; } function progressLabel(p: ProjectProgressInfo): string | null { if (p.milestonesTotal === 0) return null; const parts: string[] = []; if (p.activeMilestone) parts.push(p.activeMilestone); if (p.activeSlice) parts.push(p.activeSlice); if (p.phase) parts.push(p.phase); return parts.join(" · ") || null; } function relativeTime(timestamp: number): string { const now = Date.now(); const diffMs = now - timestamp; if (diffMs < 60_000) return "just now"; const minutes = Math.floor(diffMs / 60_000); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d ago`; return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric", }); } // ─── Shared project card component ───────────────────────────────────── function ProjectCard({ project, isActive = false, onClick, disabled = false, }: { project: ProjectMetadata; isActive?: boolean; onClick: () => void; disabled?: boolean; }) { const style = KIND_STYLE[project.kind]; const KindIcon = style.icon; const stack = techStack(project.signals); const progress = project.progress ? progressLabel(project.progress) : null; const milestoneCount = project.progress ? `${project.progress.milestonesCompleted}/${project.progress.milestonesTotal}` : null; return ( ); } // ─── ProjectsPanel (slide-out sheet from sidebar) ────────────────────── export function ProjectsPanel({ open, onOpenChange, }: { open: boolean; onOpenChange: (open: boolean) => void; }) { const manager = useProjectStoreManager(); const activeProjectCwd = useSyncExternalStore( manager.subscribe, manager.getSnapshot, manager.getSnapshot, ); const [projects, setProjects] = useState([]); const [devRoot, setDevRoot] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const loadProjects = useCallback(async (root: string) => { const projRes = await authFetch( `/api/projects?root=${encodeURIComponent(root)}&detail=true`, ); if (!projRes.ok) throw new Error(`Failed to discover projects: ${projRes.status}`); return (await projRes.json()) as ProjectMetadata[]; }, []); // Load projects when panel opens useEffect(() => { if (!open) return; let cancelled = false; async function load() { setLoading(true); setError(null); try { const prefsRes = await authFetch("/api/preferences"); if (!prefsRes.ok) throw new Error(`Failed to load preferences: ${prefsRes.status}`); const prefs = await prefsRes.json(); if (!prefs.devRoot) { setDevRoot(null); setProjects([]); setLoading(false); return; } setDevRoot(prefs.devRoot); const discovered = await loadProjects(prefs.devRoot); if (!cancelled) setProjects(discovered); } catch (err) { if (!cancelled) { setError(err instanceof Error ? err.message : "Unknown error"); } } finally { if (!cancelled) setLoading(false); } } load(); return () => { cancelled = true; }; }, [open, loadProjects]); const handleDevRootSaved = useCallback(async (newRoot: string) => { setLoading(true); setError(null); try { // Validate path and persist in a single call const res = await authFetch("/api/switch-root", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ devRoot: newRoot }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error( (body as { error?: string }).error ?? `Request failed (${res.status})`, ); } const data = (await res.json()) as { devRoot: string; projects: ProjectMetadata[]; }; setDevRoot(data.devRoot); setProjects(data.projects); } catch (err) { setError( err instanceof Error ? err.message : "Failed to switch project root", ); } finally { setLoading(false); } }, []); const [newProjectOpen, setNewProjectOpen] = useState(false); const [changeRootOpen, setChangeRootOpen] = useState(false); const _workspaceState = useSFWorkspaceState(); const handleProjectCreated = useCallback( (newProject: ProjectMetadata) => { setProjects((prev) => [...prev, newProject].sort((a, b) => a.name.localeCompare(b.name)), ); setNewProjectOpen(false); handleSelectProject(newProject); }, [handleSelectProject], ); function handleSelectProject(project: ProjectMetadata) { // Already active — just close the panel if (activeProjectCwd === project.path) { onOpenChange(false); return; } // Close panel immediately — boot happens in the background with a // loading toast managed by WorkspaceChrome onOpenChange(false); manager.switchProject(project.path); } // Sort: active-sf first, then by name const sortedProjects = [...projects].sort((a, b) => { const kindOrder: Record = { "active-sf": 0, "empty-sf": 1, brownfield: 2, "v1-legacy": 3, blank: 4, }; const ka = kindOrder[a.kind] ?? 5; const kb = kindOrder[b.kind] ?? 5; if (ka !== kb) return ka - kb; return a.name.localeCompare(b.name); }); // ─── Content for the various states ────────────────────────────── let content: React.ReactNode; if (loading) { content = (
Discovering projects…
); } else if (error) { content = (

{error}

); } else if (!devRoot) { content = ; } else if (sortedProjects.length === 0) { content = (

No projects found

No project directories discovered in{" "} {devRoot}

); } else { content = (
{/* Project cards */} {sortedProjects.map((project) => ( handleSelectProject(project)} /> ))} {/* Create new project button */} {/* New project dialog */} p.name)} onCreated={handleProjectCreated} />
); } return ( Projects Switch between projects or create a new one {/* Visible header */}

Projects

{devRoot && !loading && (
{devRoot} · {projects.length} project{projects.length !== 1 ? "s" : ""}
)}
{/* Scrollable project list */}
{content}
{/* Folder picker for changing dev root */} void handleDevRootSaved(path)} initialPath={devRoot} />
); } // ─── Active project inline summary (compact for panel card) ──────────── function _ActiveProjectSummary({ workspaceState, }: { workspaceState: ReturnType; }) { const workspace = getLiveWorkspaceIndex(workspaceState); const dashboard = getLiveAutoDashboard(workspaceState); const currentSlice = getCurrentSlice(workspace); if (!workspace) return null; const activeMilestone = workspace.milestones.find( (m) => m.id === workspace.active.milestoneId, ); const cost = dashboard?.totalCost ?? 0; const parts: string[] = []; if (activeMilestone) parts.push(activeMilestone.id); if (currentSlice) parts.push(currentSlice.id); if (cost > 0) parts.push(formatCost(cost)); if (parts.length === 0) return null; return (
{parts.join(" · ")}
); } // ─── New Project Dialog ──────────────────────────────────────────────── function NewProjectDialog({ open, onOpenChange, devRoot, existingNames, onCreated, }: { open: boolean; onOpenChange: (open: boolean) => void; devRoot: string; existingNames: string[]; onCreated: (project: ProjectMetadata) => void; }) { const [name, setName] = useState(""); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); const inputRef = useRef(null); useEffect(() => { if (open) { // eslint-disable-next-line react-hooks/set-state-in-effect -- reset form state on dialog open setName(""); setError(null); setCreating(false); const t = setTimeout(() => inputRef.current?.focus(), 100); return () => clearTimeout(t); } }, [open]); const nameValid = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name); const nameConflict = existingNames.includes(name); const canSubmit = name.length > 0 && nameValid && !nameConflict && !creating; const validationHint = (() => { if (!name) return null; if (nameConflict) return "A project with this name already exists"; if (!nameValid) return "Use letters, numbers, hyphens, underscores, dots. Must start with a letter or number."; return null; })(); async function handleCreate() { if (!canSubmit) return; setCreating(true); setError(null); try { const res = await authFetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ devRoot, name }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error( (body as { error?: string }).error ?? `Failed (${res.status})`, ); } const project = (await res.json()) as ProjectMetadata; onCreated(project); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create project"); setCreating(false); } } return ( New Project Create a new project directory in{" "} {devRoot}
{ e.preventDefault(); void handleCreate(); }} className="space-y-4 py-2" >
{ setName(e.target.value); setError(null); }} autoComplete="off" aria-invalid={!!validationHint} /> {validationHint && (

{validationHint}

)} {error &&

{error}

} {name && nameValid && !nameConflict && (

{devRoot}/{name}

)}
); } // ─── Folder Picker Dialog ─────────────────────────────────────────────── interface BrowseEntry { name: string; path: string; } interface BrowseResult { current: string; parent: string | null; entries: BrowseEntry[]; } function FolderPickerDialog({ open, onOpenChange, onSelect, initialPath, }: { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (path: string) => void; initialPath?: string | null; }) { const [currentPath, setCurrentPath] = useState(""); const [parentPath, setParentPath] = useState(null); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const browse = useCallback(async (targetPath?: string) => { setLoading(true); setError(null); try { const param = targetPath ? `?path=${encodeURIComponent(targetPath)}` : ""; const res = await authFetch(`/api/browse-directories${param}`); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error((body as { error?: string }).error ?? `${res.status}`); } const data: BrowseResult = await res.json(); setCurrentPath(data.current); setParentPath(data.parent); setEntries(data.entries); } catch (err) { setError(err instanceof Error ? err.message : "Failed to browse"); } finally { setLoading(false); } }, []); useEffect(() => { if (open) { // eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState runs after await void browse(initialPath ?? undefined); } }, [open, initialPath, browse]); return ( Choose Folder Navigate to the folder that contains your project directories.

{currentPath}

{loading && (
)} {error && (
{error}
)} {!loading && !error && ( <> {parentPath && ( )} {entries.map((entry) => ( ))} {!parentPath && entries.length === 0 && (
No subdirectories
)} )}
); } // ─── Dev Root Setup Component ─────────────────────────────────────────── function DevRootSetup({ onSaved, currentRoot, }: { onSaved: (root: string) => void; currentRoot?: string | null; }) { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); const handleSave = useCallback( async (selectedPath: string) => { setSaving(true); setError(null); setSuccess(false); try { const res = await authFetch("/api/preferences", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ devRoot: selectedPath }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error( (body as { error?: string }).error ?? `Request failed (${res.status})`, ); } setSuccess(true); onSaved(selectedPath); } catch (err) { setError( err instanceof Error ? err.message : "Failed to save preference", ); } finally { setSaving(false); } }, [onSaved], ); const isCompact = !!currentRoot; if (isCompact) { return (
{currentRoot}
{error &&

{error}

} {success &&

Dev root updated

} void handleSave(path)} initialPath={currentRoot} />
); } // Inline setup for first-time configuration return (

Set your development root

Point SF at the folder that contains your project directories. It scans one level deep.

{error &&

{error}

}
void handleSave(path)} />
); } // ─── Exported Dev Root Section for Settings ────────────────────────────── export function DevRootSettingsSection() { const [devRoot, setDevRoot] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { authFetch("/api/preferences") .then((r) => r.json()) .then((prefs) => setDevRoot(prefs.devRoot ?? null)) .catch(() => setDevRoot(null)) .finally(() => setLoading(false)); }, []); if (loading) { return (
Loading preferences…
); } return (

Development Root

The parent folder containing your project directories. SF scans one level deep for projects.

setDevRoot(root)} />
); } // ─── Project Selection Gate ───────────────────────────────────────────── // // Full-screen IDE-style welcome shown before any project is opened. // Designed to feel like opening the app — not a wizard or onboarding flow. // Mirrors the app shell layout: header bar, sidebar-width left column, // project list as the main content area. export function ProjectSelectionGate() { const manager = useProjectStoreManager(); const [projects, setProjects] = useState([]); const [devRoot, setDevRoot] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [newProjectOpen, setNewProjectOpen] = useState(false); const [changeRootOpen, setChangeRootOpen] = useState(false); const [filter, setFilter] = useState(""); const loadProjects = useCallback(async (root: string) => { const projRes = await authFetch( `/api/projects?root=${encodeURIComponent(root)}&detail=true`, ); if (!projRes.ok) throw new Error(`Failed to discover projects: ${projRes.status}`); return (await projRes.json()) as ProjectMetadata[]; }, []); useEffect(() => { let cancelled = false; async function load() { setLoading(true); setError(null); try { const prefsRes = await authFetch("/api/preferences"); if (!prefsRes.ok) throw new Error(`Failed to load preferences: ${prefsRes.status}`); const prefs = await prefsRes.json(); if (!prefs.devRoot) { setDevRoot(null); setProjects([]); setLoading(false); return; } setDevRoot(prefs.devRoot); const discovered = await loadProjects(prefs.devRoot); if (!cancelled) setProjects(discovered); } catch (err) { if (!cancelled) { setError(err instanceof Error ? err.message : "Unknown error"); } } finally { if (!cancelled) setLoading(false); } } load(); return () => { cancelled = true; }; }, [loadProjects]); const handleDevRootSaved = useCallback(async (newRoot: string) => { setLoading(true); setError(null); try { const res = await authFetch("/api/switch-root", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ devRoot: newRoot }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error( (body as { error?: string }).error ?? `Request failed (${res.status})`, ); } const data = (await res.json()) as { devRoot: string; projects: ProjectMetadata[]; }; setDevRoot(data.devRoot); setProjects(data.projects); } catch (err) { setError( err instanceof Error ? err.message : "Failed to switch project root", ); } finally { setLoading(false); } }, []); const handleProjectCreated = useCallback( (newProject: ProjectMetadata) => { setProjects((prev) => [...prev, newProject].sort((a, b) => a.name.localeCompare(b.name)), ); setNewProjectOpen(false); manager.switchProject(newProject.path); }, [manager], ); function handleSelectProject(project: ProjectMetadata) { manager.switchProject(project.path); } // Sort: active-sf first, then by name const sortedProjects = [...projects].sort((a, b) => { const kindOrder: Record = { "active-sf": 0, "empty-sf": 1, brownfield: 2, "v1-legacy": 3, blank: 4, }; const ka = kindOrder[a.kind] ?? 5; const kb = kindOrder[b.kind] ?? 5; if (ka !== kb) return ka - kb; return a.name.localeCompare(b.name); }); // Filter projects by name const filteredProjects = filter.trim() ? sortedProjects.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()), ) : sortedProjects; const hasProjects = !loading && sortedProjects.length > 0; const showFilter = sortedProjects.length > 5; return (
{/* ─── Main content ─── */}
{/* ─── Logo + subtitle ─── */}
SF SF

Select a project to get started

{/* Loading */} {loading && (
Scanning for projects…
)} {/* Error */} {error && !loading && (
{error}
)} {/* No dev root — show setup */} {!devRoot && !loading && !error && (

Welcome to SF

Set a development root to get started. SF will discover projects inside it.

)} {/* No projects found */} {devRoot && !loading && sortedProjects.length === 0 && !error && (

No projects found

No project directories were discovered. Create one to get started.

)} {/* ─── Project list ─── */} {hasProjects && (
{/* Dev root + change button */} {devRoot && (
{devRoot}
)} {/* Filter + count */}

{sortedProjects.length} project {sortedProjects.length !== 1 ? "s" : ""}

{showFilter && (
setFilter(e.target.value)} className="h-8 w-full rounded-md border border-border bg-background pl-8 pr-3 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" />
)}
{/* Project rows — table-like, dense */}
{filteredProjects.map((project) => { const style = KIND_STYLE[project.kind]; const KindIcon = style.icon; const stack = techStack(project.signals); const progress = project.progress ? progressLabel(project.progress) : null; const hasBar = project.progress && project.progress.milestonesTotal > 0; const pct = hasBar ? Math.round( (project.progress!.milestonesCompleted / project.progress!.milestonesTotal) * 100, ) : 0; return ( ); })} {/* Empty filter state */} {filteredProjects.length === 0 && filter.trim() && (
No projects matching "{filter}"
)}
{/* Create new row */} {devRoot && ( p.name)} onCreated={handleProjectCreated} /> )}
)} {/* Change root for "no projects" and "no devRoot" states */} {devRoot && !loading && sortedProjects.length === 0 && !error && (
)}
{/* Folder picker for changing dev root */} void handleDevRootSaved(path)} initialPath={devRoot} />
); }