diff --git a/app/analyze/page.tsx b/app/analyze/page.tsx index d28383c..09b6445 100644 --- a/app/analyze/page.tsx +++ b/app/analyze/page.tsx @@ -1,59 +1,39 @@ "use client"; -import { useSearchParams, useRouter } from "next/navigation"; -import useSWR from "swr"; -import { fetcher } from "@/lib/fetcher"; -import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { useEffect, useState, - Suspense, - useMemo, - useCallback, useRef, + useCallback, + Suspense, memo, } from "react"; -import { motion, AnimatePresence, useAnimationControls } from "framer-motion"; +import { useSearchParams, useRouter } from "next/navigation"; +import { AnimatePresence } from "framer-motion"; import dynamic from "next/dynamic"; -import Link from "next/link"; -import { - ArrowLeft, - FileCode, - Terminal, - Cpu, - Layers, - ThumbsUp, - ThumbsDown, - MessageSquare, - X, - LayoutGrid, - Activity, - GitPullRequest, - Target, - CheckCircle2, -} from "lucide-react"; -import { FaGithub } from "react-icons/fa"; - -// Global Components -import ExportButton from "@/components/ExportButton"; -import ShareButton from "@/components/ShareButton"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { createClient } from "@/lib/supabase-browser"; -import { RepoData } from "@/lib/types/analyze"; - -// --- Skeleton Extracted (No Re-allocation) --- -const SkeletonLoader = memo(() => ( -
-
-
-
-
-
-
-
-)); -SkeletonLoader.displayName = "SkeletonLoader"; -// --- Lazy Load ALL Tab Components --- +// ─── Hooks ──────────────────────────────────────────────────────────────────── +import { useAnalyzeData } from "@/hooks/analyze/useAnalyzeData"; +import { useAnalyzeUIState } from "@/hooks/analyze/useAnalyzeUIState"; +import { useFeedback } from "@/hooks/analyze/useFeedback"; +import { useLoadingAnimation } from "@/hooks/analyze/useLoadingAnimation"; + +// ─── Components ─────────────────────────────────────────────────────────────── +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; +import AnalyzeLoadingScreen from "@/components/analyze/AnalyzeLoadingScreen"; +import AnalyzeErrorScreen from "@/components/analyze/AnalyzeErrorScreen"; +import GitHubAuthModal from "@/components/analyze/GitHubAuthModal"; +import AnalyzeHeader from "@/components/analyze/AnalyzeHeader"; +import AnalyzeTabBar from "@/components/analyze/AnalyzeTabBar"; +import VisualizerPanel from "@/components/analyze/VisualizerPanel"; +import DoctorPanel from "@/components/analyze/DoctorPanel"; +import RiskRadarPanel from "@/components/analyze/RiskRadarPanel"; +import ChatPanel from "@/components/analyze/ChatPanel"; +import ExitModal from "@/components/analyze/ExitModal"; + +// ─── Lazy Tab Panels (unchanged from original) ──────────────────────────────── const OverviewTab = dynamic(() => import("@/components/analyze/OverviewTab"), { loading: () => , ssr: false, @@ -62,338 +42,64 @@ const PrImpactTab = dynamic(() => import("@/components/analyze/PrImpactTab"), { loading: () => , ssr: false, }); -const DebugInterface = dynamic( - () => import("@/components/debug/DebugInterface"), - { loading: () => , ssr: false }, -); -const ArchitectureMap = dynamic(() => import("@/components/ArchitectureMap"), { - loading: () => , - ssr: false, -}); -const TreemapVisualizer = dynamic( - () => import("@/components/TreemapVisualizer"), - { loading: () => , ssr: false }, -); -const DirectoryTreeVisualizer = dynamic( - () => import("@/components/DirectoryTreeVisualizer"), - { loading: () => , ssr: false }, -); -const RiskDashboard = dynamic(() => import("@/components/RiskDashboard"), { - loading: () => , - ssr: false, -}); -const RepoChat = dynamic(() => import("@/components/RepoChat"), { - loading: () => , - ssr: false, -}); -const EXPO_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]; - -// --- Tab & View Config (No Re-allocation) --- -const TAB_CONFIG = [ - { id: "overview" as const, icon: FileCode, label: "Read Docs" }, - { id: "visualizer" as const, icon: Layers, label: "Blueprint Map" }, - { id: "doctor" as const, icon: Activity, label: "Diagnostic Engine" }, - { id: "pr_impact" as const, icon: GitPullRequest, label: "PR Impact" }, - { id: "risk_radar" as const, icon: Target, label: "Risk Radar" }, -] as const; - -const MAP_VIEW_CONFIG = [ - { id: "graph" as const, label: "Dependency Flow" }, - { id: "directory" as const, label: "Folder Structure" }, - { id: "treemap" as const, label: "Codebase Weight" }, -] as const; - -const LOADING_PHRASES = [ - "Cloning repository...", - "Decrypting source tree...", - "Mapping AST nodes...", - "Tracing execution flows...", - "Calculating blast radius...", - "Evaluating test coverage...", - "Querying Groq LLM...", - "Compiling health report...", -] as const; - -// Default skeleton analysis shown while AI stream is starting -const DEFAULT_ANALYSIS = { - architecture_pattern: "Analyzing...", - what_it_does: "Waking up Groq LLM...", - execution_flow: [], - tech_stack: [], - key_modules: [], - onboarding_guide: [], - evidence_paths: [], - blast_radius: [], - health_status: { - grade: "-", - score: 0, - status: "Pending", - refactor_plan: [], - }, -} as const; - -type TabType = (typeof TAB_CONFIG)[number]["id"]; -type MapViewType = (typeof MAP_VIEW_CONFIG)[number]["id"]; - -// --- Memoized Component (Prevents Unnecessary Re-renders) --- +// ───────────────────────────────────────────────────────────────────────────── + const AnalyzeContent = memo(() => { const searchParams = useSearchParams(); const router = useRouter(); + const repoUrl = searchParams.get("url") || searchParams.get("repo"); const source = searchParams.get("source"); - // UI State - const [showGitHubAuthModal, setShowGitHubAuthModal] = useState(false); - const [isChatOpen, setIsChatOpen] = useState(false); - const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); - const [hideFeedback, setHideFeedback] = useState(false); - const [showExitModal, setShowExitModal] = useState(false); - const exitModalRef = useRef(null); - - // AI Streaming State - const [aiAnalysis, setAiAnalysis] = useState | null>( - null, - ); - const [isAiStreaming, setIsAiStreaming] = useState(false); - - // Mount Flag + // ── Mount flag (prevents SSR / sessionStorage mismatches) ────────────────── const [isMounted, setIsMounted] = useState(false); - - // Safe Synchronous Initialization (Only runs in browser) - const [activeTab, setActiveTab] = useState(() => { - if (typeof window === "undefined") return "overview"; - const saved = sessionStorage.getItem("codeautopsy_tab"); - return saved && TAB_CONFIG.some((t) => t.id === saved) - ? (saved as TabType) - : "overview"; - }); - - const [mapView, setMapView] = useState(() => { - if (typeof window === "undefined") return "graph"; - const saved = sessionStorage.getItem("codeautopsy_view"); - return saved && MAP_VIEW_CONFIG.some((v) => v.id === saved) - ? (saved as MapViewType) - : "graph"; - }); - - // Mark as mounted useEffect(() => { - const timeoutId = setTimeout(() => setIsMounted(true), 0); - return () => clearTimeout(timeoutId); + const id = setTimeout(() => setIsMounted(true), 0); + return () => clearTimeout(id); }, []); - // Sync tab/view changes to sessionStorage - useEffect(() => { - if (isMounted) { - sessionStorage.setItem("codeautopsy_tab", activeTab); - sessionStorage.setItem("codeautopsy_view", mapView); - } - }, [activeTab, mapView, isMounted]); - - // --- Local Codebase Handler (Bypasses API) --- - const [localData] = useState(() => { - if (typeof window === "undefined" || source !== "local") return null; - const stored = sessionStorage.getItem("localAnalysisResult"); - return stored ? JSON.parse(stored) : null; - }); + // ── GitHub Auth Modal ─────────────────────────────────────────────────────── + const [showGitHubAuthModal, setShowGitHubAuthModal] = useState(false); - const [localError] = useState(() => { - if (typeof window === "undefined" || source !== "local") return null; - const stored = sessionStorage.getItem("localAnalysisResult"); - return !stored - ? "Local analysis data expired or lost. Please return to home and upload again." - : null; + // ── Data ──────────────────────────────────────────────────────────────────── + const { data, loading, error, isRateLimit, isAiStreaming } = useAnalyzeData({ + repoUrl, + source, + onRequireGitHubAuth: () => setShowGitHubAuthModal(true), }); - // --- Remote API Fetching (SWR Cache) --- + // ── UI State ──────────────────────────────────────────────────────────────── const { - data: swrData, - error: swrError, - isLoading: swrLoading, - } = useSWR( - repoUrl && source !== "local" ? ["/api/analyze", repoUrl] : null, - ([url, repo]) => fetcher(url, repo), - { - revalidateOnFocus: false, - shouldRetryOnError: false, - onError: (err) => { - if (err.message === "REQUIRE_GITHUB_AUTH") setShowGitHubAuthModal(true); - }, - }, - ); - - // --- REAL-TIME AI STREAMING ENGINE --- - useEffect(() => { - if (swrData && swrData.analysis === null && !isAiStreaming && !aiAnalysis) { - setIsAiStreaming(true); - - const fetchAiStream = async () => { - try { - const res = await fetch("/api/ai", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - repoName: `${swrData.owner}/${swrData.repo}`, - description: swrData.description, - entryPoints: swrData.entryPoints, - topFiles: swrData.topFilesForGroq, - fileContents: swrData.fileContents, - blastRadiusTargets: swrData.blastRadiusTargets, - healthMetrics: swrData.healthMetrics, - }), - }); - - if (!res.body) return; - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let jsonText = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - jsonText += decoder.decode(value, { stream: true }); - - // 1. Try to parse the complete JSON first - try { - setAiAnalysis(JSON.parse(jsonText)); - } catch { - // 2. On-the-fly Partial JSON Repair for live UI rendering - try { - let fixed = jsonText.replace(/("[^"]*)$/, '"'); // Close any open string - const openBraces = (fixed.match(/\{/g) || []).length; - const closeBraces = (fixed.match(/\}/g) || []).length; - const openBrackets = (fixed.match(/\[/g) || []).length; - const closeBrackets = (fixed.match(/\]/g) || []).length; - - fixed += - "]".repeat(Math.max(0, openBrackets - closeBrackets)) + - "}".repeat(Math.max(0, openBraces - closeBraces)); - - const partial = JSON.parse(fixed); - if (partial) setAiAnalysis(partial); - } catch { - // Silently wait for the next chunk if still unparseable - } - } - } - } catch (err) { - console.error("AI Stream Failed:", err); - } finally { - setIsAiStreaming(false); - } - }; - - fetchAiStream(); - } - }, [swrData, isAiStreaming, aiAnalysis]); - - // --- Unify Data Sources with Zod Bypass + AI Stream Injection --- - const isLocal = source === "local"; - const loading = isLocal ? !localData && !localError : swrLoading; - const rawError = isLocal - ? localError - : swrError?.message === "REQUIRE_GITHUB_AUTH" - ? null - : swrError?.message; - - const data = useMemo(() => { - if (isLocal) return localData; - if (!swrData || swrError) return null; - - // Bypass strict Zod validation: the incoming AI stream is intentionally incomplete. - // Inject streaming aiAnalysis (or a safe placeholder) directly into the SWR payload. - return { - ...swrData, - analysis: - aiAnalysis ?? - (swrData.analysis === null ? DEFAULT_ANALYSIS : swrData.analysis), - }; - }, [isLocal, localData, swrData, swrError, aiAnalysis]); - - const error = rawError ?? null; - - const isRateLimit = useMemo( - () => - !!error && - (error.includes("RATE_LIMIT_REACHED") || error.includes("Daily limit")), - [error], - ); - - // Loading Animation Loop - const [loadingStep, setLoadingStep] = useState(0); - const loadingControls = useAnimationControls(); - - useEffect(() => { - if (!loading) return; - let isActive = true; - - const animate = async () => { - while (isActive) { - await loadingControls.start({ - opacity: [0, 1], - transition: { duration: 0.4, ease: "easeOut" }, - }); - await new Promise((resolve) => setTimeout(resolve, 800)); - await loadingControls.start({ - opacity: [1, 0], - transition: { duration: 0.4, ease: "easeIn" }, - }); - if (isActive) - setLoadingStep((prev) => (prev + 1) % LOADING_PHRASES.length); - } - }; - - animate(); - return () => { - isActive = false; - }; - }, [loading, loadingControls]); - - // Escape Key & Modal Focus Traps - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key !== "Escape") return; - if (showExitModal) setShowExitModal(false); - else if (showGitHubAuthModal) router.push("/"); - else if (isChatOpen) setIsChatOpen(false); - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [showExitModal, showGitHubAuthModal, isChatOpen, router]); + activeTab, + setActiveTab, + mapView, + setMapView, + isChatOpen, + setIsChatOpen, + showExitModal, + setShowExitModal, + } = useAnalyzeUIState({ + onEscapeFromAuthModal: () => router.push("/"), + showGitHubAuthModal, + isMounted, + }); - useEffect(() => { - if (!showExitModal) return; - const modal = exitModalRef.current; - if (!modal) return; + // ── Exit modal focus trap ref ─────────────────────────────────────────────── + const exitModalRef = useRef(null); - const focusable = modal.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ); - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - const trap = (e: KeyboardEvent) => { - if (e.key !== "Tab") return; - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault(); - last?.focus(); - } - } else if (document.activeElement === last) { - e.preventDefault(); - first?.focus(); - } - }; + // ── Feedback ──────────────────────────────────────────────────────────────── + const { feedbackSubmitted, hideFeedback, handleFeedback } = useFeedback({ + repoName: data?.repo, + repoUrl, + onExit: () => router.back(), + onCloseExitModal: () => setShowExitModal(false), + }); - first?.focus(); - document.addEventListener("keydown", trap); - return () => document.removeEventListener("keydown", trap); - }, [showExitModal]); + // ── Loading animation ─────────────────────────────────────────────────────── + const { loadingStep, loadingControls } = useLoadingAnimation(loading); - // Handlers + // ── GitHub OAuth handler ──────────────────────────────────────────────────── const handleGitHubLogin = useCallback(async () => { const supabase = createClient(); await supabase.auth.signInWithOAuth({ @@ -405,303 +111,51 @@ const AnalyzeContent = memo(() => { }); }, [repoUrl]); - const handleFeedback = useCallback( - async (isHelpful: boolean, exitAfter = false) => { - setFeedbackSubmitted(true); - if (exitAfter) setShowExitModal(false); - else setTimeout(() => setHideFeedback(true), 2000); - - try { - const supabase = createClient(); - await supabase.from("debug_feedback").insert([ - { - debug_id: data?.repo || repoUrl || "local-upload", - is_helpful: isHelpful, - }, - ]); - } catch (err) { - console.error(err); - } - - if (exitAfter) router.back(); - }, - [data, repoUrl, router], - ); - + // ── Back navigation ───────────────────────────────────────────────────────── const handleBackNavigation = useCallback(() => { if (!feedbackSubmitted) setShowExitModal(true); else router.back(); - }, [feedbackSubmitted, router]); + }, [feedbackSubmitted, router, setShowExitModal]); - // --- RENDERING ROUTER --- + // ── Render branches ───────────────────────────────────────────────────────── if (!isMounted || loading) return ( -
-
-
-
-
- -
-
-

- Performing Autopsy -

-

- {source === "local" - ? "Local.zip Upload" - : repoUrl?.split("/").slice(-2).join("/") || "Repository"} -

-
-
-
-
-
-
-
- - {LOADING_PHRASES[loadingStep]} - -
-
-
-
+ ); if (error) - return ( -
-
-
- {isRateLimit ? ( - - ) : ( - - )} -
-

- {isRateLimit ? "Daily Limit Reached" : "Autopsy Failed"} -

-
-

- {error} -

-
-
- {isRateLimit && ( - router.push("/pricing")} - className="w-full px-6 py-3 rounded-xl bg-white text-black font-bold text-sm hover:scale-[1.02] transition-transform" - > - View Upgrade Options - - )} - router.back()} - className="w-full px-6 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-sm font-bold text-white transition-all flex items-center justify-center gap-2" - > - Go Back - -
-
-
- ); + return ; if (!data) return null; if (showGitHubAuthModal) - return ( -
-
- -
- -
-

- Private Repository -

-

- To analyze private code, you need to authenticate with GitHub. -

-
- - Connect GitHub Account - - -
-
-
- ); + return ; + + // ── Main layout ───────────────────────────────────────────────────────────── return (
- {/* HEADER */} -
-
- -
- -

- {source === "local" ? ( - "Local Codebase" - ) : ( -
- - {data.owner} - - / - {data.repo} -
- )} -

-
- - - {data.language} - -
- {/* Streaming indicator badge */} - {isAiStreaming && ( -
-
- - AI Live - -
- )} -
-
+ handleFeedback(isHelpful)} + /> -
- - - Dashboard - - - {!hideFeedback && ( - - {!feedbackSubmitted ? ( -
- - Helpful? - -
- - -
-
- ) : ( -
- - - Thanks! - -
- )} -
- )} -
- {source !== "local" && ( - - )} - -
-
- - {/* MAIN CONTENT AREA */}
- {/* TAB LIST */} -
-
- {TAB_CONFIG.map((tab) => { - const isActive = activeTab === tab.id; - return ( - - ); - })} -
-
+ - {/* TAB PANELS */} + {/* Tab panels */}
{activeTab === "overview" && ( @@ -789,90 +243,14 @@ const AnalyzeContent = memo(() => { )} {activeTab === "doctor" && ( - -
- {data.mermaidDiagram ? ( - <> -
- - - -
- setIsChatOpen(true)} - className="w-full flex-shrink-0 py-4 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all flex items-center justify-center gap-3 group shadow-lg" - > - - - Discuss this diagnosis in Copilot → - - - - ) : ( -
- -

- Diagnostic core offline. -

-
- )} -
-
+ setIsChatOpen(true)} + /> )} - {activeTab === "risk_radar" && ( - - {data.coverageGaps && data.fileContents ? ( - - - - ) : ( -
- No risk data available for this codebase. -
- )} -
- )} + {activeTab === "risk_radar" && }
@@ -945,61 +323,20 @@ const AnalyzeContent = memo(() => {
- {/* EXIT MODAL */} - - {showExitModal && ( -
- router.back()} - /> - -

- Leaving so soon? -

-

- Quick check: Was this codebase analysis helpful to you? -

-
-
- - -
- -
-
-
- )} -
+ router.back()} + />
); }); + AnalyzeContent.displayName = "AnalyzeContent"; +// ───────────────────────────────────────────────────────────────────────────── + export default function AnalyzePage() { return ( +
+
+ {isRateLimit ? ( + + ) : ( + + )} +
+ +

+ {isRateLimit ? "Daily Limit Reached" : "Autopsy Failed"} +

+ +
+

+ {error} +

+
+ +
+ {isRateLimit && ( + router.push("/pricing")} + className="w-full px-6 py-3 rounded-xl bg-white text-black font-bold text-sm hover:scale-[1.02] transition-transform" + > + View Upgrade Options + + )} + router.back()} + className="w-full px-6 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-sm font-bold text-white transition-all flex items-center justify-center gap-2" + > + Go Back + +
+
+
+ ); +} diff --git a/components/analyze/AnalyzeHeader.tsx b/components/analyze/AnalyzeHeader.tsx new file mode 100644 index 0000000..cce0f99 --- /dev/null +++ b/components/analyze/AnalyzeHeader.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + Terminal, + LayoutGrid, + ThumbsUp, + ThumbsDown, + CheckCircle2, +} from "lucide-react"; +import { FaGithub } from "react-icons/fa"; +import Link from "next/link"; +import ExportButton from "@/components/ExportButton"; +import ShareButton from "@/components/ShareButton"; +import { RepoData } from "@/lib/types/analyze"; + +interface AnalyzeHeaderProps { + data: RepoData; + source: string | null; + isAiStreaming: boolean; + feedbackSubmitted: boolean; + hideFeedback: boolean; + onBack: () => void; + onFeedback: (isHelpful: boolean) => void; +} + +export default function AnalyzeHeader({ + data, + source, + isAiStreaming, + feedbackSubmitted, + hideFeedback, + onBack, + onFeedback, +}: AnalyzeHeaderProps) { + return ( +
+ {/* ── Left: Back + Repo Identity ── */} +
+ + +
+ + +

+ {source === "local" ? ( + "Local Codebase" + ) : ( +
+ + {data.owner} + + / + {data.repo} +
+ )} +

+ +
+ + + {data.language} + +
+ + {/* AI streaming indicator */} + {isAiStreaming && ( +
+
+ + AI Live + +
+ )} +
+
+ + {/* ── Right: Actions ── */} +
+ + + Dashboard + + + {/* Inline feedback widget */} + + {!hideFeedback && ( + + {!feedbackSubmitted ? ( +
+ + Helpful? + +
+ + +
+
+ ) : ( +
+ + + Thanks! + +
+ )} +
+ )} +
+ + {source !== "local" && ( + + )} + +
+
+ ); +} diff --git a/components/analyze/AnalyzeLoadingScreen.tsx b/components/analyze/AnalyzeLoadingScreen.tsx new file mode 100644 index 0000000..df76f63 --- /dev/null +++ b/components/analyze/AnalyzeLoadingScreen.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Cpu } from "lucide-react"; +import { LOADING_PHRASES } from "@/components/analyze/constants"; +import type { LoadingControls } from "@/hooks/analyze/useLoadingAnimation"; + +interface AnalyzeLoadingScreenProps { + repoUrl: string | null; + source: string | null; + loadingStep: number; + loadingControls: LoadingControls; +} + +export default function AnalyzeLoadingScreen({ + repoUrl, + source, + loadingStep, + loadingControls, +}: AnalyzeLoadingScreenProps) { + return ( +
+
+
+
+
+ +
+
+

+ Performing Autopsy +

+

+ {source === "local" + ? "Local.zip Upload" + : repoUrl?.split("/").slice(-2).join("/") || "Repository"} +

+
+
+
+
+
+
+
+ + {LOADING_PHRASES[loadingStep]} + +
+
+
+
+ ); +} diff --git a/components/analyze/AnalyzeTabBar.tsx b/components/analyze/AnalyzeTabBar.tsx new file mode 100644 index 0000000..fc911f6 --- /dev/null +++ b/components/analyze/AnalyzeTabBar.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { motion } from "framer-motion"; +import { TAB_CONFIG, TabType } from "@/components/analyze/constants"; + +interface AnalyzeTabBarProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; +} + +export default function AnalyzeTabBar({ + activeTab, + onTabChange, +}: AnalyzeTabBarProps) { + return ( +
+
+ {TAB_CONFIG.map((tab) => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/components/analyze/ChatPanel.tsx b/components/analyze/ChatPanel.tsx new file mode 100644 index 0000000..a4df451 --- /dev/null +++ b/components/analyze/ChatPanel.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { Terminal, MessageSquare, X } from "lucide-react"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { EXPO_OUT } from "@/components/analyze/constants"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const RepoChat = dynamic(() => import("@/components/RepoChat"), { + loading: () => , + ssr: false, +}); + +interface ChatPanelProps { + data: RepoData; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; +} + +export default function ChatPanel({ + data, + isOpen, + onOpen, + onClose, +}: ChatPanelProps) { + return ( + <> + {/* Floating toggle button — visible only when panel is closed */} + + {!isOpen && ( + +
+ + +
+
+ Need Help? + + Ask Copilot + +
+
+ )} +
+ + {/* Slide-in panel */} + + {isOpen && ( + + {/* Panel header */} +
+
+ +

+ Autopsy Copilot +

+
+ +
+ + {/* Chat content */} +
+ + + +
+
+ )} +
+ + ); +} diff --git a/components/analyze/DoctorPanel.tsx b/components/analyze/DoctorPanel.tsx new file mode 100644 index 0000000..4a9c756 --- /dev/null +++ b/components/analyze/DoctorPanel.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Terminal, MessageSquare } from "lucide-react"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const DebugInterface = dynamic( + () => import("@/components/debug/DebugInterface"), + { loading: () => , ssr: false }, +); + +interface DoctorPanelProps { + data: RepoData; + source: string | null; + onOpenChat: () => void; +} + +export default function DoctorPanel({ + data, + source, + onOpenChat, +}: DoctorPanelProps) { + const repoUrl = + source === "local" + ? "Local.zip Codebase" + : `https://github.com/${data.owner}/${data.repo}`; + + return ( + +
+ {data.mermaidDiagram ? ( + <> +
+ + + +
+ + + + Discuss this diagnosis in Copilot → + + + + ) : ( +
+ +

+ Diagnostic core offline. +

+
+ )} +
+
+ ); +} diff --git a/components/analyze/ExitModal.tsx b/components/analyze/ExitModal.tsx new file mode 100644 index 0000000..4224e76 --- /dev/null +++ b/components/analyze/ExitModal.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect } from "react"; +import type { RefObject } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { ThumbsUp, ThumbsDown } from "lucide-react"; +import { useRouter } from "next/navigation"; + +interface ExitModalProps { + isOpen: boolean; + modalRef: RefObject; + onFeedback: (isHelpful: boolean, exitAfter: boolean) => void; + onSkip: () => void; +} + +export default function ExitModal({ + isOpen, + modalRef, + onFeedback, + onSkip, +}: ExitModalProps) { + const router = useRouter(); + + // Focus trap: keeps keyboard focus inside the modal while it is open. + useEffect(() => { + if (!isOpen) return; + const modal = modalRef.current; + if (!modal) return; + + const focusable = modal.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + const trap = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + }; + + first?.focus(); + document.addEventListener("keydown", trap); + return () => document.removeEventListener("keydown", trap); + }, [isOpen, modalRef]); + + return ( + + {isOpen && ( +
+ {/* Backdrop */} + router.back()} + /> + + {/* Modal card */} + } + initial={{ opacity: 0, scale: 0.95, y: 10 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.95, y: 10 }} + className="relative z-10 glass-card p-6 rounded-2xl border border-white/10 max-w-sm w-full shadow-2xl bg-[#0e0e0e]" + > +

+ Leaving so soon? +

+

+ Quick check: Was this codebase analysis helpful to you? +

+ +
+
+ + +
+ +
+
+
+ )} +
+ ); +} diff --git a/components/analyze/GitHubAuthModal.tsx b/components/analyze/GitHubAuthModal.tsx new file mode 100644 index 0000000..3e9ce50 --- /dev/null +++ b/components/analyze/GitHubAuthModal.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ArrowLeft } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { FaGithub } from "react-icons/fa"; + +interface GitHubAuthModalProps { + onLogin: () => Promise; +} + +export default function GitHubAuthModal({ onLogin }: GitHubAuthModalProps) { + const router = useRouter(); + + return ( +
+
+ +
+ +
+ +

+ Private Repository +

+

+ To analyze private code, you need to authenticate with GitHub. +

+ +
+ + Connect GitHub Account + + +
+
+
+ ); +} diff --git a/components/analyze/RiskRadarPanel.tsx b/components/analyze/RiskRadarPanel.tsx new file mode 100644 index 0000000..cf98844 --- /dev/null +++ b/components/analyze/RiskRadarPanel.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { motion } from "framer-motion"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const RiskDashboard = dynamic(() => import("@/components/RiskDashboard"), { + loading: () => , + ssr: false, +}); + +interface RiskRadarPanelProps { + data: RepoData; +} + +export default function RiskRadarPanel({ data }: RiskRadarPanelProps) { + return ( + + {data.coverageGaps && data.fileContents ? ( + + + + ) : ( +
+ No risk data available for this codebase. +
+ )} +
+ ); +} diff --git a/components/analyze/SkeletonLoader.tsx b/components/analyze/SkeletonLoader.tsx new file mode 100644 index 0000000..a3619e8 --- /dev/null +++ b/components/analyze/SkeletonLoader.tsx @@ -0,0 +1,20 @@ +import { memo } from "react"; + +/** + * Generic pulsing skeleton shown while a lazy-loaded tab component is + * being fetched or while the page is in its initial loading state. + */ +const SkeletonLoader = memo(() => ( +
+
+
+
+
+
+
+
+)); + +SkeletonLoader.displayName = "SkeletonLoader"; + +export default SkeletonLoader; diff --git a/components/analyze/VisualizerPanel.tsx b/components/analyze/VisualizerPanel.tsx new file mode 100644 index 0000000..20a6092 --- /dev/null +++ b/components/analyze/VisualizerPanel.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Layers } from "lucide-react"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { MAP_VIEW_CONFIG, MapViewType } from "@/components/analyze/constants"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const ArchitectureMap = dynamic(() => import("@/components/ArchitectureMap"), { + loading: () => , + ssr: false, +}); +const TreemapVisualizer = dynamic( + () => import("@/components/TreemapVisualizer"), + { loading: () => , ssr: false }, +); +const DirectoryTreeVisualizer = dynamic( + () => import("@/components/DirectoryTreeVisualizer"), + { loading: () => , ssr: false }, +); + +interface VisualizerPanelProps { + data: RepoData; + mapView: MapViewType; + onMapViewChange: (view: MapViewType) => void; +} + +export default function VisualizerPanel({ + data, + mapView, + onMapViewChange, +}: VisualizerPanelProps) { + return ( + + {/* Sub-view switcher */} +
+
+

+ Blueprint Map +

+ + VISUAL LAYOUT + +
+
+ {MAP_VIEW_CONFIG.map((view) => ( + + ))} +
+
+ + {/* Map canvas */} +
+ + {mapView === "graph" ? ( + data.dependencyGraph && + Object.keys(data.dependencyGraph).length > 0 ? ( + + ) : ( +
+ +

+ No blueprint data parsed. +

+
+ ) + ) : mapView === "directory" ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/components/analyze/constants.tsx b/components/analyze/constants.tsx new file mode 100644 index 0000000..4a02318 --- /dev/null +++ b/components/analyze/constants.tsx @@ -0,0 +1,68 @@ +import { + FileCode, + Layers, + Activity, + GitPullRequest, + Target, +} from "lucide-react"; + +// ─── Framer Motion ──────────────────────────────────────────────────────────── + +export const EXPO_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]; + +// ─── Tab Configuration ──────────────────────────────────────────────────────── + +export const TAB_CONFIG = [ + { id: "overview" as const, icon: FileCode, label: "Read Docs" }, + { id: "visualizer" as const, icon: Layers, label: "Blueprint Map" }, + { id: "doctor" as const, icon: Activity, label: "Diagnostic Engine" }, + { id: "pr_impact" as const, icon: GitPullRequest, label: "PR Impact" }, + { id: "risk_radar" as const, icon: Target, label: "Risk Radar" }, +] as const; + +export type TabType = (typeof TAB_CONFIG)[number]["id"]; + +// ─── Map View Configuration ─────────────────────────────────────────────────── + +export const MAP_VIEW_CONFIG = [ + { id: "graph" as const, label: "Dependency Flow" }, + { id: "directory" as const, label: "Folder Structure" }, + { id: "treemap" as const, label: "Codebase Weight" }, +] as const; + +export type MapViewType = (typeof MAP_VIEW_CONFIG)[number]["id"]; + +// ─── Loading Screen Phrases ─────────────────────────────────────────────────── + +export const LOADING_PHRASES = [ + "Cloning repository...", + "Decrypting source tree...", + "Mapping AST nodes...", + "Tracing execution flows...", + "Calculating blast radius...", + "Evaluating test coverage...", + "Querying Groq LLM...", + "Compiling health report...", +] as const; + +// ─── Default Analysis Skeleton ──────────────────────────────────────────────── +// Shown in the UI while the AI stream is starting up. +// Intentionally does NOT satisfy the full Analysis interface — the streaming +// engine progressively replaces these placeholder values. + +export const DEFAULT_ANALYSIS = { + architecture_pattern: "Analyzing...", + what_it_does: "Waking up Groq LLM...", + execution_flow: [], + tech_stack: [], + key_modules: [], + onboarding_guide: [], + evidence_paths: [], + blast_radius: [], + health_status: { + grade: "-", + score: 0, + status: "Pending", + refactor_plan: [], + }, +} as const; diff --git a/hooks/analyze/useAiStream.ts b/hooks/analyze/useAiStream.ts new file mode 100644 index 0000000..395ced5 --- /dev/null +++ b/hooks/analyze/useAiStream.ts @@ -0,0 +1,128 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { RepoData } from "@/lib/types/analyze"; + +// The subset of swrData fields that the AI endpoint consumes. +// Keeps the hook signature explicit and avoids a full RepoData dependency. +interface AiStreamInput { + owner: string; + repo: string; + description: string; + entryPoints: string[]; + topFilesForGroq?: unknown; + fileContents?: { path: string; content: string }[]; + blastRadiusTargets?: unknown; + healthMetrics?: unknown; + // analysis is null when the server deferred AI generation to the client stream + analysis: RepoData["analysis"] | null; +} + +interface UseAiStreamReturn { + aiAnalysis: Record | null; + isAiStreaming: boolean; +} + +/** + * Owns the real-time AI streaming engine. + * + * When `swrData.analysis` comes back as `null`, the server has signalled that + * the AI generation should be streamed client-side. This hook opens a POST + * stream to `/api/ai`, reads NDJSON chunks, and applies on-the-fly partial + * JSON repair so the UI can render progressive updates before the stream + * completes. + * + * The hook is intentionally idempotent: once streaming has started (or + * completed), it will not re-trigger for the same `swrData` reference. + */ +export function useAiStream( + swrData: AiStreamInput | null | undefined, +): UseAiStreamReturn { + const [aiAnalysis, setAiAnalysis] = useState | null>( + null, + ); + const [isAiStreaming, setIsAiStreaming] = useState(false); + + useEffect(() => { + // Only start the stream when: + // 1. swrData has arrived + // 2. The server explicitly deferred analysis (analysis === null) + // 3. We are not already streaming + // 4. We have not already received a completed result + if (!swrData || swrData.analysis !== null || isAiStreaming || aiAnalysis) { + return; + } + + setIsAiStreaming(true); + + const fetchAiStream = async () => { + try { + const res = await fetch("/api/ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repoName: `${swrData.owner}/${swrData.repo}`, + description: swrData.description, + entryPoints: swrData.entryPoints, + topFiles: swrData.topFilesForGroq, + fileContents: swrData.fileContents, + blastRadiusTargets: swrData.blastRadiusTargets, + healthMetrics: swrData.healthMetrics, + }), + }); + + if (!res.body) return; + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let jsonText = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + jsonText += decoder.decode(value, { stream: true }); + + // 1. Attempt to parse the complete accumulated JSON first. + try { + setAiAnalysis(JSON.parse(jsonText)); + } catch { + // 2. On-the-fly partial JSON repair for progressive UI rendering. + // The incoming stream is valid JSON that may be mid-write, so we + // close any open string literals and balance braces/brackets. + try { + let fixed = jsonText.replace(/("[^"]*)$/, '"'); + + const openBraces = (fixed.match(/\{/g) || []).length; + const closeBraces = (fixed.match(/\}/g) || []).length; + const openBrackets = (fixed.match(/\[/g) || []).length; + const closeBrackets = (fixed.match(/\]/g) || []).length; + + fixed += + "]".repeat(Math.max(0, openBrackets - closeBrackets)) + + "}".repeat(Math.max(0, openBraces - closeBraces)); + + const partial = JSON.parse(fixed); + if (partial) setAiAnalysis(partial); + } catch { + // Silently wait for the next chunk if the fragment is still + // unparseable after repair attempts. + } + } + } + } catch (err) { + console.error("AI Stream Failed:", err); + } finally { + setIsAiStreaming(false); + } + }; + + fetchAiStream(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [swrData]); + // Intentionally omitting `isAiStreaming` and `aiAnalysis` from deps: + // the guard conditions at the top of the effect body are sufficient, and + // including them would risk re-triggering the stream on state updates. + + return { aiAnalysis, isAiStreaming }; +} \ No newline at end of file diff --git a/hooks/analyze/useAnalyzeData.ts b/hooks/analyze/useAnalyzeData.ts new file mode 100644 index 0000000..027458b --- /dev/null +++ b/hooks/analyze/useAnalyzeData.ts @@ -0,0 +1,137 @@ +"use client"; + +import { useState, useMemo } from "react"; +import useSWR from "swr"; +import { fetcher } from "@/lib/fetcher"; +import { RepoData, Analysis } from "@/lib/types/analyze"; +import { useAiStream } from "./useAiStream"; +import { DEFAULT_ANALYSIS } from "@/components/analyze/constants"; + +interface UseAnalyzeDataOptions { + repoUrl: string | null; + source: string | null; + onRequireGitHubAuth: () => void; +} + +interface UseAnalyzeDataReturn { + /** Fully resolved data, with AI analysis injected (streaming or cached). */ + data: RepoData | null; + loading: boolean; + error: string | null; + isRateLimit: boolean; + isAiStreaming: boolean; +} + +/** + * Owns all data-fetching concerns for the analyze page. + * + * Responsibilities: + * - SWR-cached remote fetch via `/api/analyze` + * - Local-upload fallback from `sessionStorage` + * - AI stream injection via `useAiStream` + * - Unified `data`, `loading`, and `error` surface for consumers + * + * The `onRequireGitHubAuth` callback is fired instead of surfacing an error + * string when the API returns `REQUIRE_GITHUB_AUTH`, so the caller can show + * the auth modal without coupling this hook to UI state. + */ +export function useAnalyzeData({ + repoUrl, + source, + onRequireGitHubAuth, +}: UseAnalyzeDataOptions): UseAnalyzeDataReturn { + const isLocal = source === "local"; + + // ─── Local Upload Path ────────────────────────────────────────────────────── + // Read once on mount. We use plain `useState` initializers (not effects) so + // the value is available synchronously on the first render. + + const [localData] = useState(() => { + if (typeof window === "undefined" || !isLocal) return null; + const stored = sessionStorage.getItem("localAnalysisResult"); + return stored ? (JSON.parse(stored) as RepoData) : null; + }); + + const [localError] = useState(() => { + if (typeof window === "undefined" || !isLocal) return null; + const stored = sessionStorage.getItem("localAnalysisResult"); + return !stored + ? "Local analysis data expired or lost. Please return to home and upload again." + : null; + }); + + // ─── Remote Fetch Path (SWR) ──────────────────────────────────────────────── + + const { + data: swrData, + error: swrError, + isLoading: swrLoading, + } = useSWR( + repoUrl && !isLocal ? ["/api/analyze", repoUrl] : null, + ([url, repo]) => fetcher(url, repo), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + onError: (err: Error) => { + if (err.message === "REQUIRE_GITHUB_AUTH") onRequireGitHubAuth(); + }, + }, + ); + + // ─── AI Stream ────────────────────────────────────────────────────────────── + // Pass swrData to the stream hook. It self-guards and will only open a + // stream when `swrData.analysis === null`. + + const { aiAnalysis, isAiStreaming } = useAiStream( + isLocal ? null : swrData, + ); + + // ─── Unified Data Surface ─────────────────────────────────────────────────── + + const data = useMemo(() => { + if (isLocal) return localData; + if (!swrData || swrError) return null; + + // The server may return `analysis: null` to signal client-side streaming. + // We inject the live aiAnalysis (or a safe placeholder skeleton) so the + // rest of the UI never needs to handle a null analysis field. + const resolvedAnalysis: Analysis = + aiAnalysis != null + ? (aiAnalysis as unknown as Analysis) + : swrData.analysis !== null + ? swrData.analysis + : (DEFAULT_ANALYSIS as unknown as Analysis); + + return { + ...swrData, + analysis: resolvedAnalysis, + } as RepoData; + }, [isLocal, localData, swrData, swrError, aiAnalysis]); + + // ─── Error Derivation ─────────────────────────────────────────────────────── + // Strip the REQUIRE_GITHUB_AUTH sentinel — that path is handled via the + // callback above and should not surface as a visible error string. + + const rawErrorMessage = + swrError?.message === "REQUIRE_GITHUB_AUTH" ? null : swrError?.message; + + const error: string | null = isLocal + ? localError + : (rawErrorMessage ?? null); + + const isRateLimit = + !!error && + (error.includes("RATE_LIMIT_REACHED") || error.includes("Daily limit")); + + // ─── Loading ───────────────────────────────────────────────────────────────── + + const loading = isLocal ? !localData && !localError : swrLoading; + + return { + data, + loading, + error, + isRateLimit, + isAiStreaming, + }; +} \ No newline at end of file diff --git a/hooks/analyze/useAnalyzeUIState.ts b/hooks/analyze/useAnalyzeUIState.ts new file mode 100644 index 0000000..df2b5e7 --- /dev/null +++ b/hooks/analyze/useAnalyzeUIState.ts @@ -0,0 +1,129 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + TAB_CONFIG, + MAP_VIEW_CONFIG, + TabType, + MapViewType, +} from "@/components/analyze/constants"; + +interface UseAnalyzeUIStateOptions { + /** + * Called when Escape is pressed and the GitHub auth modal is the active + * layer. The caller owns that modal's visibility state, so we delegate the + * navigation action rather than importing the router here. + */ + onEscapeFromAuthModal: () => void; + /** Whether the GitHub auth modal is currently visible. */ + showGitHubAuthModal: boolean; + /** Whether the mount flag has been set (prevents SSR sessionStorage reads). */ + isMounted: boolean; +} + +interface UseAnalyzeUIStateReturn { + activeTab: TabType; + setActiveTab: (tab: TabType) => void; + mapView: MapViewType; + setMapView: (view: MapViewType) => void; + isChatOpen: boolean; + setIsChatOpen: (open: boolean) => void; + showExitModal: boolean; + setShowExitModal: (open: boolean) => void; +} + +/** + * Owns all ephemeral UI state for the analyze page. + * + * Responsibilities: + * - `activeTab` and `mapView` with `sessionStorage` persistence + * - `isChatOpen` and `showExitModal` panel visibility + * - Global Escape key handler (dispatches to the correct layer) + */ +export function useAnalyzeUIState({ + onEscapeFromAuthModal, + showGitHubAuthModal, + isMounted, +}: UseAnalyzeUIStateOptions): UseAnalyzeUIStateReturn { + // ─── Tab & View State ──────────────────────────────────────────────────────── + // Safe synchronous initialization: sessionStorage is only read in the browser. + + const [activeTab, setActiveTabRaw] = useState(() => { + if (typeof window === "undefined") return "overview"; + const saved = sessionStorage.getItem("codeautopsy_tab"); + return saved && TAB_CONFIG.some((t) => t.id === saved) + ? (saved as TabType) + : "overview"; + }); + + const [mapView, setMapViewRaw] = useState(() => { + if (typeof window === "undefined") return "graph"; + const saved = sessionStorage.getItem("codeautopsy_view"); + return saved && MAP_VIEW_CONFIG.some((v) => v.id === saved) + ? (saved as MapViewType) + : "graph"; + }); + + // ─── Panel Visibility ──────────────────────────────────────────────────────── + + const [isChatOpen, setIsChatOpen] = useState(false); + const [showExitModal, setShowExitModal] = useState(false); + + // ─── sessionStorage Sync ───────────────────────────────────────────────────── + // Only write after the component has mounted to avoid SSR mismatches. + + useEffect(() => { + if (!isMounted) return; + sessionStorage.setItem("codeautopsy_tab", activeTab); + }, [activeTab, isMounted]); + + useEffect(() => { + if (!isMounted) return; + sessionStorage.setItem("codeautopsy_view", mapView); + }, [mapView, isMounted]); + + // ─── Escape Key Handler ─────────────────────────────────────────────────────── + // Layers (highest priority first): + // 1. Exit modal + // 2. GitHub auth modal → delegate navigation to caller + // 3. Chat panel + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (showExitModal) { + setShowExitModal(false); + } else if (showGitHubAuthModal) { + onEscapeFromAuthModal(); + } else if (isChatOpen) { + setIsChatOpen(false); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [showExitModal, showGitHubAuthModal, isChatOpen, onEscapeFromAuthModal]); + + // ─── Stable Setters ─────────────────────────────────────────────────────────── + // Wrapped in useCallback so consumers can use them in their own dependency + // arrays without risking stale-closure issues. + + const setActiveTab = useCallback((tab: TabType) => { + setActiveTabRaw(tab); + }, []); + + const setMapView = useCallback((view: MapViewType) => { + setMapViewRaw(view); + }, []); + + return { + activeTab, + setActiveTab, + mapView, + setMapView, + isChatOpen, + setIsChatOpen, + showExitModal, + setShowExitModal, + }; +} \ No newline at end of file diff --git a/hooks/analyze/useFeedback.ts b/hooks/analyze/useFeedback.ts new file mode 100644 index 0000000..23a961b --- /dev/null +++ b/hooks/analyze/useFeedback.ts @@ -0,0 +1,81 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { createClient } from "@/lib/supabase-browser"; + +interface UseFeedbackOptions { + /** + * The repo name or identifier used as the `debug_id` in Supabase. + * Falls back to `repoUrl`, then `"local-upload"` if neither is available. + */ + repoName: string | null | undefined; + repoUrl: string | null | undefined; + /** Called after feedback is submitted when `exitAfter` is true. */ + onExit: () => void; + /** Called to close the exit modal regardless of exit intent. */ + onCloseExitModal: () => void; +} + +interface UseFeedbackReturn { + feedbackSubmitted: boolean; + hideFeedback: boolean; + /** + * @param isHelpful - Whether the user found the analysis helpful. + * @param exitAfter - If true, closes the exit modal and calls `onExit` after + * the Supabase insert completes (fire-and-forget; errors are swallowed). + */ + handleFeedback: (isHelpful: boolean, exitAfter?: boolean) => Promise; +} + +/** + * Owns the feedback UI state and the Supabase persistence side-effect. + * + * The Supabase insert is fire-and-forget: errors are logged but never surfaced + * to the user, matching the original behaviour in page.tsx. + */ +export function useFeedback({ + repoName, + repoUrl, + onExit, + onCloseExitModal, +}: UseFeedbackOptions): UseFeedbackReturn { + const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); + const [hideFeedback, setHideFeedback] = useState(false); + + const handleFeedback = useCallback( + async (isHelpful: boolean, exitAfter = false) => { + setFeedbackSubmitted(true); + + if (exitAfter) { + onCloseExitModal(); + } else { + // Auto-hide the inline feedback widget after the success animation + // completes (2 seconds), matching the original behaviour. + setTimeout(() => setHideFeedback(true), 2000); + } + + try { + const supabase = createClient(); + await supabase.from("debug_feedback").insert([ + { + debug_id: repoName ?? repoUrl ?? "local-upload", + is_helpful: isHelpful, + }, + ]); + } catch (err) { + console.error("Feedback insert failed:", err); + } + + if (exitAfter) { + onExit(); + } + }, + [repoName, repoUrl, onExit, onCloseExitModal], + ); + + return { + feedbackSubmitted, + hideFeedback, + handleFeedback, + }; +} \ No newline at end of file diff --git a/hooks/analyze/useLoadingAnimation.ts b/hooks/analyze/useLoadingAnimation.ts new file mode 100644 index 0000000..ad8dcc4 --- /dev/null +++ b/hooks/analyze/useLoadingAnimation.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useAnimationControls } from "framer-motion"; +import { LOADING_PHRASES } from "@/components/analyze/constants"; + +export type LoadingControls = ReturnType; + +interface UseLoadingAnimationReturn { + loadingStep: number; + loadingControls: LoadingControls; +} + +/** + * Drives the loading screen phrase animation. + * + * Cycles through `LOADING_PHRASES` with a fade-out / increment / fade-in + * loop for as long as `loading` is `true`. The loop is cancelled cleanly + * via the `isActive` flag when the effect tears down. + * + * @param loading - Should be `true` while data is still being fetched. + */ +export function useLoadingAnimation( + loading: boolean, +): UseLoadingAnimationReturn { + const [loadingStep, setLoadingStep] = useState(0); + const loadingControls = useAnimationControls(); + + useEffect(() => { + if (!loading) return; + + let isActive = true; + + const animate = async () => { + while (isActive) { + await loadingControls.start({ + opacity: [0, 1], + transition: { duration: 0.4, ease: "easeOut" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 800)); + + await loadingControls.start({ + opacity: [1, 0], + transition: { duration: 0.4, ease: "easeIn" }, + }); + + if (isActive) { + setLoadingStep((prev) => (prev + 1) % LOADING_PHRASES.length); + } + } + }; + + animate(); + + return () => { + isActive = false; + }; + }, [loading, loadingControls]); + + return { loadingStep, loadingControls }; +} \ No newline at end of file