"use client" import Image from "next/image" import { useEffect, useState, useCallback, useRef, useSyncExternalStore } from "react" import { FolderOpen, Loader2, AlertCircle, Layers, Sparkles, ArrowUpCircle, GitBranch, CheckCircle2, FolderRoot, Plus, ArrowRight, X, ChevronRight, Folder, CornerLeftUp, Search, Clock, } from "lucide-react" import { cn } from "@/lib/utils" import { useProjectStoreManager } from "@/lib/project-store-manager" import { useSFWorkspaceState, getLiveWorkspaceIndex, getLiveAutoDashboard, formatCost, getCurrentSlice, } from "@/lib/sf-workspace-store" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet" import { ScrollArea } from "@/components/ui/scroll-area" import { authFetch } from "@/lib/auth" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" // ─── Types (mirroring server-side ProjectMetadata) ───────────────────────── type ProjectDetectionKind = "active-sf" | "empty-sf" | "v1-legacy" | "brownfield" | "blank" interface ProjectDetectionSignals { hasGsdFolder: 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 = { "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) }, // eslint-disable-next-line react-hooks/exhaustive-deps [], ) 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) { 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) { 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} />
) }