From a4caf10a07e918c86c416fce20befbaba67d03a5 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 28 Feb 2026 20:09:58 -0500 Subject: [PATCH 1/9] feat: phase 1 -- DirectoryPicker card grid + types + shadcn checkbox (OPE-111) New component: DirectoryPicker.tsx (252 lines) - Modal with card grid for monorepo package selection - Cards sized proportionally to file count (120-240px) - Selected: primary border + glow. Deselected: muted/ghost - Click to toggle, Select All/Deselect All checkbox - Header: owner/repo with file + function counts - Footer: selection summary + Clone & Index / Cancel buttons - Uses shadcn Card, Button, Checkbox, ScrollArea New: checkbox.tsx (shadcn) + @radix-ui/react-checkbox New types: DirectoryEntry, AnalyzeResult in types.ts Build verified: bun run build passes --- frontend/bun.lock | 3 + frontend/package.json | 1 + frontend/src/components/DirectoryPicker.tsx | 252 ++++++++++++++++++++ frontend/src/components/ui/checkbox.tsx | 30 +++ frontend/src/types.ts | 22 ++ 5 files changed, 308 insertions(+) create mode 100644 frontend/src/components/DirectoryPicker.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx diff --git a/frontend/bun.lock b/frontend/bun.lock index b293641..b540c39 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -247,6 +248,8 @@ "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], diff --git a/frontend/package.json b/frontend/package.json index 2183d4e..3f704b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "dependencies": { "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/frontend/src/components/DirectoryPicker.tsx b/frontend/src/components/DirectoryPicker.tsx new file mode 100644 index 0000000..29cf09c --- /dev/null +++ b/frontend/src/components/DirectoryPicker.tsx @@ -0,0 +1,252 @@ +/** + * DirectoryPicker -- monorepo package selection before indexing. + * + * Shows an interactive card grid where each package is a clickable card + * sized proportionally to its file count. Users select which packages + * to index instead of the entire repo. + */ + +import { useState, useMemo } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { FolderGit2, X, Files, FunctionSquare } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { ScrollArea } from '@/components/ui/scroll-area' +import { cn } from '@/lib/utils' +import type { AnalyzeResult, DirectoryEntry } from '@/types' + +interface DirectoryPickerProps { + isOpen: boolean + onClose: () => void + repoInfo: AnalyzeResult + onConfirm: (selectedPaths: string[]) => void + loading: boolean +} + +export function DirectoryPicker({ + isOpen, + onClose, + repoInfo, + onConfirm, + loading, +}: DirectoryPickerProps) { + const [selected, setSelected] = useState>(new Set()) + + const maxFiles = useMemo( + () => Math.max(...repoInfo.directories.map((d) => d.file_count), 1), + [repoInfo.directories], + ) + + const stats = useMemo(() => { + const dirs = repoInfo.directories.filter((d) => selected.has(d.path)) + return { + files: dirs.reduce((sum, d) => sum + d.file_count, 0), + functions: dirs.reduce((sum, d) => sum + d.estimated_functions, 0), + count: dirs.length, + } + }, [selected, repoInfo.directories]) + + const allSelected = selected.size === repoInfo.directories.length + + function toggleDir(path: string) { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + } + + function toggleAll() { + if (allSelected) { + setSelected(new Set()) + } else { + setSelected(new Set(repoInfo.directories.map((d) => d.path))) + } + } + + return ( + + {isOpen && ( + !loading && onClose()} + > + e.stopPropagation()} + className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col max-h-[85vh]" + > + + +
+

+ Select the packages you need for faster indexing and more focused results. +

+
+ + +
+
+ + +
+ {repoInfo.directories.map((dir) => ( + toggleDir(dir.path)} + /> + ))} +
+
+ + onConfirm(Array.from(selected))} + /> +
+
+ )} +
+ ) +} + + +function PickerHeader({ + repoInfo, + onClose, + loading, +}: { + repoInfo: AnalyzeResult + onClose: () => void + loading: boolean +}) { + return ( +
+
+
+ +
+
+

+ {repoInfo.owner}/{repoInfo.repo} +

+
+ + + {repoInfo.total_files.toLocaleString()} files + + + + ~{repoInfo.total_estimated_functions.toLocaleString()} functions + +
+
+
+ +
+ ) +} + + +function PackageCard({ + dir, + isSelected, + maxFiles, + onToggle, +}: { + dir: DirectoryEntry + isSelected: boolean + maxFiles: number + onToggle: () => void +}) { + // Scale card width: smallest = 120px, largest = 240px + const scale = dir.file_count / maxFiles + const minWidth = Math.round(120 + scale * 120) + + return ( + + ) +} + + +function PickerFooter({ + stats, + loading, + onCancel, + onConfirm, +}: { + stats: { files: number; functions: number; count: number } + loading: boolean + onCancel: () => void + onConfirm: () => void +}) { + const hasSelection = stats.count > 0 + + return ( +
+

+ {hasSelection + ? `Selected: ${stats.files.toLocaleString()} files (~${stats.functions.toLocaleString()} functions) from ${stats.count} ${stats.count === 1 ? 'package' : 'packages'}` + : 'No packages selected'} +

+
+ + +
+
+ ) +} diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..df61a13 --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7f6de78..f954efc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -19,3 +19,25 @@ export interface SearchResult { } export type RepoTab = 'overview' | 'search' | 'dependencies' | 'insights' | 'impact' + +// POST /repos/analyze response +export interface DirectoryEntry { + name: string + path: string + file_count: number + estimated_functions: number +} + +export interface AnalyzeResult { + owner: string + repo: string + default_branch: string + total_files: number + total_estimated_functions: number + size_kb: number + stars: number + language: string | null + directories: DirectoryEntry[] + suggestion: string | null + truncated: boolean +} From c10b17a82fc4a5f420f87033ede342ef6e107f0d Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 28 Feb 2026 20:14:44 -0500 Subject: [PATCH 2/9] feat: phase 2 -- budget bar with tier-aware limit display (OPE-112) BudgetBar component shows estimated functions vs tier limit: - Green bar: under 70% of limit - Amber bar: 70-100% of limit - Red bar: over limit, with 'Over your plan limit by X functions' text - Smooth CSS transition on width changes (300ms ease-out) - Counter shows 'current / limit' with red text when over PickerFooter updated: - Disabled 'Clone & Index' button when over limit - Button turns destructive/red with 'Over plan limit' text - Dynamic button label based on state (loading/empty/over/ready) Tier limits added to config/api.ts: free=2K, pro=20K, enterprise=500K 305 lines total (5 sub-components, logically cohesive). Build passes. --- frontend/src/components/DirectoryPicker.tsx | 67 ++++++++++++++++++--- frontend/src/config/api.ts | 9 +++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/DirectoryPicker.tsx b/frontend/src/components/DirectoryPicker.tsx index 29cf09c..1b9d047 100644 --- a/frontend/src/components/DirectoryPicker.tsx +++ b/frontend/src/components/DirectoryPicker.tsx @@ -29,6 +29,7 @@ export function DirectoryPicker({ repoInfo, onConfirm, loading, + functionLimit, }: DirectoryPickerProps) { const [selected, setSelected] = useState>(new Set()) @@ -118,9 +119,14 @@ export function DirectoryPicker({ + {functionLimit && ( + + )} + functionLimit} onCancel={onClose} onConfirm={() => onConfirm(Array.from(selected))} /> @@ -211,18 +217,64 @@ function PackageCard({ } +function BudgetBar({ current, limit }: { current: number; limit: number }) { + const pct = Math.min((current / limit) * 100, 100) + const overPct = current > limit ? Math.min(((current - limit) / limit) * 100, 50) : 0 + + // green < 70%, amber 70-100%, red > 100% + const barColor = + current > limit + ? 'bg-destructive' + : pct > 70 + ? 'bg-amber-500' + : 'bg-emerald-500' + + return ( +
+
+ Estimated functions + limit && 'text-destructive font-medium')}> + {current.toLocaleString()} / {limit.toLocaleString()} + +
+
+
+
+ {current > limit && ( +

+ Over your plan limit by {(current - limit).toLocaleString()} functions +

+ )} +
+ ) +} + + function PickerFooter({ stats, loading, + overLimit, onCancel, onConfirm, }: { stats: { files: number; functions: number; count: number } loading: boolean + overLimit: boolean onCancel: () => void onConfirm: () => void }) { const hasSelection = stats.count > 0 + const canConfirm = hasSelection && !overLimit && !loading + + function buttonLabel() { + if (loading) return 'Starting...' + if (!hasSelection) return 'Select packages to continue' + if (overLimit) return 'Over plan limit -- deselect packages' + return `Clone & Index ${stats.count} ${stats.count === 1 ? 'Package' : 'Packages'}` + } return (
@@ -237,14 +289,15 @@ function PickerFooter({
diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 2fcaae0..e430908 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -60,3 +60,12 @@ export const buildWsUrl = (path: string): string => { // free tier repo limit -- used in dashboard and GitHub import export const MAX_FREE_REPOS = 3 + +// function limits per tier -- used by DirectoryPicker budget bar +export const TIER_FUNCTION_LIMITS = { + free: 2_000, + pro: 20_000, + enterprise: 500_000, +} as const + +export type TierName = keyof typeof TIER_FUNCTION_LIMITS From a7c4550d1a9d97636f6649e29b0adbe24d001a25 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 28 Feb 2026 20:15:56 -0500 Subject: [PATCH 3/9] feat: phase 3 -- staggered entry animation + card hover effects (OPE-113) Cards now appear one-by-one with staggered fade+slide (40ms per card): - framer-motion staggerChildren on card grid container - Each card: opacity 0->1, y 8->0 on mount - Total stagger for 33 packages: ~1.3s (feels deliberate, not slow) Card hover polish: - Subtle scale-up (1.02x) on hover - Shadow lift on deselected cards - 200ms transition for snappy feel 320 lines. Build passes. --- frontend/src/components/DirectoryPicker.tsx | 35 +++++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/DirectoryPicker.tsx b/frontend/src/components/DirectoryPicker.tsx index 1b9d047..0de3303 100644 --- a/frontend/src/components/DirectoryPicker.tsx +++ b/frontend/src/components/DirectoryPicker.tsx @@ -106,17 +106,32 @@ export function DirectoryPicker({ -
+ {repoInfo.directories.map((dir) => ( - toggleDir(dir.path)} - /> + variants={{ + hidden: { opacity: 0, y: 8 }, + visible: { opacity: 1, y: 0 }, + }} + > + toggleDir(dir.path)} + /> + ))} -
+
{functionLimit && ( @@ -201,10 +216,10 @@ function PackageCard({ onClick={onToggle} style={{ minWidth }} className={cn( - 'flex flex-col gap-1 rounded-lg border p-3 text-left transition-all cursor-pointer', + 'flex flex-col gap-1 rounded-lg border p-3 text-left transition-all duration-200 cursor-pointer hover:scale-[1.02]', isSelected ? 'border-primary bg-primary/5 shadow-sm shadow-primary/10' - : 'border-border bg-card/50 opacity-60 hover:opacity-80 hover:border-muted-foreground/30', + : 'border-border bg-card/50 opacity-60 hover:opacity-80 hover:border-muted-foreground/30 hover:shadow-sm', )} > {dir.name} From 3b695b515f11f313cce4563382316560ed8ccbb3 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 28 Feb 2026 20:20:00 -0500 Subject: [PATCH 4/9] feat: phase 4 -- wire DirectoryPicker to analyze API + DashboardHome flow (OPE-114) AddRepoForm.tsx: - On submit, calls POST /repos/analyze for GitHub URLs - If large repo (suggestion: 'large_repo'), calls onAnalyzed callback - If small repo or non-GitHub, proceeds directly to clone+index - Button shows 'Analyzing...' state with pulse icon during API call - Graceful fallback: if analyze fails, falls through to direct add DashboardHome.tsx: - New state: analyzeResult, pendingGitUrl/Branch, showDirectoryPicker - handleAnalyzed: opens DirectoryPicker with analyze result - handleDirectoryConfirm: calls addAndIndex with include_paths - addAndIndex sends IndexConfig body when include_paths provided - DirectoryPicker wired with TIER_FUNCTION_LIMITS.free as budget limit Flow: URL -> Analyze -> Picker (large) -> Clone -> Index (subset) or: URL -> Analyze -> Clone -> Index (small, no picker) Build passes. 194 + 295 + 320 lines across 3 files. --- frontend/src/components/AddRepoForm.tsx | 52 +++++++++++--- .../components/dashboard/DashboardHome.tsx | 68 ++++++++++++++++++- 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/AddRepoForm.tsx b/frontend/src/components/AddRepoForm.tsx index dbcd095..64251ac 100644 --- a/frontend/src/components/AddRepoForm.tsx +++ b/frontend/src/components/AddRepoForm.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { Package, Plus, X, Loader2 } from 'lucide-react' +import { Package, Plus, X, Loader2, Search } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { API_URL } from '@/config/api' +import type { AnalyzeResult } from '@/types' // Discriminated union: if isOpen is provided, onOpenChange is required type UncontrolledProps = { @@ -18,21 +20,50 @@ type ControlledProps = { type AddRepoFormProps = { onAdd: (gitUrl: string, branch: string) => Promise + onAnalyzed?: (result: AnalyzeResult, gitUrl: string, branch: string) => void loading: boolean } & (UncontrolledProps | ControlledProps) -export function AddRepoForm({ onAdd, loading, isOpen, onOpenChange }: AddRepoFormProps) { +export function AddRepoForm({ onAdd, onAnalyzed, loading, isOpen, onOpenChange }: AddRepoFormProps) { const [gitUrl, setGitUrl] = useState('') const [branch, setBranch] = useState('main') + const [analyzing, setAnalyzing] = useState(false) const [internalOpen, setInternalOpen] = useState(false) const isControlled = isOpen !== undefined const showForm = isControlled ? isOpen : internalOpen const setShowForm = isControlled ? onOpenChange : setInternalOpen + const isBusy = loading || analyzing const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!gitUrl) return + + // If onAnalyzed provided and URL is GitHub, analyze first + if (onAnalyzed && gitUrl.includes('github.com')) { + try { + setAnalyzing(true) + const resp = await fetch(`${API_URL}/repos/analyze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ github_url: gitUrl }), + }) + if (resp.ok) { + const result: AnalyzeResult = await resp.json() + if (result.suggestion === 'large_repo') { + onAnalyzed(result, gitUrl, branch) + setShowForm(false) + return + } + } + // Small repo or non-GitHub: proceed directly + } catch { + // Analyze failed -- fall through to direct add + } finally { + setAnalyzing(false) + } + } + await onAdd(gitUrl, branch) setGitUrl('') setBranch('main') @@ -83,7 +114,7 @@ export function AddRepoForm({ onAdd, loading, isOpen, onOpenChange }: AddRepoFor @@ -100,7 +131,7 @@ export function AddRepoForm({ onAdd, loading, isOpen, onOpenChange }: AddRepoFor onChange={(e) => setGitUrl(e.target.value)} placeholder="https://github.com/username/repo" required - disabled={loading} + disabled={isBusy} autoFocus /> @@ -114,7 +145,7 @@ export function AddRepoForm({ onAdd, loading, isOpen, onOpenChange }: AddRepoFor onChange={(e) => setBranch(e.target.value)} placeholder="main" required - disabled={loading} + disabled={isBusy} />

Repository will be cloned and automatically indexed

@@ -125,17 +156,22 @@ export function AddRepoForm({ onAdd, loading, isOpen, onOpenChange }: AddRepoFor type="button" variant="outline" onClick={() => { setShowForm(false); setGitUrl(''); setBranch('main') }} - disabled={loading} + disabled={isBusy} className="flex-1" > Cancel