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: , entry: , classes: , flow: , functions: , diagram: , shield: , package: , compare: , complexity: , config: , pattern: , // Onboarding step icons github: , chat: , explore: , }; 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 (