import { useState, useEffect, useRef } from "react"; import { BASE, deleteRepo, fetchMcpStatus, fetchMcpPrompt } from "../api"; function ContextualTip() { const [open, setOpen] = useState(false); return (
{open && (

Hit on any repo to re-index with contextual retrieval — the AI prepends a description to each key chunk before embedding. Searches, diagrams, and the semantic map all improve.

)}
); } function SessionItem({ sess, onLoad, onDelete, onRename, isActive }) { const [confirming, setConfirming] = useState(false); const [editing, setEditing] = useState(false); const [editVal, setEditVal] = useState(sess.title); const inputRef = useRef(null); // Focus the input when entering edit mode useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]); function startEdit(e) { e.stopPropagation(); setEditVal(sess.title); setEditing(true); } function commitEdit() { const trimmed = editVal.trim(); if (trimmed && trimmed !== sess.title) onRename(sess.id, trimmed); setEditing(false); } function handleEditKey(e) { if (e.key === "Enter") { e.preventDefault(); commitEdit(); } if (e.key === "Escape") { setEditing(false); } } return (
{editing ? ( setEditVal(e.target.value)} onBlur={commitEdit} onKeyDown={handleEditKey} onClick={e => e.stopPropagation()} maxLength={80} aria-label="Edit session title" /> ) : ( )} {confirming ? ( ) : ( )}
); } function timeAgo(iso) { const diff = Date.now() - new Date(iso).getTime(); const m = Math.floor(diff / 60000); if (m < 1) return "just now"; if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; } // Staleness thresholds: warn if index is older than 3 days, stale if > 7 days. function stalenessLevel(isoTimestamp) { if (!isoTimestamp) return null; const days = (Date.now() - new Date(isoTimestamp).getTime()) / (1000 * 60 * 60 * 24); if (days < 3) return null; // fresh — no indicator if (days < 7) return "warn"; // getting old return "stale"; // definitely stale } export default function Sidebar({ repos, reposLoading, activeRepo, onSelectRepo, onReposChange, mode, onModeChange, agentMode, onAgentModeChange, sessions, currentSessionId, onLoadSession, onDeleteSession, onRenameSession, isOpen, onClose, collapsed, onToggleCollapse, onGenerateReadme, isLanding = false }) { const [url, setUrl] = useState(""); const [status, setStatus] = useState(null); // {type, text} const [loading, setLoading] = useState(false); const [mcpInfo, setMcpInfo] = useState(null); // MCP server status const [mcpOpen, setMcpOpen] = useState(false); // expand/collapse panel const [mcpExpandedKey, setMcpExpandedKey] = useState(null); // "tool:name" | "res:uri" | "prompt:name" const [mcpPromptPreview, setMcpPromptPreview] = useState({}); // name → text (fetched lazily) const [confirming, setConfirming] = useState(null); // slug being confirmed for delete const [ingestProgress, setIngestProgress] = useState([]); // [{step, detail, done}] const [isIngesting, setIsIngesting] = useState(false); const [reindexing, setReindexing] = useState(null); // slug currently re-indexing const [reindexDone, setReindexDone] = useState({}); // slug → bool (just finished) const [reindexPct, setReindexPct] = useState({}); // slug → 0-100 progress % const [sessionSearch, setSessionSearch] = useState(""); // filter text for sessions list // Load MCP status once on mount useEffect(() => { fetchMcpStatus().then(setMcpInfo).catch(() => setMcpInfo({ connected: false })); }, []); // Landing hero → Sidebar bridge. The hero lives in the main pane and // has no reference to this component's state, so it asks us to ingest // by dispatching a window-level event. We pre-fill the URL, expand the // sidebar (so the user can watch the progress steps), and submit. useEffect(() => { function onExternalIngest(e) { const repo = e.detail?.repo; if (!repo) return; setUrl(repo); // defer to next tick so the controlled input has flushed setTimeout(() => { document.querySelector('.ingest-form')?.requestSubmit(); }, 0); } window.addEventListener("cartographer:ingest", onExternalIngest); return () => window.removeEventListener("cartographer:ingest", onExternalIngest); }, []); function handleIngest(e) { e.preventDefault(); if (!url.trim() || isIngesting) return; setIsIngesting(true); setIngestProgress([]); setStatus(null); // Connect to the SSE stream — the server pushes step events as it progresses // through fetching → filtering → chunking → embedding → storing → done. // EventSource handles reconnection automatically on network blips, so we // explicitly close it once we receive "done" or "error" to prevent that. const streamUrl = `${BASE}/ingest/stream?repo=${encodeURIComponent(url.trim())}`; const es = new EventSource(streamUrl); es.onmessage = (e) => { const event = JSON.parse(e.data); setIngestProgress(prev => { // Mark all previous steps as completed, then append the new active step. const updated = prev.map(s => ({ ...s, done: true })); return [...updated, { step: event.step, detail: event.detail, done: false }]; }); if (event.step === "done" || event.step === "error") { // The final step was appended as active (done: false). Mark it done now — // no subsequent event will arrive to flip it, so we do it explicitly. setIngestProgress(prev => prev.map(s => ({ ...s, done: true }))); es.close(); setIsIngesting(false); if (event.step === "done") { // Extract owner/repo slug from the URL the user typed. // Handles both "github.com/owner/repo" and "https://github.com/owner/repo". const match = url.match(/github\.com\/([^/]+\/[^/]+)/); if (match && onSelectRepo) onSelectRepo(match[1]); setUrl(""); onReposChange(); // Collapse the progress list after 3s so the card returns to normal size setTimeout(() => setIngestProgress([]), 3000); } } }; es.onerror = () => { es.close(); setIsIngesting(false); setIngestProgress(prev => [ ...prev, { step: "error", detail: "Connection failed — is the backend running?", done: false }, ]); }; } async function handleDelete(e, slug) { e.stopPropagation(); try { await deleteRepo(slug); if (activeRepo === slug) onSelectRepo(null); onReposChange(); } catch (err) { setStatus({ type: "error", text: err.message }); } } function handleReindex(e, slug) { e.stopPropagation(); if (reindexing) return; setReindexing(slug); setReindexDone(prev => ({ ...prev, [slug]: false })); setReindexPct(prev => ({ ...prev, [slug]: 5 })); // Map ingestion steps to approximate % complete so the bar fills meaningfully. // "contextualizing" is dynamic — we compute it from the "X / Y" in the detail. const STEP_PCT = { fetching: 10, filtering: 22, chunking: 38, embedding: 80, storing: 92, done: 100 }; // Use EventSource (GET SSE) instead of a POST fetch so the connection never // times out — large repos take several minutes to re-embed. The backend sends // keepalive pings every 15s to prevent proxy idle-disconnect. // // IMPORTANT: EventSource auto-reconnects when the server closes the stream. // We must call es.close() as soon as we receive any terminal event (done/error) // to prevent it from replaying the force=true re-index a second time. const es = new EventSource(`${BASE}/ingest/stream?repo=${encodeURIComponent(`https://github.com/${slug}`)}&force=true`); let completed = false; // true once "done" event received let closed = false; // guard against double-close / double-onerror const closeEs = () => { if (!closed) { closed = true; es.close(); } }; es.onmessage = (ev) => { const event = JSON.parse(ev.data); let pct = STEP_PCT[event.step] ?? null; // Contextualizing fires many times with "X / Y" in the detail. // Map it to 38–78% range so the bar visibly advances during this long phase. if (event.step === "contextualizing" && event.detail) { const m = event.detail.match(/(\d+)\s*\/\s*(\d+)/); if (m) { const [done, total] = [parseInt(m[1]), parseInt(m[2])]; pct = Math.round(38 + (done / total) * 40); } else { pct = 40; // initial "contextualizing" event before first batch } } if (pct !== null) setReindexPct(prev => ({ ...prev, [slug]: pct })); if (event.step === "done") { completed = true; closeEs(); setReindexing(null); setReindexDone(prev => ({ ...prev, [slug]: true })); onReposChange(); setTimeout(() => { setReindexDone(prev => { const n = {...prev}; delete n[slug]; return n; }); setReindexPct(prev => { const n = {...prev}; delete n[slug]; return n; }); }, 8000); // matches reindex-done-fade animation duration } else if (event.step === "error") { closeEs(); setReindexing(null); setReindexPct(prev => { const n = {...prev}; delete n[slug]; return n; }); setStatus({ type: "error", text: `Re-index failed: ${event.detail}` }); } }; es.onerror = () => { if (closed) return; // already handled — prevent double-fire closeEs(); setReindexing(null); setReindexPct(prev => { const n = {...prev}; delete n[slug]; return n; }); onReposChange(); // Defer the error display by one event-loop tick. // When the server sends "done" and immediately closes the stream, the browser // can queue onerror (connection-close) BEFORE delivering the final onmessage. // The setTimeout(0) lets any pending onmessage callbacks flush first, so // `completed` is already true by the time we check it here. setTimeout(() => { if (!completed) { setStatus({ type: "error", text: "Re-index may have completed — connection dropped at the end. Check the chunk count." }); } }, 0); }; } const SEARCH_MODE_TITLES = { hybrid: "Combines text matching + semantic similarity (recommended)", semantic: "Finds conceptually similar code", keyword: "Exact identifier matching", }; // ── Collapsed rail ──────────────────────────────────────────────────────── // When collapsed, show a slim 52px icon strip with key counts + expand button. // Same pattern as rag-research-copilot: two separate JSX trees, no CSS trickery. if (collapsed) { return (
{/* Brand icon */} {/* Repo count */} {repos.length > 0 && (
{repos.length}
)} {/* Session count */} {sessions && sessions.length > 0 && (
{sessions.length}
)} {/* Expand button — pinned to bottom */}
); } return (
{/* ── Scrollable top section ── */}
{/* ── Brand ── */}
{/* Icon container — Raycast-style rounded square with gradient + compass inside */}
{/* Subtle glow behind compass — same as favicon */} {/* N — dominant, full accent blue */} {/* S/E/W — dim */} {/* Center pivot — white for contrast */}
Cartographer
{/* ── Ingest ── hidden on landing (the hero owns this primary action) */} {!isLanding && (
Add Repository
setUrl(e.target.value)} disabled={isIngesting} />
{/* Curated repos — quick-start for new users */}
Try these
{[ { slug: "karpathy/nanoGPT", label: "GPT from scratch" }, { slug: "karpathy/micrograd", label: "autograd engine" }, { slug: "langchain-ai/langchain", label: "LLM framework" }, ].map(({ slug, label }) => ( ))}
{status && (
{status.text}
)} {ingestProgress.length > 0 && (
{ingestProgress.map((p, i) => (
{p.step === "error" ? ( /* X circle */ ) : p.done ? ( /* Check circle */ ) : ( /* Spinner dots — three dots for "in progress" */ )} {p.detail}
))}
)}
{/* end ingest-card */}
)} {/* ── Query mode (RAG vs Agent) ── hidden on landing (no chat yet) */} {!isLanding && (
Query Mode

{agentMode ? "Searches → reads → searches again. Slower but thorough." : "Retrieves code once, streams an answer. Fast."}

)} {/* ── Search mode (only visible in RAG mode, and not on landing) ── */} {!isLanding && !agentMode && (
Search Mode
{["hybrid", "semantic", "keyword"].map((m) => ( ))}

{mode === "hybrid" && "Text + semantic combined. Best for most questions."} {mode === "semantic" && "Finds conceptually similar code, even without exact terms."} {mode === "keyword" && "Exact identifier matching. Best for function or class names."}

)} {/* ── Repos ── */}
Indexed Repos ({reposLoading ? "…" : repos.length})
{reposLoading ? ( // Skeleton while the first fetch is in flight — backend can take a moment on cold start
{[1, 2].map(i => (
))}
) : repos.length === 0 ? (

No repos indexed yet. Add one above.

) : (
{repos.map((r) => { const staleness = stalenessLevel(r.indexed_at); const isReindexingThis = reindexing === r.slug; const justDone = reindexDone[r.slug]; const pct = reindexPct[r.slug] ?? null; return (
onSelectRepo(activeRepo === r.slug ? null : r.slug)} style={{ position: "relative", overflow: "hidden" }} >
{/* GitHub mark — reinforces these are GitHub repos without taking space */} {/* Split slug: owner dimmed + repo name prominent — scanability trick from Linear */} {r.slug.split("/")[0]}/{r.slug.split("/")[1]}
{/* Staleness indicator — shown when index is > 3 days old */} {staleness && !justDone && ( {staleness === "warn" ? "~old" : "stale"} )} {justDone && ( updated )} {r.contextual_at && ( )} {r.chunks}
{/* README generator — subtle hover-only action */} {onGenerateReadme && ( )} {/* Re-index button — one click re-ingests from scratch */} {confirming === r.slug ? ( ) : ( )}
{/* Progress bar — shown while re-indexing, then holds at 100% and glows for 8s after completion before fading out */} {(pct !== null || justDone) && (
)}
); })}
)} {!isLanding && repos.length > 0 && }
{/* ── Recent chats ── */} {sessions && sessions.length > 0 && (
Recent chats
{/* Session search — visible when there are enough sessions to warrant filtering */} {sessions.length >= 3 && ( setSessionSearch(e.target.value)} aria-label="Search sessions" /> )}
{sessions .filter(sess => !sessionSearch || sess.title.toLowerCase().includes(sessionSearch.toLowerCase())) .map(sess => ( )) } {sessionSearch && sessions.filter(s => s.title.toLowerCase().includes(sessionSearch.toLowerCase())).length === 0 && (
No chats match "{sessionSearch}"
)}
)}
{/* end sidebar-scroll */} {/* ── MCP Server Status — pinned at bottom, does not scroll with sidebar ── */}
{mcpOpen && mcpInfo && (
{!mcpInfo.connected ? (

Not connected — is the backend running?

) : ( <> {/* Primer — one line explaining what this panel exposes. Turns a debug list into a piece of the product story. */}

Live from the backend — every capability the agent uses to reason over your code.

{mcpInfo.tools.length > 0 && (
Tools {mcpInfo.tools.length}
{mcpInfo.tools.map(t => { const key = `tool:${t.name}`; const expanded = mcpExpandedKey === key; return (
{expanded && t.description && (

{t.description}

)}
); })}
)} {mcpInfo.resources.length > 0 && (
Resources {mcpInfo.resources.length}
{mcpInfo.resources.map(r => { const key = `res:${r.uri}`; const expanded = mcpExpandedKey === key; return (
{expanded && (

{r.description || "Read-only resource exposed over MCP."}

)}
); })}
)} {mcpInfo.prompts.length > 0 && (
Prompts {mcpInfo.prompts.length}
{mcpInfo.prompts.map(p => { const key = `prompt:${p.name}`; const expanded = mcpExpandedKey === key; const preview = mcpPromptPreview[p.name]; const args = p.arguments || []; const hasRequiredArgs = args.some(a => a.required); return (
{expanded && (
{p.description &&

{p.description}

} {args.length > 0 && (
Arguments
{args.map(a => (
{a.name} {a.required && required} {a.description && {a.description}}
))}
)} {preview && (
{preview}
)} {!hasRequiredArgs && !preview && ( )} {hasRequiredArgs && !preview && (

Invoke from chat: type /{p.name} in the message box.

)}
)}
); })}
)} )}
)}
); }