import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import posthog from "posthog-js";
import Sidebar from "./components/Sidebar";
import Message from "./components/Message";
import DiagramView from "./components/DiagramView";
import ReadmeView from "./components/ReadmeView";
import LandingHero from "./components/LandingHero";
import LandingIngestion from "./components/LandingIngestion";
import CustomCursor from "./components/CustomCursor";
import { fetchRepos, streamQuery, streamAgentQuery, fetchMcpStatus, fetchMcpPrompt, fetchAgentModels, fetchSessions, fetchSession, saveSession, deleteSession } from "./api";

// ── Suggestion card icons ────────────────────────────────────────────────────
// Simple 16×16 line-art SVGs for each suggestion category.
// Kept inline so there's no icon-library dependency.
// Clean Octicons-inspired icons — 16×16 filled/stroked, consistent 1.5px stroke
const ICONS = {
  // Suggestion card icons
  architecture: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8.75 3.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM3.75 11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM13.75 11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM8 5.5a.5.5 0 0 0-.5.5v1.5H5.06A2.25 2.25 0 1 0 5 9.25h.06l.44.44V11a2.25 2.25 0 1 0 1.5.04V9.69l.44-.44H11a2.25 2.25 0 1 0-.06-1.5H8.5V6a.5.5 0 0 0-.5-.5Z"/></svg>,
  entry:        <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1h6.5c.966 0 1.75.784 1.75 1.75v2.5a.75.75 0 0 1-1.5 0v-2.5a.25.25 0 0 0-.25-.25h-6.5a.25.25 0 0 0-.25.25v11.5c0 .138.112.25.25.25h6.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 8.25 15h-6.5A1.75 1.75 0 0 1 0 13.25V2.75C0 1.784.784 1 1.75 1Zm9.42 7.75-3.22 3.22a.75.75 0 1 1-1.06-1.06l1.97-1.97H3.75a.75.75 0 0 1 0-1.5h5.11L6.89 5.47a.75.75 0 1 1 1.06-1.06l3.22 3.22a.75.75 0 0 1 0 1.06Z"/></svg>,
  classes:      <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M14.064 0h.186C15.216 0 16 .784 16 1.75v.186a8.752 8.752 0 0 1-2.564 6.186l-.458.459c-.314.314-.641.616-.979.904v3.207l-2.209 3.322A.75.75 0 0 1 9 15.75v-4.055c-.338-.288-.665-.59-.979-.904l-.458-.459A8.752 8.752 0 0 1 5 4.136V3.75A.75.75 0 0 1 5.75 3H9.5l-1.75 2h3.5l-1 2h2.25l1-4h.064ZM4.751 7.5H1a.75.75 0 0 0 0 1.5h2.37L4.75 7.5Zm.375 2.5L3.75 12.5H7l1.376-2.5H5.126Z"/></svg>,
  flow:         <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M11.28 12.78a.75.75 0 0 1-1.06-1.06l1.72-1.72H6.75a3.25 3.25 0 0 1-3.25-3.25v-3a.75.75 0 0 1 1.5 0v3a1.75 1.75 0 0 0 1.75 1.75h5.19l-1.72-1.72a.75.75 0 1 1 1.06-1.06l3 3a.75.75 0 0 1 0 1.06l-3 3Z"/></svg>,
  functions:    <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4.72.22a.75.75 0 0 1 1.06 0L9.53 4 8.47 5.06 5.25 1.84 3.28 3.81l1.5 1.5a.75.75 0 0 1-1.06 1.06L2.22 4.87a.75.75 0 0 1 0-1.06L4.72.22ZM11.28 11.78a.75.75 0 0 1-1.06 0L6.47 8 7.53 6.94l3.22 3.22 1.97-1.97-1.5-1.5a.75.75 0 1 1 1.06-1.06l1.5 1.5a.75.75 0 0 1 0 1.06l-2.5 2.59ZM1.5 8.75h.69l.5-2H2a.75.75 0 0 1 0-1.5h1.19l.41-1.66a.75.75 0 1 1 1.46.36l-.33 1.3H6l.41-1.66a.75.75 0 1 1 1.46.36L7.5 5.25h.75a.75.75 0 0 1 0 1.5H7.19l-.5 2H8a.75.75 0 0 1 0 1.5H6.31l-.41 1.66a.75.75 0 1 1-1.46-.36l.33-1.3H3.5l-.41 1.66a.75.75 0 0 1-1.46-.36L2 8.75H1.5a.75.75 0 0 1 0-1.5h-.5Zm2.5 0h1.5l.5-2h-1.5l-.5 2Z"/></svg>,
  diagram:      <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l1.22 1.22a.75.75 0 0 1-1.06 1.06L7.75 12.81l-1.47 1.47a.75.75 0 0 1-1.06-1.06L6.44 12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25H2.75Z"/></svg>,
  shield:       <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M7.467.133a1.748 1.748 0 0 1 1.066 0l5.25 1.68A1.75 1.75 0 0 1 15 3.48V7c0 1.566-.32 3.182-1.303 4.682-.983 1.498-2.585 2.813-5.032 3.855a1.697 1.697 0 0 1-1.33 0c-2.447-1.042-4.049-2.357-5.032-3.855C1.32 10.182 1 8.566 1 7V3.48a1.75 1.75 0 0 1 1.217-1.667Zm.61 1.429a.25.25 0 0 0-.153 0l-5.25 1.68a.25.25 0 0 0-.174.238V7c0 1.358.275 2.666 1.057 3.86.784 1.194 2.121 2.34 4.366 3.297a.196.196 0 0 0 .154 0c2.245-.956 3.582-2.104 4.366-3.298C13.225 9.666 13.5 8.36 13.5 7V3.48a.25.25 0 0 0-.174-.237l-5.25-1.68ZM11.28 6.28l-3.5 3.5a.75.75 0 0 1-1.06 0l-1.5-1.5a.75.75 0 0 1 1.06-1.06l.97.97 2.97-2.97a.75.75 0 0 1 1.06 1.06Z"/></svg>,
  package:      <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8.878.392a1.75 1.75 0 0 0-1.756 0l-5.25 3.045A1.75 1.75 0 0 0 1 4.951v6.098c0 .624.332 1.2.872 1.514l5.25 3.045a1.75 1.75 0 0 0 1.756 0l5.25-3.045c.54-.313.872-.89.872-1.514V4.951c0-.624-.332-1.2-.872-1.514Zm-.438 1.297a.25.25 0 0 1 .25 0l2.688 1.559-4.003 2.32-2.929-1.71Zm.31 4.171v5.058l-4.25-2.464V5.745Zm1.5 5.058V5.745l4.25-2.464v5.07Z"/></svg>,
  compare:      <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M9.78 12.78a.75.75 0 0 1-1.06-1.06l1.97-1.97H5.75a3.25 3.25 0 0 1-3.25-3.25v-3a.75.75 0 0 1 1.5 0v3a1.75 1.75 0 0 0 1.75 1.75h4.94l-1.97-1.97a.75.75 0 1 1 1.06-1.06l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25Z"/></svg>,
  complexity:   <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1.5 1.75V13.5h13.75a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75V1.75a.75.75 0 0 1 1.5 0Zm14.28 2.53-5.25 5.25a.75.75 0 0 1-1.06 0L7 7.06 4.28 9.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0L9.97 7.94l4.72-4.72a.75.75 0 1 1 1.06 1.06Z"/></svg>,
  config:       <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.103-.303c-.066-.019-.176-.011-.299.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.103.303c.066.019.176.011.299-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.531.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/></svg>,
  pattern:      <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 4.25 6h-1.5A1.75 1.75 0 0 1 1 4.25Zm1.75-.25a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25ZM1 11.75C1 10.784 1.784 10 2.75 10h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 4.25 15h-1.5A1.75 1.75 0 0 1 1 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25Zm5.5-9.5C8.25 1 9.034 1.784 9.034 2.75v1.5a1.75 1.75 0 0 1-1.75 1.75h-1.5A1.75 1.75 0 0 1 4.034 4.25v-1.5C4.034 1.784 4.818 1 5.784 1h1.5v.25ZM8.25 1h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 9.75 6h-1.5A1.75 1.75 0 0 1 6.5 4.25v-1.5C6.5 1.784 7.284 1 8.25 1Zm.25 1.5a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25Zm3.25-.75c0-.966.784-1.75 1.75-1.75h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 15 6h-1.5a1.75 1.75 0 0 1-1.75-1.75Zm1.75-.25a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25H15a.25.25 0 0 0 .25-.25v-1.5A.25.25 0 0 0 15 1.5Zm-3.25 9.5a1.75 1.75 0 0 0-1.75 1.75v1.5c0 .966.784 1.75 1.75 1.75h1.5A1.75 1.75 0 0 0 15 13.25v-1.5A1.75 1.75 0 0 0 13.5 10Zm-.25 1.75a.25.25 0 0 1 .25-.25h1.5a.25.25 0 0 1 .25.25v1.5a.25.25 0 0 1-.25.25h-1.5a.25.25 0 0 1-.25-.25Z"/></svg>,
  // Onboarding step icons
  github:  <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>,
  chat:    <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>,
  explore: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm3.94-1.5 2.656.886 1.316-1.316a.75.75 0 0 1 1.09 1.03l-.03.03-1.316 1.316.887 2.657a.75.75 0 0 1-.975.975l-4-2a.75.75 0 0 1-.31-.31l-2-4a.75.75 0 0 1 .975-.975l4 2Zm.16 2.11L4.5 9.5l1.89.63-.69-2.07Zm1.74.5.63 1.89 1.07-1.07-.63-1.89-.07.07-1 1Z"/></svg>,
};

export default function App() {
  // ── URL-driven state ─────────────────────────────────────────────────
  // activeRepo + view used to be local React state; they're now derived
  // from the URL so refreshing preserves position, links are shareable,
  // and browser back/forward works without bespoke history shims. The
  // setActiveRepo / setView functions wrap useNavigate so existing call
  // sites don't change — they just push to history instead of mutating
  // local state.
  const params         = useParams();
  const navigate       = useNavigate();
  const location       = useLocation();

  const activeRepo = useMemo(() => {
    return (params.owner && params.repo) ? `${params.owner}/${params.repo}` : null;
  }, [params.owner, params.repo]);

  // Session id from /r/:owner/:repo/c/:sessionId — drives which conversation
  // is loaded into the chat panel. Null when the user is on a fresh chat.
  const sessionIdFromUrl = params.sessionId || null;

  // View is determined by the trailing path segment:
  //   /r/owner/repo                → graph (default — diagram is the richer landing)
  //   /r/owner/repo/diagram        → graph
  //   /r/owner/repo/chat           → chat
  //   /r/owner/repo/c/:sessionId   → chat (a session is always a chat)
  // Without a repo, view is irrelevant; we report "chat" so the empty
  // landing state stays unchanged.
  const view = useMemo(() => {
    if (!activeRepo) return "chat";
    if (location.pathname.endsWith("/chat")) return "chat";
    if (location.pathname.includes("/c/")) return "chat";
    return "graph";
  }, [activeRepo, location.pathname]);

  const setActiveRepo = useCallback((slug) => {
    if (!slug) navigate("/");
    else        navigate(`/r/${slug}`);
  }, [navigate]);

  const setView = useCallback((nextView) => {
    if (!activeRepo) return;  // no repo selected — nothing to switch on
    if (nextView === "chat")  navigate(`/r/${activeRepo}/chat`);
    else                      navigate(`/r/${activeRepo}/diagram`);
  }, [activeRepo, navigate]);

  const [repos, setRepos]           = useState([]);
  const [reposLoading, setReposLoading] = useState(true);
  const [mode, setMode]             = useState("hybrid");
  const [agentMode, setAgentMode]   = useState(() => localStorage.getItem('ghrc_agentMode') === 'true');
  const [messages, setMessages]     = useState([]);
  const [sessions, setSessions]     = useState([]);      // recent sessions for active repo
  const [lastSources, setLastSources] = useState([]); // sources from last RAG query (kept for future use)
  const [focusFiles, setFocusFiles]   = useState(null); // filepaths from last "Diagram this →" click
  const [input, setInput]           = useState("");
  const [streaming, setStreaming]   = useState(false);
  const [backendOk, setBackendOk]   = useState(null); // null=unknown, true=ok, false=error
  const [currentSessionId, setCurrentSessionId] = useState(null); // highlights active session in sidebar
  const prevRepoRef  = useRef(null); // track previous repo before switching
  const messagesRef  = useRef([]);   // always-fresh messages ref to avoid stale closures
  const sessionIdRef = useRef(null); // ID of the current open session
  const [showReadme, setShowReadme]   = useState(false);
  const [sidebarOpen, setSidebarOpen] = useState(false);
  const [sidebarCollapsed, setSidebarCollapsed] = useState(
    () => localStorage.getItem('ghrc_sidebarCollapsed') === 'true'
  );
  // Diagram view's fullscreen state is lifted here so the layout can hide the
  // sidebar + chat-header chrome cleanly (rather than relying on z-index over
  // elements that can create stacking/containing blocks mid-animation).
  const [diagramFullscreen, setDiagramFullscreen] = useState(false);
  // Active in-landing ingestion journey. When non-null, the hero is replaced
  // by LandingIngestion which owns its own SSE stream and renders the live
  // map forming. On completion/error we clear this and route into the new
  // repo. Shape: { url: string, slug: string|null, accent: string }
  const [activeJourney, setActiveJourney] = useState(null);
  // Prompt autocomplete: shown when input starts with "/"
  const [prompts, setPrompts]         = useState([]);      // MCP prompt list
  const [promptMenu, setPromptMenu]   = useState(false);   // dropdown visible
  const [promptFilter, setPromptFilter] = useState("");    // text after "/"

  // Model selector: available models fetched from /agent/models
  const [agentModels, setAgentModels] = useState([]);
  const [selectedModelId, setSelectedModelId] = useState(
    () => localStorage.getItem('ghrc_selectedModel') || null
  );
  const [modelMenuOpen, setModelMenuOpen] = useState(false);
  const modelMenuRef = useRef(null);

  const bottomRef           = useRef(null);
  const scrollRef           = useRef(null);
  const latestAssistantRef  = useRef(null); // top of the current streaming assistant message
  const textareaRef    = useRef(null);
  const stopStream     = useRef(null);       // cleanup fn for active SSE
  const streamingRef   = useRef(false);      // always-fresh streaming flag for event handlers
  const countdownTimer = useRef(null);       // setInterval handle for rate-limit auto-retry
  const handleSubmitRef = useRef(null);      // stable ref so closures can call handleSubmit
  const msgIdCounter    = useRef(0);         // monotonic counter for message IDs — avoids Date.now() collisions
  const rateLimitRetries = useRef(0);        // consecutive rate-limit count — resets on success

  // ── Multi-session persistence (localStorage, up to 10 sessions per repo) ───
  // Modelled on rag-research-copilot: each session has an id, title (first
  // question truncated to 55 chars), messages array, and ISO timestamp.
  // Sessions are stored as `ghrc_sessions_{repo}` → JSON array, newest first.

  // Strip transient streaming fields before saving so reloaded messages are clean
  function cleanMsgs(msgs) {
    return msgs.map(({ streaming: _s, currentTool: _ct, phase: _p, ...m }) => m);
  }

  // Build the session record, mirror it into local state immediately, and
  // persist to the backend in the background. Optimistic updates keep the
  // sidebar responsive even on slow networks; a failed save logs but doesn't
  // surface a UI error since the local state is already correct.
  function upsertSession(repo, sessionId, msgs, isAgentMode = false) {
    if (!repo || !sessionId || msgs.length === 0) return null;
    const title = msgs.find(m => m.role === "user")?.content?.slice(0, 55) ?? "Untitled";
    const session = {
      id:        sessionId,
      repo,
      title,
      messages:  cleanMsgs(msgs),
      timestamp: new Date().toISOString(),
      agentMode: isAgentMode,
    };
    setSessions(prev => {
      const exists = prev.some(s => s.id === sessionId);
      if (exists) return prev.map(s => s.id === sessionId ? session : s);
      return [session, ...prev].slice(0, 50);
    });
    saveSession(session).catch(err => console.warn("session save failed:", err));
    return session;
  }

  // Keep refs in sync so event handlers always read the latest values
  useEffect(() => { messagesRef.current = messages; }, [messages]);
  useEffect(() => { streamingRef.current = streaming; }, [streaming]);
  // Persist agent mode preference across page loads
  useEffect(() => { localStorage.setItem('ghrc_agentMode', agentMode); }, [agentMode]);
  // Persist selected model
  useEffect(() => {
    if (selectedModelId) localStorage.setItem('ghrc_selectedModel', selectedModelId);
    else localStorage.removeItem('ghrc_selectedModel');
  }, [selectedModelId]);
  // Fetch available agent models once on mount
  useEffect(() => {
    fetchAgentModels().then(models => {
      setAgentModels(models);
      // If no model selected yet, default to the first available one
      setSelectedModelId(prev => {
        if (prev && models.some(m => m.id === prev)) return prev;
        const first = models.find(m => m.available);
        return first ? first.id : null;
      });
    });
  }, []);
  // Close model menu when clicking outside
  useEffect(() => {
    function onClickOutside(e) {
      if (modelMenuRef.current && !modelMenuRef.current.contains(e.target)) {
        setModelMenuOpen(false);
      }
    }
    document.addEventListener("mousedown", onClickOutside);
    return () => document.removeEventListener("mousedown", onClickOutside);
  }, []);
  // Keep handleSubmitRef pointing at the latest handleSubmit (avoids stale closures
  // in the rate-limit countdown which captures this ref via closure).
  // We update it on every render so it always has the current state in scope.
  useEffect(() => { handleSubmitRef.current = (q) => handleSubmit(null, q); });

  // Load sessions list whenever active repo changes. Sessions live in a
  // backend Qdrant collection; the first time a user with pre-existing
  // localStorage data hits the new app for a given repo we push those
  // records up so nothing is lost in the transition.
  useEffect(() => {
    // Save the current session for the old repo before switching
    if (prevRepoRef.current && prevRepoRef.current !== activeRepo && sessionIdRef.current) {
      upsertSession(prevRepoRef.current, sessionIdRef.current, messagesRef.current, agentMode);
    }
    prevRepoRef.current = activeRepo;

    // Reset chat state. sessionIdRef is set later by the sessionId-from-URL
    // effect below if the user landed on /r/owner/repo/c/:id.
    sessionIdRef.current = null;
    setCurrentSessionId(null);
    setMessages([]);
    setLastSources([]);
    setFocusFiles(null);

    if (!activeRepo) {
      setSessions([]);
      return;
    }

    let cancelled = false;
    (async () => {
      // One-time migration: drain any localStorage records for this repo
      // into the backend, then clear the local key. Idempotent — repeat
      // calls are no-ops because the localStorage key is gone.
      try {
        const localKey = `ghrc_sessions_${activeRepo}`;
        const localRaw = localStorage.getItem(localKey);
        if (localRaw) {
          const local = JSON.parse(localRaw) || [];
          if (Array.isArray(local) && local.length > 0) {
            await Promise.all(local.map(s => saveSession({
              ...s,
              id:   String(s.id),         // legacy IDs were numbers; backend expects strings
              repo: activeRepo,
            })));
          }
          localStorage.removeItem(localKey);
        }
      } catch (e) {
        console.warn("session migration failed:", e);
      }

      const remote = await fetchSessions(activeRepo);
      if (!cancelled) setSessions(remote);
    })();
    return () => { cancelled = true; };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeRepo]);

  // When the URL carries a session id, hydrate the chat panel from that
  // session. Skips the fetch if the id matches what's already loaded so
  // unrelated re-renders don't cause flicker.
  useEffect(() => {
    if (!activeRepo || !sessionIdFromUrl) return;
    if (sessionIdFromUrl === sessionIdRef.current) return;
    let cancelled = false;
    (async () => {
      const s = await fetchSession(sessionIdFromUrl);
      if (cancelled || !s) return;
      sessionIdRef.current = s.id;
      setCurrentSessionId(s.id);
      setMessages(s.messages || []);
      setLastSources([]);
      setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "instant" }), 50);
    })();
    return () => { cancelled = true; };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeRepo, sessionIdFromUrl]);

  // Auto-save current session after each complete streaming exchange
  const prevStreaming = useRef(false);
  useEffect(() => {
    if (prevStreaming.current && !streaming && activeRepo && sessionIdRef.current) {
      const next = upsertSession(activeRepo, sessionIdRef.current, messagesRef.current, agentMode);
      if (next) setSessions(next);
    }
    prevStreaming.current = streaming;
  }, [streaming, messages, activeRepo]);

  // ── Session actions ─────────────────────────────────────────────────────────

  function handleLoadSession(session) {
    if (streaming) return;
    // Save whatever is currently open before switching
    if (sessionIdRef.current && messagesRef.current.length > 0) {
      upsertSession(activeRepo, sessionIdRef.current, messagesRef.current, agentMode);
    }
    // Navigate to the session URL — the sessionIdFromUrl effect picks up
    // and hydrates the chat panel from the session record.
    if (activeRepo) navigate(`/r/${activeRepo}/c/${session.id}`);
    setShowReadme(false);
  }

  function handleDeleteSession(sessionId) {
    setSessions(prev => prev.filter(s => s.id !== sessionId));
    deleteSession(sessionId).catch(err => console.warn("session delete failed:", err));
    // If we deleted the open session, clear the chat and drop the /c/:id
    // segment from the URL so the user lands back on a fresh chat.
    if (sessionIdRef.current === sessionId) {
      sessionIdRef.current = null;
      setCurrentSessionId(null);
      setMessages([]);
      setFocusFiles(null);
      if (activeRepo) navigate(`/r/${activeRepo}/chat`);
    }
  }

  function handleRenameSession(sessionId, newTitle) {
    // Optimistic local update + persist via the same upsert path so a
    // rename is identical to any other session edit on the wire.
    setSessions(prev => {
      const target = prev.find(s => s.id === sessionId);
      if (target) {
        saveSession({ ...target, title: newTitle, repo: activeRepo })
          .catch(err => console.warn("session rename failed:", err));
      }
      return prev.map(s => s.id === sessionId ? { ...s, title: newTitle } : s);
    });
  }

  function toggleSidebarCollapse() {
    const next = !sidebarCollapsed;
    setSidebarCollapsed(next);
    localStorage.setItem('ghrc_sidebarCollapsed', String(next));
  }

  function handleDiagramThis(sources) {
    // Extract unique filepaths from the message's source cards, then switch to
    // the Diagram tab showing an architecture view with a focused-files banner.
    const files = [...new Set((sources || []).map(s => s.filepath))];
    setFocusFiles(files.length > 0 ? files : null);
    setView("graph");
  }

  function handleStop() {
    if (stopStream.current) { stopStream.current(); stopStream.current = null; }
    setStreaming(false);
    // Mark the in-progress message as done (no streaming cursor)
    setMessages(prev => prev.map(m =>
      m.streaming ? { ...m, streaming: false, phase: null, currentTool: null } : m
    ));
  }

  // ⌘K / Ctrl+K — focus the input from anywhere in the app.
  // Productivity Tool must_have: keyboard-shortcuts (ui-ux-pro-max-skill #16).
  // navigator.platform is deprecated — prefer userAgentData (Chrome 90+) with fallback.
  const isMac = (navigator.userAgentData?.platform ?? navigator.platform).toUpperCase().includes("MAC");
  useEffect(() => {
    function onGlobalKey(e) {
      if ((e.metaKey || e.ctrlKey) && e.key === "k") {
        e.preventDefault();
        if (view === "graph") setView("chat");
        // Small delay if we just switched views (textarea may not be mounted yet)
        setTimeout(() => textareaRef.current?.focus(), 20);
      }
      // Escape — stop streaming (mirrors Claude/ChatGPT behaviour)
      // Use streamingRef to avoid stale closure (this effect only reruns on view change)
      if (e.key === "Escape" && streamingRef.current) {
        if (stopStream.current) { stopStream.current(); stopStream.current = null; }
        setStreaming(false);
        setMessages(prev => prev.map(m =>
          m.streaming ? { ...m, streaming: false, phase: null, currentTool: null } : m
        ));
      }
    }
    window.addEventListener("keydown", onGlobalKey);
    return () => window.removeEventListener("keydown", onGlobalKey);
  }, [view]);

  // Auto-grow textarea as user types
  useEffect(() => {
    const el = textareaRef.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = `${el.scrollHeight}px`;
  }, [input]);

  // Load repos on mount — also tracks backend health for the header status dot.
  // Auto-selects the only repo if exactly one is indexed, so new users land
  // directly in that repo's view rather than a bare landing screen.
  const loadRepos = useCallback(async () => {
    setReposLoading(true);
    try {
      const data = await fetchRepos();
      const list = data.repos || [];
      setRepos(list);
      setBackendOk(true);
      // Auto-select if only one repo is indexed and nothing is selected yet
      if (list.length === 1 && !activeRepo) {
        setActiveRepo(list[0].slug);
      }
    } catch {
      setBackendOk(false);
    } finally {
      setReposLoading(false);
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => { loadRepos(); }, [loadRepos]);

  // Load MCP prompts once on mount for the "/" autocomplete
  useEffect(() => {
    fetchMcpStatus()
      .then(info => setPrompts(info.prompts || []))
      .catch(() => {});
  }, []);

  // Scroll to the TOP of the assistant message the moment it first appears.
  // We track the last scrolled-to ID so this only fires once per response.
  const scrolledToId = useRef(null);
  useEffect(() => {
    const streamingMsg = messages.find(m => m.role === "assistant" && m.streaming);
    if (streamingMsg && streamingMsg.id !== scrolledToId.current) {
      scrolledToId.current = streamingMsg.id;
      setTimeout(() => {
        latestAssistantRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
      }, 50);
    }
  }, [messages]);

  // While streaming, keep scrolling to bottom only if user is already near bottom.
  // After streaming ends, do a final smooth scroll to bottom.
  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
    if (streaming) {
      if (distFromBottom < 120) bottomRef.current?.scrollIntoView({ behavior: "instant" });
    } else {
      bottomRef.current?.scrollIntoView({ behavior: "smooth" });
    }
  }, [messages, streaming]);

  // Accept optional retryQuestion so the rate-limit countdown can re-submit
  // without reading stale `input` state from a closure.
  function handleSubmit(e, retryQuestion = null) {
    e?.preventDefault();
    const question = retryQuestion || input.trim();
    if (!question || streaming) return;
    if (!retryQuestion) setInput(""); // only clear the box on a fresh submit

    // Assign a session ID on the first message of a new conversation, then
    // reflect it in the URL so the chat is bookmarkable / shareable from
    // its very first message. UUIDs are URL-safe and globally unique so
    // two users starting fresh chats don't collide.
    if (!sessionIdRef.current) {
      const id = (typeof crypto !== "undefined" && crypto.randomUUID)
        ? crypto.randomUUID()
        : `s-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
      sessionIdRef.current = id;
      setCurrentSessionId(id);
      if (activeRepo) navigate(`/r/${activeRepo}/c/${id}`, { replace: true });
    }

    // Build conversation history from completed exchanges (not the current one).
    // Only include messages with content — skip failed/empty responses.
    // Cap at 10 items (5 back-and-forth exchanges) to stay within LLM token limits.
    const completedMsgs = messagesRef.current.filter(m => !m.streaming && m.content);
    const history = completedMsgs
      .slice(-10)
      .map(m => ({ role: m.role, content: m.content }));

    // Track query event
    posthog.capture("query_submitted", { repo: activeRepo, mode: agentMode ? "agent" : "rag" });

    // Add user message + placeholder assistant message.
    // On auto-retry (retryQuestion set), skip the user message — it's already in the chat
    // from the first attempt. Adding it again causes duplicate question bubbles.
    const userMsg = { role: "user", content: question };
    // Use a unique counter (not Date.now()) so auto-retry can never create a
    // new message with the same ID as the old one — preventing a stale onSources
    // callback from the old RAG stream polluting the new message's state.
    const assistantId = ++msgIdCounter.current;
    const assistantMsg = {
      id: assistantId, role: "assistant",
      // Store mode explicitly so Message.jsx never has to infer it from mutable state.
      // phase, queryType, etc. can all be overwritten by async callbacks; mode cannot.
      mode: agentMode ? "agent" : "rag",
      content: "", sources: [], queryType: null, streaming: true,
      phase: agentMode ? null : "searching",
      sourceCount: null,
      toolCalls: [], currentTool: null, iterations: null,
    };
    if (retryQuestion) {
      setMessages((prev) => [...prev, assistantMsg]);
    } else {
      setMessages((prev) => [...prev, userMsg, assistantMsg]);
    }
    setStreaming(true);

    // ── Common callbacks ──────────────────────────────────────────────────────
    const onToken = (token) =>
      setMessages((prev) =>
        prev.map((m) => m.id === assistantId ? { ...m, content: m.content + token } : m)
      );

    const onError = (err) => {
      // Make errors actionable: distinguish network vs backend vs rate limit vs unknown.
      const errStr = String(err);
      let friendly = `Error: ${err}`;
      let isRateLimit = false;

      if (errStr.includes("fetch") || errStr.includes("network") || errStr.includes("Failed to fetch")) {
        friendly = "Cannot reach the backend (localhost:8000). Is it running?\n\nTry: `uvicorn backend.main:app --reload`";
      } else if (errStr.includes("502") || errStr.includes("503")) {
        friendly = "Backend returned a server error (502/503). Try refreshing in a few seconds.";
      } else if (
        errStr.includes("429") ||
        errStr.includes("rate-limited") ||
        errStr.includes("rate limited") ||
        errStr.includes("daily limit")
      ) {
        isRateLimit = true;
        friendly = "⟳ Rate limited — retrying in 45s";
      } else if (errStr.includes("timeout") || errStr.includes("Timeout")) {
        friendly = "Request timed out. The query may be too complex — try a simpler question.";
      }

      setMessages((prev) =>
        prev.map((m) => m.id === assistantId
          ? { ...m, content: friendly, streaming: false, rateLimited: isRateLimit, retryQuestion: isRateLimit ? question : null }
          : m
        )
      );
      setStreaming(false);
      stopStream.current = null;

      // Rate-limit auto-retry: count down 45 s, then re-submit the same question.
      // Max 2 auto-retries — after that, show a permanent error so it doesn't loop forever.
      // The user can also click "Retry now" to skip the wait (also counted against the limit).
      if (isRateLimit) {
        rateLimitRetries.current += 1;
        const attempt = rateLimitRetries.current;

        if (attempt > 2) {
          // Give up — show a clear message instead of looping endlessly
          setMessages(prev => prev.map(m =>
            m.id === assistantId
              ? { ...m, content: "Rate limit hit too many times. Wait a minute and try again.", streaming: false, rateLimited: false }
              : m
          ));
          return;
        }

        let secsLeft = 45;
        if (countdownTimer.current) clearInterval(countdownTimer.current);
        countdownTimer.current = setInterval(() => {
          secsLeft -= 1;
          if (secsLeft <= 0) {
            clearInterval(countdownTimer.current);
            countdownTimer.current = null;
            // Stop the old stream before retrying — prevents stale onSources/onToken
            // callbacks from the previous attempt firing on the new message.
            stopStream.current?.();
            stopStream.current = null;
            setMessages(prev => prev.filter(m => m.id !== assistantId));
            handleSubmitRef.current?.(question);
          } else {
            setMessages(prev => prev.map(m =>
              m.id === assistantId
                ? { ...m, content: `⟳ Rate limited (attempt ${attempt}/2) — retrying in ${secsLeft}s` }
                : m
            ));
          }
        }, 1000);
      }
    };

    let stop;

    if (agentMode) {
      // ── Agent mode: ReAct loop with live tool-call trace ──────────────────
      stop = streamAgentQuery({
        question,
        repo: activeRepo,
        model_id: selectedModelId || undefined,
        history,
        onThought: (text) => {
          // Append a thought entry to the trace — rendered as a reasoning bubble
          // before the tool call that follows it.
          setMessages((prev) =>
            prev.map((m) => m.id === assistantId
              ? { ...m, toolCalls: [...m.toolCalls, { type: "thought", text }] }
              : m
            )
          );
        },
        onToolCall: (tool, input) => {
          // Show spinner with tool name while agent is calling
          setMessages((prev) =>
            prev.map((m) => m.id === assistantId
              ? { ...m, currentTool: tool }
              : m
            )
          );
          // Append to the tool call trace (output will be filled by onToolResult)
          setMessages((prev) =>
            prev.map((m) => m.id === assistantId
              ? { ...m, toolCalls: [...m.toolCalls, { tool, input, output: "" }] }
              : m
            )
          );
        },
        onToolResult: (tool, output) => {
          // Fill in the output of the last tool call in the trace
          setMessages((prev) =>
            prev.map((m) => {
              if (m.id !== assistantId) return m;
              const calls = [...m.toolCalls];
              // Find the first (oldest) unfilled slot for this tool — results arrive
              // in the same order as calls were emitted, so FIFO matching is correct.
              // Scanning backwards was wrong: parallel same-name calls got swapped.
              for (let i = 0; i < calls.length; i++) {
                if (calls[i].tool === tool && !calls[i].output) {
                  calls[i] = { ...calls[i], output };
                  break;
                }
              }
              return { ...m, toolCalls: calls, currentTool: "thinking" };
            })
          );
        },
        onToken,
        onSources: (sources) => {
          // Agent mode: store collected file references for the source cards panel.
          // These arrive just before the "done" event, after all tool calls complete.
          setMessages((prev) =>
            prev.map((m) => m.id === assistantId
              ? { ...m, sources: sources || [] }
              : m
            )
          );
        },
        onDone: (iterations, model) => {
          rateLimitRetries.current = 0; // reset on success
          setMessages((prev) =>
            prev.map((m) => m.id === assistantId
              ? { ...m, streaming: false, currentTool: null, iterations, model }
              : m
            )
          );
          setStreaming(false);
          stopStream.current = null;
        },
        onError,
      });
    } else {
      // ── Plain RAG mode: single retrieval → stream tokens ──────────────────
      stop = streamQuery({
        question,
        repo: activeRepo,
        mode,
        history,
        onToken,
        onSources: (sources, queryType, pipeline, model) => {
          // Transition from "searching" → "generating" so the phase indicator updates.
          // pipeline = {hyde, expanded, reranker} — shows which quality features fired.
          setMessages((prev) =>
            prev.map((m) => m.id === assistantId
              ? { ...m, sources, queryType, pipeline, model, phase: "generating", sourceCount: sources.length }
              : m)
          );
          setLastSources(sources || []);
        },
        onGrade: (grade) => {
          setMessages((prev) =>
            prev.map((m) => m.id === assistantId ? { ...m, grade } : m)
          );
        },
        onDone: () => {
          rateLimitRetries.current = 0; // reset on success
          setMessages((prev) =>
            prev.map((m) => m.id === assistantId ? { ...m, streaming: false, phase: null } : m)
          );
          setStreaming(false);
          stopStream.current = null;
        },
        onError,
      });
    }

    stopStream.current = stop;
  }

  function handleInputChange(e) {
    const val = e.target.value;
    setInput(val);
    // Show prompt menu when input is just "/" or "/partial"
    if (val.startsWith("/") && !val.includes(" ")) {
      setPromptFilter(val.slice(1).toLowerCase());
      setPromptMenu(true);
    } else {
      setPromptMenu(false);
    }
  }

  async function handleSelectPrompt(prompt) {
    setPromptMenu(false);
    // Build arguments: pass activeRepo if we have one
    const args = activeRepo ? { repo: activeRepo } : {};
    try {
      const result = await fetchMcpPrompt(prompt.name, args);
      setInput(result.text);
      setTimeout(() => textareaRef.current?.focus(), 0);
    } catch {
      // Fallback: just fill with the prompt name as a question
      setInput(`/${prompt.name}`);
    }
  }

  function handleKeyDown(e) {
    if (promptMenu && e.key === "Escape") {
      setPromptMenu(false);
      return;
    }
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSubmit();
    }
  }

  function handleClear() {
    if (stopStream.current) { stopStream.current(); stopStream.current = null; }
    if (countdownTimer.current) { clearInterval(countdownTimer.current); countdownTimer.current = null; }
    // Save the current session before starting a new one
    if (sessionIdRef.current && messagesRef.current.length > 0) {
      upsertSession(activeRepo, sessionIdRef.current, messagesRef.current, agentMode);
    }
    sessionIdRef.current = null;
    setCurrentSessionId(null);
    setMessages([]);
    setFocusFiles(null);
    setStreaming(false);
    // Drop the /c/:sessionId segment from the URL so the next message
    // gets its own fresh id (and shareable link).
    if (activeRepo && sessionIdFromUrl) navigate(`/r/${activeRepo}/chat`);
  }

  // "/" triggers MCP prompt autocomplete — surface this in the placeholder so
  // users discover it without reading docs.
  const placeholder = activeRepo
    ? `Ask about ${activeRepo}… (type / for AI prompts)`
    : "Ask about any indexed repo…";

  // Landing mode = a fresh user with nowhere else to be. We dedicate the
  // whole viewport to the hero in this state — sidebar collapses to an icon
  // strip, the chat input hides, and the landing layout takes over.
  const isLanding =
    !showReadme &&
    view === "chat" &&
    messages.length === 0 &&
    !activeRepo;

  // Sidebar visibility follows the user's persisted preference in every state,
  // landing included. Earlier we force-collapsed on landing to hand the whole
  // viewport to the hero; it turned out the sidebar reads as context
  // (indexed repos, sessions) rather than clutter, so we keep it visible.
  const effectiveCollapsed = sidebarCollapsed;

  // Landing → journey: tile click and URL input both start the same live
  // ingestion experience that replaces the hero in-place. If the repo is
  // already indexed, we skip the journey and select it directly.
  //
  // We build a full https:// URL here because the /ingest/stream endpoint
  // expects one; accept any of the shorthand forms the hero input allows.
  function toIngestUrl(input) {
    const raw = (input || "").trim();
    if (!raw) return null;
    if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
    if (raw.startsWith("github.com/")) return `https://${raw}`;
    if (raw.includes("/")) return `https://github.com/${raw}`;
    return null;
  }
  function startJourney({ slug, url, accent }) {
    // Don't stack journeys — if one's already running, ignore duplicate clicks.
    if (activeJourney) return;
    const ingestUrl = url || (slug ? `https://github.com/${slug}` : null);
    if (!ingestUrl) return;
    setActiveJourney({ url: ingestUrl, slug: slug || null, accent: accent || "#5B8FF9" });
  }
  function handleLandingPick(slug, accent) {
    posthog.capture("landing_tile_clicked", { slug });
    // If this repo is already indexed, skip the journey — the user has
    // already seen the map form. Straight into the product via the iris
    // reveal (cinematic iris from viewport centre).
    const indexed = repos.find(r => r.slug === slug);
    if (indexed) {
      triggerReveal();
      setActiveRepo(slug);
      setShowReadme(false);
      return;
    }
    startJourney({ slug, accent });
  }
  function handleLandingUrl(raw) {
    posthog.capture("landing_url_submitted", { input: raw });
    const url = toIngestUrl(raw);
    if (!url) return;
    triggerReveal();
    startJourney({ url });
  }
  function triggerReveal() {
    // Origin is always viewport-centre. We tried click-point origins first,
    // but tiles sit in the bottom third of the viewport, so the iris read
    // as "something happening near my cursor" instead of "the page is
    // revealing itself." Centre-origin plays like a cinematic iris — the
    // same gesture regardless of which tile was picked.
    const el = document.documentElement;
    el.style.setProperty("--reveal-x", "50vw");
    el.style.setProperty("--reveal-y", "50vh");
    el.classList.add("is-revealing");
    window.setTimeout(() => el.classList.remove("is-revealing"), 1250);
  }
  // Journey completion: refresh the repo list so the sidebar picks up the
  // newly indexed repo, then route the user straight into the Diagram view —
  // that's the "understand this repo" destination. Chat is for questions;
  // Diagram is the tour.
  async function handleJourneyComplete(slug) {
    if (!activeJourney) return;
    const effectiveSlug = slug || activeJourney.slug;
    setActiveJourney(null);
    // Reload repos so the sidebar list updates (the new repo will appear).
    await loadRepos();
    if (effectiveSlug) {
      setActiveRepo(effectiveSlug);
      setShowReadme(false);
      // Diagram view is the natural first stop for a brand-new repo — it
      // shows the concept tour / structural overview. The user can still
      // jump to chat any time. Journey → tour is the narrative promise.
      setView("graph");
      posthog.capture("landing_journey_completed", { repo: effectiveSlug });
    }
  }
  function handleJourneyAbort() {
    setActiveJourney(null);
  }
  function handleJourneyError(msg) {
    posthog.capture("landing_journey_error", { message: msg });
    // Keep the overlay visible so the user can read the error and retry
    // via the "Back" button; the component shows its own error copy.
  }

  return (
    <div className={`layout${effectiveCollapsed ? " layout-collapsed" : ""}${isLanding ? " layout-landing" : ""}${diagramFullscreen ? " layout-fullscreen" : ""}`}>
      <CustomCursor />

      {/* Sidebar overlay for mobile — closes sidebar when clicking outside */}
      {sidebarOpen && (
        <div className="sidebar-overlay" onClick={() => setSidebarOpen(false)} aria-hidden="true" />
      )}

      <Sidebar
        repos={repos}
        reposLoading={reposLoading}
        activeRepo={activeRepo}
        onSelectRepo={(repo) => { setActiveRepo(repo); setShowReadme(false); posthog.capture("repo_selected", { repo }); }}
        onReposChange={loadRepos}
        mode={mode}
        onModeChange={setMode}
        agentMode={agentMode}
        onAgentModeChange={setAgentMode}
        sessions={sessions}
        currentSessionId={currentSessionId}
        onLoadSession={handleLoadSession}
        onDeleteSession={handleDeleteSession}
        onRenameSession={handleRenameSession}
        isOpen={sidebarOpen}
        onClose={() => setSidebarOpen(false)}
        collapsed={effectiveCollapsed}
        onToggleCollapse={toggleSidebarCollapse}
        onGenerateReadme={(repo) => { setActiveRepo(repo); setShowReadme(true); posthog.capture("readme_opened", { repo }); }}
        isLanding={isLanding}
      />

      {/* .main is the universal canvas for every view. We apply the same
          ambient primitives (cursor-glow + constellation parallax) that make
          the landing feel premium, so every surface — chat empty state,
          diagram, explore, story, readme — shares one ambient language.
          Individual views can still add tighter card-level glows on top. */}
      <div
        className="main has-cursor-glow constellation-bg"
        onMouseMove={(e) => {
          // Shared --mx/--my channel: one handler on .main feeds both the
          // cursor-glow pseudo and the constellation parallax. Custom
          // properties inherit, so any descendant that reads var(--mx)/
          // var(--my) will see the same values without its own handler.
          const r = e.currentTarget.getBoundingClientRect();
          const mx = ((e.clientX - r.left) / r.width) * 100;
          const my = ((e.clientY - r.top)  / r.height) * 100;
          e.currentTarget.style.setProperty("--mx", `${mx}%`);
          e.currentTarget.style.setProperty("--my", `${my}%`);
        }}
      >
        {/* Header */}
        {/* 3-column grid: left (repo badge) | center (toggle) | right (actions)
            Equal 1fr flanks guarantee the center column is always truly centred,
            regardless of asymmetric content on either side — the Linear/Vercel pattern. */}
        <div className="chat-header">
          {/* LEFT — hamburger (mobile) + repo context */}
          <div className="header-left">
            <button
              className="mobile-menu-btn"
              onClick={() => setSidebarOpen(true)}
              aria-label="Open navigation"
            >
              <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
                <path d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 010 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 010 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H1.75z"/>
              </svg>
            </button>
            {backendOk !== null && (
              <span
                className="backend-dot"
                title={backendOk ? "Backend connected" : "Backend unreachable"}
                style={{ background: backendOk ? "var(--green)" : "var(--red)" }}
              />
            )}
            {activeRepo && (() => {
              const [owner, name] = activeRepo.split("/");
              return (
                <span className="repo-badge">
                  <span style={{ opacity: 0.55, fontWeight: 400 }}>{owner}/</span>
                  <span style={{ fontWeight: 600 }}>{name}</span>
                </span>
              );
            })()}
            {/* Agent-mode indicator — persistent badge when agent mode is active.
                Pulses when a query is streaming (the "thinking" state). Makes
                mode immediately legible from any view, not just the sidebar pill. */}
            {agentMode && activeRepo && (
              <span
                className="agent-badge"
                data-thinking={streaming || undefined}
                title={streaming ? "Agent is investigating your question" : "Agent mode is active"}
              >
                <span className="agent-badge-mark" aria-hidden="true">✦</span>
                <span className="agent-badge-label">
                  {streaming ? "Agent thinking…" : "Agent"}
                </span>
              </span>
            )}
          </div>

          {/* CENTER — view toggle, only when a specific repo is selected */}
          <div className="header-center">
            {activeRepo && (
              <div className="view-toggle">
                <button
                  className={`view-btn ${view === "chat" && !showReadme ? "active" : ""}`}
                  onClick={() => { setView("chat"); setShowReadme(false); }}
                >Chat</button>
                <button
                  className={`view-btn ${view === "graph" && !showReadme ? "active" : ""}`}
                  onClick={() => { setView("graph"); setShowReadme(false); posthog.capture("diagram_view_opened", { repo: activeRepo }); }}
                >Diagram <span style={{ fontSize: 8, verticalAlign: "middle", color: "var(--accent-soft)", marginLeft: 2 }}>●</span></button>
              </div>
            )}
          </div>

          {/* RIGHT — contextual actions */}
          <div className="header-actions">
            {view === "chat" && messages.length > 0 && (
              <button className="clear-btn" onClick={handleClear}>New Chat</button>
            )}
          </div>
        </div>

        {/* ── README view ── */}
        {showReadme && activeRepo && (
          <ReadmeView
            repo={activeRepo}
            contextualAt={repos.find(r => r.slug === activeRepo)?.contextual_at ?? null}
            onClose={() => setShowReadme(false)}
          />
        )}

        {/* ── Diagram view ── */}
        {/* Keyed to activeRepo so switching repos (or from chat→diagram) replays
            .view-switch-in. Matches the tab transition inside DiagramView and
            the mode transition inside ExploreView. */}
        {!showReadme && view === "graph" && activeRepo && (
          <div key={`diag-${activeRepo}`} className="view-switch-in app-view-host">
            <DiagramView
              repo={activeRepo}
              focusFiles={focusFiles}
              onAskAbout={(question) => {
                setView("chat");
                setFocusFiles(null);
                setInput(question);
                setTimeout(() => textareaRef.current?.focus(), 50);
              }}
              onFullscreenChange={setDiagramFullscreen}
            />
          </div>
        )}

        {/* ── Chat view ── */}
        {!showReadme && view === "chat" && (
          <>
            {messages.length === 0 ? (
              isLanding ? (
                // Full-bleed landing — LandingHero owns its own layout, or
                // LandingIngestion takes over when a journey is in flight.
                // We key on activeJourney so the entrance animation replays
                // on mount — the feeling of "the map starting to form."
                <div className="landing-root view-switch-in">
                  {activeJourney ? (
                    <LandingIngestion
                      key={`journey-${activeJourney.url}`}
                      repoUrl={activeJourney.url}
                      repoSlug={activeJourney.slug}
                      accent={activeJourney.accent}
                      onComplete={handleJourneyComplete}
                      onError={handleJourneyError}
                      onAbort={handleJourneyAbort}
                    />
                  ) : (
                    <LandingHero
                      onPickRepo={handleLandingPick}
                      onPasteUrl={handleLandingUrl}
                    />
                  )}
                </div>
              ) : (
              <div
                className="empty-state has-cursor-glow"
                // Shared --mx/--my channel: one mousemove feeds both the
                // glow pseudo and the constellation parallax. Percentages
                // are used so the transforms are resolution-independent.
                onMouseMove={(e) => {
                  const r = e.currentTarget.getBoundingClientRect();
                  const mx = ((e.clientX - r.left) / r.width) * 100;
                  const my = ((e.clientY - r.top) / r.height) * 100;
                  e.currentTarget.style.setProperty("--mx", `${mx}%`);
                  e.currentTarget.style.setProperty("--my", `${my}%`);
                }}
                style={{ "--glow-size": "640px", "--glow-intensity": "6%" }}
              >
                {/* Repo selected — show mode-aware suggestions + feature discovery cards */}
                <div className="suggest-state">
                    <h2>How does {activeRepo.split("/")[1]} work?</h2>


                    {agentMode ? (
                      <>
                        <div className="mode-hint" style={{ marginBottom: 12 }}>
                          <strong>Agent mode</strong> — search → observe → reason → search again. Watch the ReAct loop work in real time.
                        </div>
                        <div className="suggestions">
                          {[
                            { icon: "architecture", title: "Map the architecture",    body: `Walk ${activeRepo.split("/")[1]} from entry point to output`,  q: `How is ${activeRepo.split("/")[1]} structured? Trace the main execution path from the entry point all the way to the output.` },
                            { icon: "functions",    title: "Key functions",           body: "Most important functions and how they connect",                  q: `What are the most important functions in ${activeRepo.split("/")[1]} and how do they call each other?` },
                            { icon: "diagram",      title: "Generate a diagram",      body: "Visual map of the main components",                             q: `Generate a diagram of the main components in ${activeRepo.split("/")[1]} and how they relate to each other.` },
                            { icon: "shield",       title: "Error handling",          body: "How edge cases are managed across the codebase",                q: `How does ${activeRepo.split("/")[1]} handle errors and edge cases?` },
                            { icon: "flow",         title: "Data flow",               body: "How data moves from input to final result",                     q: `How does data flow through ${activeRepo.split("/")[1]} from input to final result?` },
                          ].map(({ icon, title, body, q }, i) => {
                            return (
                              <button key={title} className="suggestion-btn"
                                style={{ animationDelay: `${150 + i * 120}ms` }}
                                onClick={() => { setInput(q); textareaRef.current?.focus(); }}>
                                <span className="suggestion-icon">{ICONS[icon]}</span>
                                <span className="suggestion-content">
                                  <span className="suggestion-title">{title}</span>
                                  <span className="suggestion-body">{body}</span>
                                </span>
                                <svg className="suggestion-arrow" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
                              </button>
                            );
                          })}
                        </div>
                        <button className="graph-hint-btn" onClick={() => setView("graph")}>
                          Explore Diagrams for {activeRepo.split("/")[1]} →
                        </button>
                      </>
                    ) : (
                      <>
                        <div className="suggestions">
                          {[
                            { icon: "architecture", title: "Overall architecture",  body: `How is ${activeRepo.split("/")[1]} structured?`,    q: `How is ${activeRepo.split("/")[1]} structured overall? What are the main components and how do they fit together?` },
                            { icon: "entry",        title: "Entry points",          body: "Main entry points and how the code flows",           q: `What are the main entry points of ${activeRepo.split("/")[1]} and how does execution flow through them?` },
                            { icon: "classes",      title: "Key classes",           body: "What each major class does",                         q: `What are the key classes in ${activeRepo.split("/")[1]} and what is each one responsible for?` },
                            { icon: "flow",         title: "Data processing",       body: "How data is transformed through the system",         q: `How is data transformed and processed as it flows through ${activeRepo.split("/")[1]}?` },
                            { icon: "package",      title: "Dependencies",          body: "External libraries and how they're used",            q: `What external libraries does ${activeRepo.split("/")[1]} depend on and how does it use them?` },
                          ].map(({ icon, title, body, q }, i) => {
                            return (
                              <button key={title} className="suggestion-btn"
                                style={{ animationDelay: `${150 + i * 120}ms` }}
                                onClick={() => { setInput(q); textareaRef.current?.focus(); }}>
                                <span className="suggestion-icon">{ICONS[icon]}</span>
                                <span className="suggestion-content">
                                  <span className="suggestion-title">{title}</span>
                                  <span className="suggestion-body">{body}</span>
                                </span>
                                <svg className="suggestion-arrow" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
                              </button>
                            );
                          })}
                        </div>
                        {/* Secondary action row — below suggestions so it doesn't compete */}
                        <div className="suggest-footer">
                          <button className="suggest-footer-btn" onClick={() => setAgentMode(true)}>
                            ✦ Agent mode
                          </button>
                          <span className="suggest-footer-sep">·</span>
                          <button className="suggest-footer-btn" onClick={() => setView("graph")}>
                            ◫ Diagrams
                          </button>
                        </div>
                      </>
                    )}
                  </div>
              </div>
              )
            ) : (
              <div
                className="messages"
                ref={scrollRef}
                role="log"
                aria-live="polite"
                aria-label="Chat messages"
              >
                {messages.map((msg, i) => (
                  <Message
                    key={msg.id ?? i}
                    msg={msg}
                    showRepo={!activeRepo}
                    onDiagramThis={activeRepo ? handleDiagramThis : null}
                    ref={msg.role === "assistant" && msg.streaming ? latestAssistantRef : null}
                    onRetry={msg.rateLimited && msg.retryQuestion ? (q) => {
                      // User clicked "Retry now" — cancel countdown and re-submit immediately
                      if (countdownTimer.current) { clearInterval(countdownTimer.current); countdownTimer.current = null; }
                      setMessages(prev => prev.filter(m => m.id !== msg.id));
                      handleSubmit(null, q);
                    } : null}
                  />
                ))}
                <div ref={bottomRef} />
              </div>
            )}

            {/* Input — hidden on landing (there's no repo to chat about yet) */}
            {!isLanding && (
            <div className="input-bar">
              {/* Prompt autocomplete dropdown — shown when input starts with "/" */}
              {promptMenu && prompts.length > 0 && (() => {
                const filtered = prompts.filter(p =>
                  p.name.toLowerCase().includes(promptFilter)
                );
                return filtered.length > 0 ? (
                  <div className="prompt-menu">
                    <div className="prompt-menu-label">MCP Prompts</div>
                    {filtered.map(p => (
                      <button
                        key={p.name}
                        className="prompt-menu-item"
                        onMouseDown={(e) => { e.preventDefault(); handleSelectPrompt(p); }}
                      >
                        <span className="prompt-menu-name">/{p.name}</span>
                        <span className="prompt-menu-desc">{p.description?.slice(0, 60)}…</span>
                      </button>
                    ))}
                  </div>
                ) : null;
              })()}
              <div className="input-row">
                <textarea
                  ref={textareaRef}
                  rows={1}
                  placeholder={agentMode ? "Ask a complex question…" : placeholder}
                  value={input}
                  onChange={handleInputChange}
                  onKeyDown={handleKeyDown}
                  onBlur={() => setTimeout(() => setPromptMenu(false), 150)}
                  disabled={streaming}
                />
                <button
                  className={`btn${streaming ? " btn-stop" : ""}`}
                  onClick={streaming ? handleStop : handleSubmit}
                  disabled={!streaming && !input.trim()}
                  aria-label={streaming ? "Stop generating" : agentMode ? "Run Agent" : "Send"}
                  title={streaming ? "Stop (Esc)" : undefined}
                >
                  {streaming
                    ? <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><rect x="1.5" y="1.5" width="9" height="9" rx="2"/></svg>
                    : <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8h10M8 4l4 4-4 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"/></svg>
                  }
                </button>
                {/* ⌘K hint — inside input-row so it positions relative to the textarea, not the whole bar */}
                {!streaming && !input && (
                  <div className="input-hint" aria-hidden="true">{isMac ? "⌘K" : "Ctrl+K"}</div>
                )}
              </div>
              {/* Agent mode footer: badge + model selector */}
              {agentMode && (
                <div className="input-footer-row">
                  <div className="input-mode-badge" title="Agent mode — runs the ReAct loop (Reason + Act): searches the codebase, reads the result, decides if it needs more context, then searches again. The same pattern production agents use.">✦ Agent</div>
                  {agentModels.length > 0 && (() => {
                    const active = agentModels.find(m => m.id === selectedModelId) || agentModels.find(m => m.available) || agentModels[0];
                    return (
                      <div className="model-selector" ref={modelMenuRef}>
                        <button
                          className="model-selector-btn"
                          onClick={() => setModelMenuOpen(o => !o)}
                          title={active?.note}
                        >
                          <span className="model-selector-name">{active?.name ?? "Auto"}</span>
                          {active && <span className={`model-speed-badge model-speed-${active.speed}`}>{active.speed_label}</span>}
                          {/* chevron */}
                          <svg className={`model-chevron${modelMenuOpen ? " open" : ""}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
                            <path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
                          </svg>
                        </button>
                        {modelMenuOpen && (
                          <div className="model-menu">
                            {agentModels.map(m => (
                              <button
                                key={m.id}
                                className={`model-menu-item${m.id === selectedModelId ? " active" : ""}${!m.available ? " unavailable" : ""}`}
                                onClick={() => { setSelectedModelId(m.id); setModelMenuOpen(false); }}
                                disabled={!m.available}
                                title={!m.available ? `Requires ${m.provider} API key` : undefined}
                              >
                                <div className="model-menu-row">
                                  <span className="model-menu-name">{m.name}</span>
                                  <span className={`model-speed-badge model-speed-${m.speed}`}>{m.speed_label}</span>
                                  {m.id === selectedModelId && (
                                    <svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{marginLeft:"auto",flexShrink:0}}>
                                      <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
                                    </svg>
                                  )}
                                </div>
                                <div className="model-menu-note">{m.note}</div>
                                {!m.available && <div className="model-menu-unavail">API key not configured</div>}
                              </button>
                            ))}
                          </div>
                        )}
                      </div>
                    );
                  })()}
                </div>
              )}
            </div>
            )}
          </>
        )}
      </div>

    </div>
  );
}
