cartographer / ui /src /components /Sidebar.jsx
umanggarg's picture
UX polish: agent badge, remove All repos, wire ingestion progress
a64c4c4
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 appliedre-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>
);
}