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"}
-
-
-
- {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 && (
-
- )}
-
-
+ 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"}
+
+
+
+
+
+ {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 (
+
+ );
+}
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