cartographer / ui /src /App.jsx
umanggarg's picture
Tier 2: shareable conversation URLs backed by Qdrant
8625087
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>
);
}