Spaces:
Running
Running
| import { useState, useEffect, useRef } from "react"; | |
| import { BASE, deleteRepo, fetchMcpStatus, fetchMcpPrompt } from "../api"; | |
| function ContextualTip() { | |
| const [open, setOpen] = useState(false); | |
| return ( | |
| <div className="ctip"> | |
| <button className="ctip-trigger" onClick={() => setOpen(o => !o)}> | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.5, flexShrink: 0 }}> | |
| <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm-.5 4.5h1v1.5h-1zm0 3h1v4h-1z"/> | |
| </svg> | |
| <span>Improve search quality</span> | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: "auto", opacity: 0.4, transition: "transform 0.2s", transform: open ? "rotate(180deg)" : "none" }}> | |
| <path d="m4 6 4 4 4-4"/> | |
| </svg> | |
| </button> | |
| {open && ( | |
| <p className="ctip-body"> | |
| Hit <span className="quality-tip-key">⟳</span> on any repo to re-index with <strong>contextual retrieval</strong> — the AI prepends a description to each key chunk before embedding. Searches, diagrams, and the semantic map all improve. | |
| </p> | |
| )} | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className={`session-item${isActive ? " active" : ""}`}> | |
| {editing ? ( | |
| <input | |
| ref={inputRef} | |
| className="session-title-input" | |
| value={editVal} | |
| onChange={e => setEditVal(e.target.value)} | |
| onBlur={commitEdit} | |
| onKeyDown={handleEditKey} | |
| onClick={e => e.stopPropagation()} | |
| maxLength={80} | |
| aria-label="Edit session title" | |
| /> | |
| ) : ( | |
| <button className="session-btn" onClick={() => onLoad(sess)} onDoubleClick={startEdit} title={`${sess.title}\n(double-click to rename)`}> | |
| <span className="session-title">{sess.title}</span> | |
| <span style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}> | |
| {sess.agentMode && <span className="session-mode-badge" title="Agent mode session">✦</span>} | |
| <span className="session-time">{timeAgo(sess.timestamp)}</span> | |
| </span> | |
| </button> | |
| )} | |
| {confirming ? ( | |
| <span className="session-confirm"> | |
| <button className="session-confirm-yes" onClick={() => { onDelete(sess.id); setConfirming(false); }}>Remove</button> | |
| <button className="session-confirm-no" onClick={() => setConfirming(false)}>Cancel</button> | |
| </span> | |
| ) : ( | |
| <button className="session-delete" onClick={() => setConfirming(true)} title="Delete session" aria-label="Delete session">×</button> | |
| )} | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="sidebar sidebar-collapsed"> | |
| {/* Brand icon */} | |
| <svg width="28" height="28" viewBox="0 0 24 24" fill="none" style={{ margin: '12px 0 4px' }}> | |
| <path d="M12 2 L14.5 7 L12 12 L9.5 7 Z" fill="var(--accent)"/> | |
| <path d="M12 22 L13.5 17 L12 12 L10.5 17 Z" fill="var(--accent)" opacity="0.28"/> | |
| <path d="M22 12 L17 10.5 L12 12 L17 13.5 Z" fill="var(--accent)" opacity="0.28"/> | |
| <path d="M2 12 L7 10.5 L12 12 L7 13.5 Z" fill="var(--accent)" opacity="0.28"/> | |
| <circle cx="12" cy="12" r="1.4" fill="var(--accent)"/> | |
| </svg> | |
| {/* Repo count */} | |
| {repos.length > 0 && ( | |
| <div className="sidebar-collapsed-item" title={`${repos.length} repo${repos.length !== 1 ? 's' : ''} indexed`}> | |
| <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.5 }}> | |
| <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Z"/> | |
| </svg> | |
| <span className="sidebar-collapsed-badge">{repos.length}</span> | |
| </div> | |
| )} | |
| {/* Session count */} | |
| {sessions && sessions.length > 0 && ( | |
| <div className="sidebar-collapsed-item" title={`${sessions.length} saved chat${sessions.length !== 1 ? 's' : ''}`}> | |
| <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.5 }}> | |
| <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.25Z"/> | |
| </svg> | |
| <span className="sidebar-collapsed-badge">{sessions.length}</span> | |
| </div> | |
| )} | |
| {/* Expand button — pinned to bottom */} | |
| <button | |
| className="sidebar-collapsed-expand" | |
| onClick={onToggleCollapse} | |
| title="Expand sidebar" | |
| aria-label="Expand sidebar" | |
| > | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="m6 4 4 4-4 4"/> | |
| </svg> | |
| </button> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className={`sidebar ${isOpen ? "open" : ""}`}> | |
| {/* ── Scrollable top section ── */} | |
| <div className="sidebar-scroll"> | |
| {/* ── Brand ── */} | |
| <div className="sidebar-brand"> | |
| {/* Icon container — Raycast-style rounded square with gradient + compass inside */} | |
| <div className="sidebar-brand-icon"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none"> | |
| {/* Subtle glow behind compass — same as favicon */} | |
| <circle cx="12" cy="12" r="10" fill="rgba(91,143,249,0.10)"/> | |
| {/* N — dominant, full accent blue */} | |
| <path d="M12 2 L14.5 7 L12 12 L9.5 7 Z" fill="#5B8FF9"/> | |
| {/* S/E/W — dim */} | |
| <path d="M12 22 L13.5 17 L12 12 L10.5 17 Z" fill="#5B8FF9" opacity="0.28"/> | |
| <path d="M22 12 L17 10.5 L12 12 L17 13.5 Z" fill="#5B8FF9" opacity="0.28"/> | |
| <path d="M2 12 L7 10.5 L12 12 L7 13.5 Z" fill="#5B8FF9" opacity="0.28"/> | |
| {/* Center pivot — white for contrast */} | |
| <circle cx="12" cy="12" r="1.6" fill="white"/> | |
| </svg> | |
| </div> | |
| <div style={{ flex: 1 }}> | |
| <div className="sidebar-brand-name">Cartographer</div> | |
| </div> | |
| <button | |
| className="sidebar-collapse-btn" | |
| onClick={onToggleCollapse} | |
| title="Collapse sidebar" | |
| aria-label="Collapse sidebar" | |
| > | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="m10 4-4 4 4 4"/> | |
| </svg> | |
| </button> | |
| </div> | |
| {/* ── Ingest ── hidden on landing (the hero owns this primary action) */} | |
| {!isLanding && ( | |
| <div className="sidebar-section"> | |
| <div className="section-label">Add Repository</div> | |
| <div className="ingest-card"> | |
| <form className="ingest-form" onSubmit={handleIngest}> | |
| <input | |
| type="text" | |
| placeholder="github.com/owner/repo" | |
| value={url} | |
| onChange={(e) => setUrl(e.target.value)} | |
| disabled={isIngesting} | |
| /> | |
| <button className="btn" type="submit" disabled={isIngesting || !url.trim()} title="Index repository"> | |
| {isIngesting | |
| ? <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg> | |
| : <svg 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> | |
| </form> | |
| {/* Curated repos — quick-start for new users */} | |
| <div style={{ marginTop: 10 }}> | |
| <div style={{ marginBottom: 5, color: "var(--faint)", fontSize: 10, textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 }}>Try these</div> | |
| <div className="try-repo-chips"> | |
| {[ | |
| { slug: "karpathy/nanoGPT", label: "GPT from scratch" }, | |
| { slug: "karpathy/micrograd", label: "autograd engine" }, | |
| { slug: "langchain-ai/langchain", label: "LLM framework" }, | |
| ].map(({ slug, label }) => ( | |
| <button | |
| key={slug} | |
| className="try-repo-chip" | |
| onClick={() => setUrl(`github.com/${slug}`)} | |
| title={label} | |
| > | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.6, flexShrink: 0 }}> | |
| <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/> | |
| </svg> | |
| {slug.split("/")[1]} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {status && ( | |
| <div className={`status-bar ${status.type}`} style={{ marginTop: 8 }}> | |
| {status.text} | |
| </div> | |
| )} | |
| {ingestProgress.length > 0 && ( | |
| <div className="ingest-progress"> | |
| {ingestProgress.map((p, i) => ( | |
| <div | |
| key={i} | |
| className={`ingest-step ${p.done ? "done" : "active"} ${p.step === "error" ? "error" : ""}`} | |
| > | |
| <span className="ingest-step-icon"> | |
| {p.step === "error" ? ( | |
| /* X circle */ | |
| <svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| <path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.03 10.97L10.03 12 8 9.97 5.97 12l-1-1.03L7 8.97 5 6.97l1-1 2 2 2-2 1 1-2 2z"/> | |
| </svg> | |
| ) : p.done ? ( | |
| /* Check circle */ | |
| <svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| <path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 6.22-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06l1.47 1.47 3.97-3.97a.75.75 0 1 1 1.06 1.06z"/> | |
| </svg> | |
| ) : ( | |
| /* Spinner dots — three dots for "in progress" */ | |
| <svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| <circle cx="2" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="14" cy="8" r="1.5"/> | |
| </svg> | |
| )} | |
| </span> | |
| <span className="ingest-step-detail">{p.detail}</span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div>{/* end ingest-card */} | |
| </div> | |
| )} | |
| {/* ── Query mode (RAG vs Agent) ── hidden on landing (no chat yet) */} | |
| {!isLanding && ( | |
| <div className="sidebar-section"> | |
| <div className="section-label">Query Mode</div> | |
| <div className="mode-pills"> | |
| <button | |
| className={`pill ${!agentMode ? "active" : ""}`} | |
| onClick={() => onAgentModeChange(false)} | |
| aria-pressed={!agentMode} | |
| >RAG</button> | |
| <button | |
| className={`pill pill--agent ${agentMode ? "active" : ""}`} | |
| onClick={() => onAgentModeChange(true)} | |
| aria-pressed={agentMode} | |
| > | |
| <span className="pill-mark" aria-hidden="true">✦</span> | |
| Agent | |
| </button> | |
| </div> | |
| <p className="mode-description"> | |
| {agentMode | |
| ? "Searches → reads → searches again. Slower but thorough." | |
| : "Retrieves code once, streams an answer. Fast."} | |
| </p> | |
| </div> | |
| )} | |
| {/* ── Search mode (only visible in RAG mode, and not on landing) ── */} | |
| {!isLanding && !agentMode && ( | |
| <div className="sidebar-section"> | |
| <div className="section-label">Search Mode</div> | |
| <div className="mode-pills"> | |
| {["hybrid", "semantic", "keyword"].map((m) => ( | |
| <button | |
| key={m} | |
| className={`pill ${mode === m ? "active" : ""}`} | |
| onClick={() => onModeChange(m)} | |
| aria-pressed={mode === m} | |
| >{m}</button> | |
| ))} | |
| </div> | |
| <p className="mode-description"> | |
| {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."} | |
| </p> | |
| </div> | |
| )} | |
| {/* ── Repos ── */} | |
| <div className="sidebar-section"> | |
| <div className="section-label">Indexed Repos ({reposLoading ? "…" : repos.length})</div> | |
| {reposLoading ? ( | |
| // Skeleton while the first fetch is in flight — backend can take a moment on cold start | |
| <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 4 }}> | |
| {[1, 2].map(i => ( | |
| <div key={i} style={{ | |
| height: 34, borderRadius: "var(--radius-sm)", | |
| background: "var(--surface-3)", | |
| animation: "pulse 1.4s ease-in-out infinite", | |
| animationDelay: `${i * 0.15}s`, | |
| }} /> | |
| ))} | |
| </div> | |
| ) : repos.length === 0 ? ( | |
| <p style={{ fontSize: 13, color: "var(--muted)", lineHeight: 1.5 }}> | |
| No repos indexed yet. Add one above. | |
| </p> | |
| ) : ( | |
| <div className="repo-list"> | |
| {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 ( | |
| <div | |
| key={r.slug} | |
| className={`repo-item ${activeRepo === r.slug ? "active" : ""}`} | |
| onClick={() => onSelectRepo(activeRepo === r.slug ? null : r.slug)} | |
| style={{ position: "relative", overflow: "hidden" }} | |
| > | |
| <div className="repo-item-main"> | |
| {/* GitHub mark — reinforces these are GitHub repos without taking space */} | |
| <svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.3, flexShrink: 0 }}> | |
| <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/> | |
| </svg> | |
| {/* Split slug: owner dimmed + repo name prominent — scanability trick from Linear */} | |
| <span className="repo-slug"> | |
| <span className="repo-owner">{r.slug.split("/")[0]}/</span>{r.slug.split("/")[1]} | |
| </span> | |
| <div className="repo-item-meta"> | |
| {/* Staleness indicator — shown when index is > 3 days old */} | |
| {staleness && !justDone && ( | |
| <span className={`repo-staleness repo-staleness--${staleness}`} title={`Indexed ${timeAgo(r.indexed_at)}`}> | |
| {staleness === "warn" ? "~old" : "stale"} | |
| </span> | |
| )} | |
| {justDone && ( | |
| <span className="repo-staleness repo-staleness--fresh">updated</span> | |
| )} | |
| {r.contextual_at && ( | |
| <span className="repo-contextual" title={`Contextual retrieval applied — re-indexed ${timeAgo(r.contextual_at)}`} aria-label="Contextual retrieval applied"> | |
| <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | |
| {/* Sparkle: one large 4-point star + two tiny companions. | |
| Reads unambiguously as "AI enhanced" at any size. */} | |
| <path d="M12 3v4m0 10v4M3 12h4m10 0h4M6.7 6.7l2.8 2.8m5 5 2.8 2.8M6.7 17.3l2.8-2.8m5-5 2.8-2.8"/> | |
| </svg> | |
| </span> | |
| )} | |
| <span className="repo-count" title={`${r.chunks} indexed code chunks`}>{r.chunks}</span> | |
| </div> | |
| </div> | |
| <div className="repo-item-actions"> | |
| {/* README generator — subtle hover-only action */} | |
| {onGenerateReadme && ( | |
| <button | |
| className="repo-readme-btn" | |
| onClick={(e) => { e.stopPropagation(); onGenerateReadme(r.slug); }} | |
| title="Generate README" | |
| aria-label={`Generate README for ${r.slug}`} | |
| > | |
| <svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true"> | |
| <rect x="2" y="1" width="9" height="13" rx="1"/> | |
| <path d="M5 5h4M5 7h4M5 9h2"/> | |
| <path d="M9 1v3h3"/> | |
| </svg> | |
| </button> | |
| )} | |
| {/* Re-index button — one click re-ingests from scratch */} | |
| <button | |
| className={`repo-reindex${isReindexingThis ? " spinning" : ""}${justDone ? " done-glow" : ""}`} | |
| onClick={(e) => handleReindex(e, r.slug)} | |
| disabled={!!reindexing} | |
| title={isReindexingThis ? "Re-indexing…" : "Re-index with contextual retrieval — adds AI-generated descriptions to key chunks before embedding, improving search precision"} | |
| aria-label={`Re-index ${r.slug}`} | |
| > | |
| ⟳ | |
| </button> | |
| {confirming === r.slug ? ( | |
| <span style={{ display: "flex", gap: 2, alignItems: "center", flexShrink: 0 }}> | |
| <button | |
| className="repo-confirm-yes" | |
| onClick={(e) => { e.stopPropagation(); handleDelete(e, r.slug); setConfirming(null); }} | |
| >Delete</button> | |
| <button | |
| className="repo-confirm-no" | |
| onClick={(e) => { e.stopPropagation(); setConfirming(null); }} | |
| >Cancel</button> | |
| </span> | |
| ) : ( | |
| <button | |
| className="repo-delete" | |
| onClick={(e) => { e.stopPropagation(); setConfirming(r.slug); }} | |
| title="Remove from index" | |
| aria-label={`Remove ${r.slug} from index`} | |
| >×</button> | |
| )} | |
| </div> | |
| {/* Progress bar — shown while re-indexing, then holds at 100% and | |
| glows for 8s after completion before fading out */} | |
| {(pct !== null || justDone) && ( | |
| <div className="repo-reindex-progress"> | |
| <div | |
| className={`repo-reindex-progress-bar${justDone ? " done" : ""}`} | |
| style={{ width: justDone ? "100%" : `${pct}%` }} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {!isLanding && repos.length > 0 && <ContextualTip />} | |
| </div> | |
| {/* ── Recent chats ── */} | |
| {sessions && sessions.length > 0 && ( | |
| <div className="sidebar-section"> | |
| <div className="section-label">Recent chats</div> | |
| {/* Session search — visible when there are enough sessions to warrant filtering */} | |
| {sessions.length >= 3 && ( | |
| <input | |
| className="session-search" | |
| type="text" | |
| placeholder="Search chats…" | |
| value={sessionSearch} | |
| onChange={e => setSessionSearch(e.target.value)} | |
| aria-label="Search sessions" | |
| /> | |
| )} | |
| <div className="session-list"> | |
| {sessions | |
| .filter(sess => !sessionSearch || sess.title.toLowerCase().includes(sessionSearch.toLowerCase())) | |
| .map(sess => ( | |
| <SessionItem | |
| key={sess.id} | |
| sess={sess} | |
| isActive={sess.id === currentSessionId} | |
| onLoad={onLoadSession} | |
| onDelete={onDeleteSession} | |
| onRename={onRenameSession} | |
| /> | |
| )) | |
| } | |
| {sessionSearch && sessions.filter(s => s.title.toLowerCase().includes(sessionSearch.toLowerCase())).length === 0 && ( | |
| <div style={{ fontSize: 12, color: "var(--muted)", padding: "6px 0" }}>No chats match "{sessionSearch}"</div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div>{/* end sidebar-scroll */} | |
| {/* ── MCP Server Status — pinned at bottom, does not scroll with sidebar ── */} | |
| <div className="mcp-panel"> | |
| <button | |
| className="mcp-panel-header" | |
| onClick={() => setMcpOpen(o => !o)} | |
| aria-expanded={mcpOpen} | |
| aria-controls="mcp-panel-body" | |
| > | |
| <span className={`mcp-dot ${mcpInfo?.connected ? "connected" : "disconnected"}`} /> | |
| <span className="mcp-panel-title">MCP Server</span> | |
| {mcpInfo?.connected && ( | |
| <span className="mcp-counts"> | |
| {mcpInfo.tools.length}T · {mcpInfo.resources.length}R · {mcpInfo.prompts.length}P | |
| </span> | |
| )} | |
| {/* Panel expands UPWARD from the bottom. Closed state points up | |
| (where the panel will appear); open state points down (where | |
| it will collapse back to). The down-chevron SVG is the base — | |
| rotate it when closed so the caret matches the action. */} | |
| <svg | |
| className="mcp-chevron" | |
| width="10" height="10" viewBox="0 0 16 16" | |
| fill="none" stroke="currentColor" strokeWidth="2" | |
| strokeLinecap="round" strokeLinejoin="round" | |
| style={{ transform: mcpOpen ? "none" : "rotate(180deg)", transition: "transform 0.2s" }} | |
| aria-hidden="true" | |
| > | |
| <path d="m4 6 4 4 4-4"/> | |
| </svg> | |
| </button> | |
| {mcpOpen && mcpInfo && ( | |
| <div id="mcp-panel-body" className="mcp-panel-body"> | |
| {!mcpInfo.connected ? ( | |
| <p className="mcp-error">Not connected — is the backend running?</p> | |
| ) : ( | |
| <> | |
| {/* Primer — one line explaining what this panel exposes. | |
| Turns a debug list into a piece of the product story. */} | |
| <p className="mcp-primer"> | |
| Live from the backend — every capability the agent uses to reason over your code. | |
| </p> | |
| {mcpInfo.tools.length > 0 && ( | |
| <div className="mcp-section"> | |
| <div className="mcp-section-label"> | |
| <span>Tools</span> | |
| <span className="mcp-section-count">{mcpInfo.tools.length}</span> | |
| </div> | |
| {mcpInfo.tools.map(t => { | |
| const key = `tool:${t.name}`; | |
| const expanded = mcpExpandedKey === key; | |
| return ( | |
| <div key={t.name} className={`mcp-row${expanded ? " is-open" : ""}`}> | |
| <button | |
| type="button" | |
| className="mcp-item" | |
| onClick={() => setMcpExpandedKey(expanded ? null : key)} | |
| aria-expanded={expanded} | |
| > | |
| <span className="mcp-kind mcp-kind-tool" aria-hidden="true"> | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M6 3 3 6l3 3M10 13l3-3-3-3M9 4 7 12"/></svg> | |
| </span> | |
| <span className="mcp-item-content"> | |
| <span className="mcp-item-name">{t.name}</span> | |
| {t.description && <span className="mcp-item-desc">{t.description}</span>} | |
| </span> | |
| <svg className="mcp-item-chevron" width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg> | |
| </button> | |
| {expanded && t.description && ( | |
| <div className="mcp-detail"> | |
| <p className="mcp-detail-desc">{t.description}</p> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {mcpInfo.resources.length > 0 && ( | |
| <div className="mcp-section"> | |
| <div className="mcp-section-label"> | |
| <span>Resources</span> | |
| <span className="mcp-section-count">{mcpInfo.resources.length}</span> | |
| </div> | |
| {mcpInfo.resources.map(r => { | |
| const key = `res:${r.uri}`; | |
| const expanded = mcpExpandedKey === key; | |
| return ( | |
| <div key={r.uri} className={`mcp-row${expanded ? " is-open" : ""}`}> | |
| <button | |
| type="button" | |
| className="mcp-item" | |
| onClick={() => setMcpExpandedKey(expanded ? null : key)} | |
| aria-expanded={expanded} | |
| > | |
| <span className="mcp-kind mcp-kind-resource" aria-hidden="true"> | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M8 2C4.7 2 2 3.3 2 5v6c0 1.7 2.7 3 6 3s6-1.3 6-3V5c0-1.7-2.7-3-6-3Z"/><path d="M2 5c0 1.7 2.7 3 6 3s6-1.3 6-3M2 8c0 1.7 2.7 3 6 3s6-1.3 6-3"/></svg> | |
| </span> | |
| <span className="mcp-item-content"> | |
| <span className="mcp-item-name">{r.name || r.uri.split("://").pop()}</span> | |
| <span className="mcp-item-desc mcp-uri">{r.uri}</span> | |
| </span> | |
| <svg className="mcp-item-chevron" width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg> | |
| </button> | |
| {expanded && ( | |
| <div className="mcp-detail"> | |
| <p className="mcp-detail-desc"> | |
| {r.description || "Read-only resource exposed over MCP."} | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {mcpInfo.prompts.length > 0 && ( | |
| <div className="mcp-section"> | |
| <div className="mcp-section-label"> | |
| <span>Prompts</span> | |
| <span className="mcp-section-count">{mcpInfo.prompts.length}</span> | |
| </div> | |
| {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 ( | |
| <div key={p.name} className={`mcp-row${expanded ? " is-open" : ""}`}> | |
| <button | |
| type="button" | |
| className="mcp-item" | |
| onClick={() => setMcpExpandedKey(expanded ? null : key)} | |
| aria-expanded={expanded} | |
| > | |
| <span className="mcp-kind mcp-kind-prompt" aria-hidden="true"> | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h10M3 6h10M3 9h7M3 12h4"/></svg> | |
| </span> | |
| <span className="mcp-item-content"> | |
| <span className="mcp-item-name">/{p.name}</span> | |
| {p.description && <span className="mcp-item-desc">{p.description}</span>} | |
| </span> | |
| <svg className="mcp-item-chevron" width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg> | |
| </button> | |
| {expanded && ( | |
| <div className="mcp-detail"> | |
| {p.description && <p className="mcp-detail-desc">{p.description}</p>} | |
| {args.length > 0 && ( | |
| <div className="mcp-sig"> | |
| <div className="mcp-sig-label">Arguments</div> | |
| {args.map(a => ( | |
| <div key={a.name} className="mcp-sig-arg"> | |
| <span className="mcp-sig-name">{a.name}</span> | |
| {a.required && <span className="mcp-sig-req">required</span>} | |
| {a.description && <span className="mcp-sig-desc">{a.description}</span>} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {preview && ( | |
| <pre className="mcp-detail-preview">{preview}</pre> | |
| )} | |
| {!hasRequiredArgs && !preview && ( | |
| <button | |
| type="button" | |
| className="mcp-detail-action" | |
| onClick={async () => { | |
| try { | |
| const { text } = await fetchMcpPrompt(p.name, {}); | |
| setMcpPromptPreview(prev => ({ ...prev, [p.name]: text })); | |
| } catch (err) { | |
| setMcpPromptPreview(prev => ({ ...prev, [p.name]: `Error: ${err.message}` })); | |
| } | |
| }} | |
| > | |
| Preview expanded prompt | |
| </button> | |
| )} | |
| {hasRequiredArgs && !preview && ( | |
| <p className="mcp-detail-hint"> | |
| Invoke from chat: type <code>/{p.name}</code> in the message box. | |
| </p> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |