<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bobbin</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%234a9eff'/%3E%3Ctext x='8' y='12' font-family='monospace' font-size='11' font-weight='bold' fill='white' text-anchor='middle'%3Eb%3C/text%3E%3C/svg%3E">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#0f1117;--surface:#1a1d27;--surface2:#242837;--border:#2e3347;
--text:#e2e8f0;--dim:#8892b0;--accent:#60a5fa;--accent2:#818cf8;
--green:#34d399;--amber:#fbbf24;--red:#f87171;--cyan:#22d3ee;
--mono:'JetBrains Mono','Fira Code','Cascadia Code',monospace;
--sans:system-ui,-apple-system,sans-serif;
}
body{background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;line-height:1.5}
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
/* Nav */
nav{background:linear-gradient(135deg,#1e2235,#252a3e);border-bottom:1px solid var(--border);
padding:0 1.5rem;display:flex;align-items:center;gap:2rem;position:sticky;top:0;z-index:100;
backdrop-filter:blur(12px)}
nav .logo{font-family:var(--mono);font-size:1.1rem;font-weight:700;color:var(--accent);
padding:0.8rem 0;letter-spacing:-0.5px}
nav .tabs{display:flex;gap:0}
nav .tab{padding:0.8rem 1rem;color:var(--dim);cursor:pointer;border-bottom:2px solid transparent;
transition:all 0.15s;font-size:0.85rem;font-weight:500}
nav .tab:hover{color:var(--text)}
nav .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
nav .pulse{margin-left:auto;width:8px;height:8px;border-radius:50%;background:var(--green);
animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
/* Layout */
main{max-width:1200px;margin:0 auto;padding:1.5rem}
.page{display:none}.page.active{display:block}
h2{font-size:1.2rem;font-weight:600;margin-bottom:1rem;color:var(--text)}
h3{font-size:0.95rem;font-weight:600;margin-bottom:0.5rem;color:var(--dim)}
/* Cards */
.grid{display:grid;gap:1rem}
.grid-4{grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}
.grid-3{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
.card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem}
.card-header{font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--dim);margin-bottom:0.5rem}
.stat-value{font-size:1.8rem;font-weight:700;font-family:var(--mono)}
.stat-value.accent{color:var(--accent)}
.stat-value.green{color:var(--green)}
.stat-value.amber{color:var(--amber)}
/* Search */
.search-box{display:flex;gap:0.5rem;margin-bottom:1rem}
.search-box input{flex:1;background:var(--surface);border:1px solid var(--border);border-radius:6px;
padding:0.6rem 1rem;color:var(--text);font-size:0.9rem;font-family:var(--sans);outline:none}
.search-box input:focus{border-color:var(--accent)}
.search-box select{background:var(--surface);border:1px solid var(--border);border-radius:6px;
padding:0.6rem 0.75rem;color:var(--text);font-size:0.85rem;outline:none}
.search-box button{background:var(--accent);color:#fff;border:none;border-radius:6px;
padding:0.6rem 1.2rem;cursor:pointer;font-weight:600;font-size:0.85rem}
.search-box button:hover{opacity:0.9}
/* Results */
.result{background:var(--surface);border:1px solid var(--border);border-radius:6px;
padding:0.75rem 1rem;margin-bottom:0.5rem}
.result-header{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem}
.result-path{font-family:var(--mono);font-size:0.85rem;color:var(--accent)}
.result-type{font-size:0.7rem;padding:2px 6px;border-radius:3px;background:var(--surface2);color:var(--dim)}
.result-score{font-family:var(--mono);font-size:0.75rem;color:var(--dim);margin-left:auto}
.result-name{font-family:var(--mono);font-size:0.85rem;font-weight:600;color:var(--cyan)}
.result-preview{font-family:var(--mono);font-size:0.8rem;color:var(--dim);white-space:pre-wrap;
overflow:hidden;max-height:80px;line-height:1.4;margin-top:0.25rem;
background:var(--bg);padding:0.5rem;border-radius:4px}
.result-lines{font-size:0.75rem;color:var(--dim)}
.entity-badges{display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.35rem}
.entity-badge{display:inline-flex;align-items:center;gap:0.3rem;font-size:0.7rem;padding:2px 8px;
border-radius:10px;background:var(--surface2);border:1px solid var(--border);color:var(--text);
text-decoration:none;transition:border-color 0.15s,background 0.15s}
.entity-badge:hover{border-color:var(--accent);background:var(--surface)}
.entity-badge .badge-type{color:var(--dim);font-size:0.65rem}
/* Lang bars */
.lang-bar{display:flex;height:6px;border-radius:3px;overflow:hidden;margin:0.5rem 0}
.lang-segment{height:100%}
.lang-list{display:flex;flex-wrap:wrap;gap:0.75rem;margin-top:0.5rem}
.lang-item{display:flex;align-items:center;gap:0.35rem;font-size:0.8rem}
.lang-dot{width:8px;height:8px;border-radius:50%}
/* File tree */
.file-item{padding:0.4rem 0.75rem;display:flex;align-items:center;gap:0.5rem;
border-bottom:1px solid var(--border);font-size:0.85rem}
.file-item:hover{background:var(--surface2)}
.file-path{font-family:var(--mono);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-chunks{font-family:var(--mono);color:var(--dim);font-size:0.8rem}
/* Bead results */
.bead{background:var(--surface);border:1px solid var(--border);border-radius:6px;
padding:0.75rem 1rem;margin-bottom:0.5rem;display:flex;align-items:flex-start;gap:0.75rem}
.bead-id{font-family:var(--mono);font-size:0.8rem;color:var(--accent);white-space:nowrap}
.bead-priority{font-family:var(--mono);font-size:0.7rem;padding:1px 5px;border-radius:3px;font-weight:700}
.bead-priority.P1{background:rgba(248,113,113,0.2);color:var(--red)}
.bead-priority.P2{background:rgba(251,191,36,0.2);color:var(--amber)}
.bead-priority.P3{background:rgba(148,163,184,0.2);color:var(--dim)}
.bead-title{font-size:0.9rem}
.bead-meta{font-size:0.75rem;color:var(--dim);margin-top:0.25rem}
/* Autocomplete */
.search-wrap{position:relative;flex:1;display:flex}
.search-wrap input{width:100%}
.ac-dropdown{position:absolute;top:100%;left:0;right:0;background:var(--surface);border:1px solid var(--border);
border-radius:0 0 6px 6px;max-height:200px;overflow-y:auto;z-index:200;display:none}
.ac-dropdown.open{display:block}
.ac-item{padding:0.4rem 0.75rem;font-size:0.85rem;cursor:pointer;font-family:var(--mono)}
.ac-item:hover,.ac-item.selected{background:var(--surface2);color:var(--accent)}
.ac-field{color:var(--dim);font-size:0.75rem;margin-left:0.5rem}
/* Filter chips */
.filter-chips{display:flex;flex-wrap:wrap;gap:0.35rem;margin-bottom:0.5rem}
.filter-chip{display:inline-flex;align-items:center;gap:0.3rem;font-size:0.75rem;font-family:var(--mono);
padding:2px 8px;border-radius:4px;background:var(--surface2);color:var(--accent);border:1px solid var(--border)}
.filter-chip.negated{color:var(--red);border-color:rgba(248,113,113,0.3)}
.filter-chip .chip-x{cursor:pointer;color:var(--dim);font-size:0.7rem;margin-left:0.15rem}
.filter-chip .chip-x:hover{color:var(--red)}
/* Syntax help */
.syntax-help{position:relative;display:inline-flex}
.syntax-btn{background:none;border:1px solid var(--border);border-radius:50%;width:24px;height:24px;
color:var(--dim);cursor:pointer;font-size:0.75rem;display:flex;align-items:center;justify-content:center}
.syntax-btn:hover{color:var(--accent);border-color:var(--accent)}
.syntax-popup{display:none;position:absolute;top:calc(100% + 6px);right:0;width:340px;background:var(--surface);
border:1px solid var(--border);border-radius:6px;padding:0.75rem;z-index:200;font-size:0.8rem;
box-shadow:0 4px 12px rgba(0,0,0,0.4)}
.syntax-popup.open{display:block}
.syntax-popup h4{font-size:0.8rem;color:var(--accent);margin-bottom:0.5rem}
.syntax-popup code{font-family:var(--mono);font-size:0.75rem;background:var(--bg);padding:1px 4px;border-radius:3px}
.syntax-popup .syn-row{display:flex;gap:0.5rem;margin-bottom:0.3rem}
.syntax-popup .syn-key{color:var(--cyan);min-width:120px;font-family:var(--mono);font-size:0.75rem}
.syntax-popup .syn-desc{color:var(--dim)}
/* Spinner */
.spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--border);
border-top-color:var(--accent);border-radius:50%;animation:spin 0.6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.loading{text-align:center;padding:2rem;color:var(--dim)}
/* Detail overlay */
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:300;
backdrop-filter:blur(4px);justify-content:center;align-items:flex-start;padding:3rem 1rem;overflow-y:auto}
.overlay.open{display:flex}
.detail-panel{background:var(--surface);border:1px solid var(--border);border-radius:10px;
width:100%;max-width:800px;padding:1.5rem;position:relative}
.detail-close{position:absolute;top:0.75rem;right:1rem;background:none;border:none;
color:var(--dim);font-size:1.2rem;cursor:pointer}
.detail-close:hover{color:var(--red)}
.detail-section{margin-top:1rem}
.detail-section h4{font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--dim);margin-bottom:0.5rem}
.detail-kv{display:grid;grid-template-columns:120px 1fr;gap:0.25rem 0.75rem;font-size:0.85rem}
.detail-kv dt{color:var(--dim);font-family:var(--mono);font-size:0.8rem}
.detail-kv dd{font-family:var(--mono);word-break:break-all}
.detail-file{padding:0.3rem 0.5rem;border-bottom:1px solid var(--border);font-family:var(--mono);font-size:0.8rem;color:var(--accent)}
.detail-file:last-child{border-bottom:none}
/* Empty state */
.empty{text-align:center;padding:2rem;color:var(--dim);font-style:italic}
/* Tables */
table{width:100%;border-collapse:collapse}
th{text-align:left;font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;
color:var(--dim);padding:0.5rem 0.75rem;border-bottom:1px solid var(--border)}
td{padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);font-size:0.85rem}
td.mono{font-family:var(--mono)}
/* Quick action cards */
.card:hover{border-color:var(--accent);transition:border-color 0.15s}
@media(max-width:768px){
.grid-4{grid-template-columns:repeat(2,1fr)}
nav{padding:0 0.75rem;gap:1rem}
main{padding:0.75rem}
#page-home > div[style*="grid-template-columns"]{grid-template-columns:1fr !important}
}
</style>
</head>
<body>
<nav>
<div class="logo">bobbin</div>
<div class="tabs">
<div class="tab active" data-page="home">Home</div>
<div class="tab" data-page="search">Search</div>
<div class="tab" data-page="context">Context</div>
<div class="tab" data-page="repos">Repos</div>
<div class="tab" data-page="tags">Tags</div>
<div class="tab" data-page="bundles">Bundles</div>
<div class="tab" data-page="beads">Beads</div>
<div class="tab" data-page="hotspots">Hotspots</div>
<div class="tab" data-page="knowledge">Knowledge</div>
<div class="tab" data-page="feedback">Feedback</div>
<div class="tab" data-page="status">Status</div>
</div>
<div class="pulse" id="pulse" title="Connected"></div>
</nav>
<main>
<!-- STATUS PAGE -->
<div class="page" id="page-status">
<h2>Index Status</h2>
<div class="grid grid-4" id="status-stats"></div>
<div style="margin-top:1.5rem">
<h3>Languages</h3>
<div id="lang-bar"></div>
<div id="lang-list" class="lang-list"></div>
</div>
<details style="margin-top:1.5rem">
<summary style="cursor:pointer;color:var(--dim);font-size:0.85rem">Prometheus Metrics (raw)</summary>
<div class="card" style="margin-top:0.5rem"><pre id="metrics-raw" style="font-family:var(--mono);font-size:0.8rem;color:var(--dim);white-space:pre-wrap"></pre></div>
</details>
</div>
<!-- HOME DASHBOARD -->
<div class="page active" id="page-home">
<!-- Quick search -->
<div class="search-box" style="margin-bottom:1.5rem">
<input type="text" id="home-search-q" placeholder="Search code, docs, symbols..." style="font-size:1rem;padding:0.8rem 1rem" autocomplete="off">
<button onclick="homeSearch()" style="padding:0.8rem 1.5rem;font-size:0.9rem">Search</button>
</div>
<!-- Stats row -->
<div class="grid grid-4" id="home-stats"></div>
<!-- Two-column: Recent injections + Quick actions -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1.5rem">
<!-- Recent injections -->
<div>
<h3>Recent Injections</h3>
<div id="home-injections" style="margin-top:0.5rem"><div class="loading"><div class="spinner"></div></div></div>
</div>
<!-- Quick actions -->
<div>
<h3>Quick Actions</h3>
<div style="display:flex;flex-direction:column;gap:0.5rem;margin-top:0.5rem">
<div class="card" style="cursor:pointer;display:flex;align-items:center;gap:0.75rem" onclick="switchTab('context')">
<span style="font-size:1.2rem">📦</span>
<div>
<div style="font-weight:600;font-size:0.9rem">Assemble Context</div>
<div style="font-size:0.8rem;color:var(--dim)">Build task-relevant context bundles</div>
</div>
</div>
<div class="card" style="cursor:pointer;display:flex;align-items:center;gap:0.75rem" onclick="switchTab('hotspots')">
<span style="font-size:1.2rem">🔥</span>
<div>
<div style="font-weight:600;font-size:0.9rem">Code Hotspots</div>
<div style="font-size:0.8rem;color:var(--dim)">High-churn, high-complexity files</div>
</div>
</div>
<div class="card" style="cursor:pointer;display:flex;align-items:center;gap:0.75rem" onclick="switchTab('tags')">
<span style="font-size:1.2rem">🏷️</span>
<div>
<div style="font-weight:600;font-size:0.9rem">Browse Tags</div>
<div style="font-size:0.8rem;color:var(--dim)">Explore semantic tag assignments</div>
</div>
</div>
<div class="card" style="cursor:pointer;display:flex;align-items:center;gap:0.75rem" onclick="switchTab('feedback')">
<span style="font-size:1.2rem">📊</span>
<div>
<div style="font-weight:600;font-size:0.9rem">Injection Quality</div>
<div style="font-size:0.8rem;color:var(--dim)">Review agent feedback on injections</div>
</div>
</div>
<div class="card" style="cursor:pointer;display:flex;align-items:center;gap:0.75rem" onclick="switchTab('beads')">
<span style="font-size:1.2rem">🔍</span>
<div>
<div style="font-weight:600;font-size:0.9rem">Search Beads</div>
<div style="font-size:0.8rem;color:var(--dim)">Find issues and tasks</div>
</div>
</div>
</div>
</div>
</div>
<!-- Top repos -->
<div style="margin-top:1.5rem">
<h3>Repositories</h3>
<div id="home-repos" style="margin-top:0.5rem"><div class="loading"><div class="spinner"></div></div></div>
</div>
</div>
<!-- SEARCH PAGE -->
<div class="page" id="page-search">
<h2>Search</h2>
<div id="filter-chips" class="filter-chips"></div>
<div class="search-box">
<div class="search-wrap">
<input type="text" id="search-q" placeholder="Search code, docs, symbols... (try repo: lang: type:)" autocomplete="off" autofocus>
<div class="ac-dropdown" id="ac-dropdown"></div>
</div>
<select id="search-mode">
<option value="hybrid">Hybrid</option>
<option value="semantic">Semantic</option>
<option value="keyword">Keyword</option>
</select>
<input type="number" id="search-limit" value="20" min="1" max="100" style="width:60px" title="Max results">
<label style="display:flex;align-items:center;gap:0.35rem;font-size:0.8rem;color:var(--dim);cursor:pointer;white-space:nowrap">
<input type="checkbox" id="search-archive" style="accent-color:var(--accent)"> Include archive
</label>
<button onclick="doSearch()">Search</button>
<div class="syntax-help">
<button class="syntax-btn" onclick="toggleSyntaxHelp()" title="Query syntax help">?</button>
<div class="syntax-popup" id="syntax-popup">
<h4>Query Syntax</h4>
<div class="syn-row"><span class="syn-key">repo:name</span><span class="syn-desc">Filter by repository</span></div>
<div class="syn-row"><span class="syn-key">lang:rust,go</span><span class="syn-desc">Filter by language(s)</span></div>
<div class="syn-row"><span class="syn-key">type:function</span><span class="syn-desc">Filter by chunk type</span></div>
<div class="syn-row"><span class="syn-key">file:*.rs</span><span class="syn-desc">Filter by file path (glob)</span></div>
<div class="syn-row"><span class="syn-key">group:infra</span><span class="syn-desc">Filter by repo group</span></div>
<div class="syn-row"><span class="syn-key">-repo:test</span><span class="syn-desc">Exclude a filter value</span></div>
<div class="syn-row"><span class="syn-key">"exact phrase"</span><span class="syn-desc">Match exact phrase</span></div>
<div class="syn-row"><span class="syn-key">A OR B</span><span class="syn-desc">Match either term</span></div>
<div class="syn-row"><span class="syn-key">-word / NOT word</span><span class="syn-desc">Exclude term from results</span></div>
<div class="syn-row"><span class="syn-key">/regex/</span><span class="syn-desc">Regex pattern filter</span></div>
</div>
</div>
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:0.75rem;align-items:center">
<input type="text" id="search-repo" placeholder="repo" style="width:100px;font-size:0.8rem;padding:0.3rem 0.5rem" title="Filter by repository name">
<input type="text" id="search-tag" placeholder="tag" style="width:100px;font-size:0.8rem;padding:0.3rem 0.5rem" title="Include only chunks with this tag (comma-separated)">
<input type="text" id="search-exclude-tag" placeholder="exclude tag" style="width:110px;font-size:0.8rem;padding:0.3rem 0.5rem" title="Exclude chunks with this tag (comma-separated)">
<input type="text" id="search-group" placeholder="group" style="width:80px;font-size:0.8rem;padding:0.3rem 0.5rem" title="Filter by repo group name">
</div>
<div id="search-results"></div>
</div>
<!-- CONTEXT PAGE -->
<div class="page" id="page-context">
<h2>Context Assembly</h2>
<p style="color:var(--dim);margin-bottom:1rem;font-size:0.85rem">Assemble task-relevant context bundles from search + git coupling. This is what agents receive via hooks.</p>
<div class="search-box">
<input type="text" id="context-q" placeholder="Describe your task... e.g. fix the login timeout bug">
<input type="number" id="context-budget" value="500" min="50" max="5000" style="width:80px" title="Budget (lines)">
<input type="number" id="context-limit" value="20" min="1" max="100" style="width:60px" title="Search limit">
<select id="context-repo">
<option value="">All repos</option>
</select>
<button onclick="doContext()">Assemble</button>
</div>
<div id="context-results"></div>
</div>
<!-- TAGS PAGE -->
<div class="page" id="page-tags">
<h2>Tag Browser</h2>
<p style="color:var(--dim);margin-bottom:1rem;font-size:0.85rem">Browse semantic tags assigned to indexed chunks. Tags drive score effects in context assembly.</p>
<div id="tags-summary" style="margin-bottom:1rem"></div>
<div class="grid grid-3" id="tags-grid"></div>
<div id="tag-files-section" style="display:none;margin-top:1.5rem">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.75rem">
<h3 id="tag-files-title" style="margin:0"></h3>
<span style="color:var(--dim);cursor:pointer;font-size:0.85rem" onclick="closeTagFiles()">[close]</span>
</div>
<div class="card" style="max-height:50vh;overflow-y:auto">
<div id="tag-file-list"></div>
</div>
</div>
</div>
<!-- REPOS PAGE -->
<div class="page" id="page-repos">
<h2>Repositories</h2>
<div id="repos-list"></div>
<div id="repo-files-section" style="display:none;margin-top:1.5rem">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.75rem">
<h3 id="repo-files-title" style="margin:0"></h3>
<span style="color:var(--dim);cursor:pointer;font-size:0.85rem" onclick="closeRepoFiles()">[close]</span>
</div>
<div class="search-box">
<input type="text" id="repo-filter" placeholder="Filter files..." oninput="filterFiles()">
</div>
<div class="card" style="max-height:50vh;overflow-y:auto">
<div id="file-list"></div>
</div>
</div>
</div>
<!-- BUNDLES PAGE -->
<div class="page" id="page-bundles">
<h2>Context Bundles</h2>
<p style="color:var(--dim);margin-bottom:1rem;font-size:0.85rem">Curated knowledge anchors — named groups of files, symbols, and docs that capture a concept or subsystem.</p>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;align-items:center">
<select id="bundles-repo-filter" onchange="loadBundles()" style="background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:0.5rem 0.75rem;color:var(--text);font-size:0.85rem">
<option value="">All repos</option>
</select>
<span id="bundles-count" style="color:var(--dim);font-size:0.85rem"></span>
</div>
<div class="grid grid-3" id="bundles-grid"></div>
<div id="bundle-detail-section" style="display:none;margin-top:1.5rem">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.75rem">
<h3 id="bundle-detail-title" style="margin:0"></h3>
<span style="color:var(--dim);cursor:pointer;font-size:0.85rem" onclick="closeBundleDetail()">[close]</span>
</div>
<div id="bundle-detail-content" class="card" style="max-height:60vh;overflow-y:auto;padding:1.25rem"></div>
</div>
</div>
<!-- BEADS PAGE -->
<div class="page" id="page-beads">
<h2>Beads Search</h2>
<div class="search-box">
<input type="text" id="beads-q" placeholder="Search beads by title, description...">
<select id="beads-status">
<option value="">Any status</option>
<option value="open" selected>Open</option>
<option value="in_progress">In Progress</option>
<option value="closed">Closed</option>
</select>
<button onclick="doBeadsSearch()">Search</button>
</div>
<div id="beads-results"></div>
</div>
<!-- FEEDBACK PAGE -->
<div class="page" id="page-feedback">
<h2>Injection Feedback</h2>
<div class="grid grid-4" id="feedback-stats"></div>
<div style="margin-top:1.5rem">
<div class="search-box">
<select id="feedback-rating" onchange="loadFeedbackList()">
<option value="">All ratings</option>
<option value="useful">Useful</option>
<option value="noise">Noise</option>
<option value="harmful">Harmful</option>
</select>
<input type="text" id="feedback-agent" placeholder="Filter by agent..." onchange="loadFeedbackList()">
<button onclick="loadFeedbackList()">Refresh</button>
</div>
<div id="feedback-list"></div>
</div>
</div>
<!-- HOTSPOTS PAGE -->
<div class="page" id="page-hotspots">
<h2>Code Hotspots</h2>
<p style="color:var(--dim);margin-bottom:1rem;font-size:0.85rem">Files with highest churn + complexity. Candidates for refactoring.</p>
<div id="hotspots-table"></div>
</div>
<div class="page" id="page-knowledge">
<h2>Knowledge Graph</h2>
<p style="color:var(--dim);margin-bottom:1rem;font-size:0.85rem">Explore the Quipu knowledge graph. Search for entities or browse the graph.</p>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
<input id="knowledge-q" class="search-input" style="flex:1" placeholder="Search entities (e.g. proxmox, dns, reactor)...">
<button class="btn" onclick="knowledgeSearch()">Explore</button>
<a id="knowledge-popout" class="btn" style="text-decoration:none;display:none" target="_blank">Pop out</a>
</div>
<div id="knowledge-graph" style="min-height:400px;background:var(--bg);border:1px solid var(--border);border-radius:8px;position:relative">
<div id="knowledge-placeholder" style="display:flex;align-items:center;justify-content:center;height:400px;color:var(--dim)">
Loading knowledge graph...
</div>
</div>
<div id="knowledge-entities" style="margin-top:1rem"></div>
</div>
</main>
<!-- Injection detail overlay -->
<div class="overlay" id="injection-overlay" onclick="if(event.target===this)closeDetail()">
<div class="detail-panel">
<button class="detail-close" onclick="closeDetail()">×</button>
<h2 style="font-size:1rem;font-family:var(--mono)" id="detail-title">Injection Detail</h2>
<div id="detail-content"><div class="loading"><div class="spinner"></div> Loading...</div></div>
</div>
</div>
<script>
const API = ''; // same origin
let _sources = null; // populated from /status
// Tab navigation
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('page-' + tab.dataset.page).classList.add('active');
if (tab.dataset.page === 'repos' && !window._reposLoaded) loadRepos();
if (tab.dataset.page === 'tags' && !window._tagsLoaded) loadTags();
if (tab.dataset.page === 'bundles' && !window._bundlesLoaded) loadBundles();
if (tab.dataset.page === 'hotspots' && !window._hotspotsLoaded) loadHotspots();
if (tab.dataset.page === 'feedback' && !window._feedbackLoaded) loadFeedback();
if (tab.dataset.page === 'knowledge' && !window._knowledgeLoaded) loadKnowledge();
});
});
// Knowledge tab — Quipu web components
let _quipuEndpoint = null;
function loadKnowledge() {
window._knowledgeLoaded = true;
// Detect quipu endpoint from status response
fetch(API + '/status').then(r => r.json()).then(data => {
_quipuEndpoint = data.quipu_endpoint || null;
if (!_quipuEndpoint) {
document.getElementById('knowledge-placeholder').textContent = 'Quipu endpoint not configured (set quipu_endpoint in config.toml)';
return;
}
document.getElementById('knowledge-popout').href = _quipuEndpoint;
document.getElementById('knowledge-popout').style.display = '';
// Load quipu web components script
const script = document.createElement('script');
script.src = _quipuEndpoint.replace(/\/$/, '') + '/quipu-components.js';
script.onload = () => initKnowledgeGraph();
script.onerror = () => {
document.getElementById('knowledge-placeholder').textContent = 'Could not load Quipu components — is quipu.example running?';
};
document.head.appendChild(script);
}).catch(() => {
document.getElementById('knowledge-placeholder').textContent = 'Could not reach Bobbin API';
});
}
function initKnowledgeGraph() {
const container = document.getElementById('knowledge-graph');
const placeholder = document.getElementById('knowledge-placeholder');
if (typeof customElements.get('quipu-graph') !== 'undefined') {
placeholder.style.display = 'none';
const graph = document.createElement('quipu-graph');
graph.setAttribute('endpoint', _quipuEndpoint);
graph.setAttribute('height', '400px');
container.appendChild(graph);
} else {
placeholder.textContent = 'Quipu graph component not available';
}
}
function knowledgeSearch() {
const q = document.getElementById('knowledge-q').value.trim();
if (!q || !_quipuEndpoint) return;
const graph = document.querySelector('#knowledge-graph quipu-graph');
if (graph) {
graph.setAttribute('query', `SELECT ?s ?p ?o WHERE { ?s ?p ?o . ?s rdfs:label ?l . FILTER(regex(?l, "${q}", "i")) } LIMIT 50`);
}
// Also fetch spotlight for entity list
fetch(_quipuEndpoint.replace(/\/$/, '') + '/spotlight', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: q, confidence: 0.3})
}).then(r => r.json()).then(data => {
const el = document.getElementById('knowledge-entities');
const annotations = data.annotations || [];
if (annotations.length === 0) {
el.innerHTML = '<div style="color:var(--dim);font-size:0.85rem">No entities found</div>';
return;
}
el.innerHTML = '<div class="entity-badges">' + annotations.map(a =>
`<a class="entity-badge" href="${esc(quipuEntityUrl(a.iri))}" target="_blank" title="${(a.confidence * 100).toFixed(0)}% confidence"><span class="badge-type">${esc(a.entity_type)}</span> ${esc(a.surface)}</a>`
).join('') + '</div>';
}).catch(() => {});
}
document.getElementById('knowledge-q').addEventListener('keydown', e => { if (e.key === 'Enter') knowledgeSearch(); });
// Keyboard shortcut: Enter to search
const searchInput = document.getElementById('search-q');
const acDropdown = document.getElementById('ac-dropdown');
let acItems = [];
let acSelected = -1;
let acCache = {};
searchInput.addEventListener('keydown', e => {
if (acDropdown.classList.contains('open')) {
if (e.key === 'ArrowDown') { e.preventDefault(); acNav(1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); acNav(-1); return; }
if (e.key === 'Tab' || e.key === 'Enter') {
if (acSelected >= 0 && acSelected < acItems.length) {
e.preventDefault(); acAccept(acItems[acSelected]); return;
}
acClose();
if (e.key === 'Enter') doSearch();
return;
}
if (e.key === 'Escape') { acClose(); return; }
}
if (e.key === 'Enter') doSearch();
});
searchInput.addEventListener('input', () => { acCheck(); updateChips(); });
searchInput.addEventListener('blur', () => { setTimeout(acClose, 150); });
document.getElementById('beads-q').addEventListener('keydown', e => { if (e.key === 'Enter') doBeadsSearch(); });
// Language colors
const LANG_COLORS = {
rust:'#dea584',go:'#00add8',python:'#3572a5',javascript:'#f1e05a',
typescript:'#3178c6',markdown:'#083fa1',git:'#f44d27',ruby:'#701516',
java:'#b07219',c:'#555555',cpp:'#f34b7d',shell:'#89e051',yaml:'#cb171e',
toml:'#9c4221',json:'#292929',html:'#e34c26',css:'#563d7c',sql:'#e38c00'
};
function langColor(lang) { return LANG_COLORS[lang] || '#6b7280'; }
// Format timestamp
function timeAgo(ts) {
if (!ts) return 'never';
const d = Date.now()/1000 - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d/60) + 'm ago';
if (d < 86400) return Math.floor(d/3600) + 'h ago';
return Math.floor(d/86400) + 'd ago';
}
// Load status page
async function loadStatus() {
try {
const [status, metricsText] = await Promise.all([
fetch(API + '/status').then(r => r.json()),
fetch(API + '/metrics').then(r => r.text())
]);
_sources = status.sources || null;
if (status.repo_path_prefix) _repoPathPrefix = status.repo_path_prefix.replace(/\/?$/, '/');
const ix = status.index;
document.getElementById('status-stats').innerHTML = `
<div class="card"><div class="card-header">Files Indexed</div><div class="stat-value accent">${ix.total_files.toLocaleString()}</div></div>
<div class="card"><div class="card-header">Chunks</div><div class="stat-value green">${ix.total_chunks.toLocaleString()}</div></div>
<div class="card"><div class="card-header">Embeddings</div><div class="stat-value">${ix.total_embeddings.toLocaleString()}</div></div>
<div class="card"><div class="card-header">Last Indexed</div><div class="stat-value amber" style="font-size:1rem">${timeAgo(ix.last_indexed)}</div></div>
`;
// Language breakdown
const langs = ix.languages.sort((a,b) => b.chunk_count - a.chunk_count);
const total = langs.reduce((s,l) => s + l.chunk_count, 0);
document.getElementById('lang-bar').innerHTML = '<div class="lang-bar">' +
langs.map(l => `<div class="lang-segment" style="width:${(l.chunk_count/total*100).toFixed(1)}%;background:${langColor(l.language)}" title="${l.language}: ${l.chunk_count} chunks"></div>`).join('') +
'</div>';
document.getElementById('lang-list').innerHTML = langs.map(l =>
`<div class="lang-item"><div class="lang-dot" style="background:${langColor(l.language)}"></div>${l.language} <span style="color:var(--dim)">${l.file_count} files / ${l.chunk_count} chunks</span></div>`
).join('');
document.getElementById('metrics-raw').textContent = metricsText;
document.getElementById('pulse').style.background = 'var(--green)';
} catch(e) {
document.getElementById('pulse').style.background = 'var(--red)';
document.getElementById('status-stats').innerHTML = '<div class="card"><div class="stat-value" style="color:var(--red)">Offline</div><div style="color:var(--dim);margin-top:0.5rem">' + e.message + '</div></div>';
}
}
// Strip bobbin index prefix to get repo-relative path.
// _repoPathPrefix is set from the /status response (dynamic, not hardcoded).
let _repoPathPrefix = '/var/lib/bobbin/repos/'; // default until status loads
function relPath(fp) {
if (_repoPathPrefix && fp.startsWith(_repoPathPrefix)) return fp.slice(_repoPathPrefix.length);
// Also handle relative "repos/" prefix (some chunks stored without full path)
if (fp.startsWith('repos/')) return fp.slice(6);
return fp;
}
// Build a link to the file source using configured URL templates
// Returns null for repos with empty URL (e.g. archive repos without web UI)
// repoHint: optional repo name from search result metadata (avoids inferring from path)
function repoLink(fp, startLine, repoHint) {
const rel = relPath(fp);
const slashIdx = rel.indexOf('/');
if (slashIdx < 0) return null;
const inferredRepo = rel.slice(0, slashIdx);
// Use explicit repo hint when path doesn't start with a known repo name
// (e.g. pensieve records indexed as "records/_plans/..." instead of "pensieve/...")
const repo = (repoHint && _sources && _sources.repos && !(inferredRepo in _sources.repos)) ? repoHint : inferredRepo;
const filePath = repoHint && repo === repoHint ? rel : rel.slice(slashIdx + 1);
// Check explicit repo config first (empty string = no link)
if (_sources && _sources.repos && repo in _sources.repos) {
if (!_sources.repos[repo]) return null;
return buildUrl(_sources.repos[repo], repo, filePath, startLine);
}
const template = _sources && _sources.default_url;
if (!template) return null;
return buildUrl(template, repo, filePath, startLine);
}
function buildUrl(template, repo, filePath, startLine) {
// If template has no {path} placeholder, append a default source browse pattern
let tmpl = template;
if (!tmpl.includes('{path}')) {
// Detect forge type from URL pattern
if (tmpl.includes('github.com')) {
tmpl = tmpl.replace(/\/?$/, '') + '/blob/main/{path}#L{line}';
} else {
// Forgejo/Gitea default
tmpl = tmpl.replace(/\/?$/, '') + '/src/branch/main/{path}#L{line}';
}
}
let url = tmpl.replace('{repo}', repo).replace('{path}', filePath);
if (startLine) {
url = url.replace('{line}', startLine);
} else {
url = url.replace(/#L\{line\}/, '').replace(/\{line\}/, '');
}
return url;
}
// Build a link to the repo root (for repo cards)
function repoRootLink(repoName) {
// Check explicit config (empty = no link)
if (_sources && _sources.repos && repoName in _sources.repos) {
if (!_sources.repos[repoName]) return null;
return _sources.repos[repoName].replace(/\/\{path\}.*$/, '').replace(/\{path\}.*$/, '').replace('{repo}', repoName);
}
const template = _sources && _sources.default_url;
if (!template) return null;
return template
.replace(/\/\{path\}.*$/, '')
.replace(/\{path\}.*$/, '')
.replace('{repo}', repoName);
}
// Search
async function doSearch() {
const q = document.getElementById('search-q').value.trim();
if (!q) return;
const mode = document.getElementById('search-mode').value;
const limit = document.getElementById('search-limit').value;
const includeArchive = document.getElementById('search-archive').checked;
const el = document.getElementById('search-results');
el.innerHTML = '<div class="loading"><div class="spinner"></div> Searching...</div>';
updateDeepLink('search');
try {
let searchUrl = `${API}/search?q=${encodeURIComponent(q)}&mode=${mode}&limit=${limit}`;
const repo = document.getElementById('search-repo').value.trim();
const tag = document.getElementById('search-tag').value.trim();
const excludeTag = document.getElementById('search-exclude-tag').value.trim();
const group = document.getElementById('search-group').value.trim();
if (repo) searchUrl += `&repo=${encodeURIComponent(repo)}`;
if (tag) searchUrl += `&tag=${encodeURIComponent(tag)}`;
if (excludeTag) searchUrl += `&exclude_tag=${encodeURIComponent(excludeTag)}`;
if (group) searchUrl += `&group=${encodeURIComponent(group)}`;
const fetches = [fetch(searchUrl).then(r => r.json())];
if (includeArchive) {
fetches.push(fetch(`${API}/archive/search?q=${encodeURIComponent(q)}&limit=10`).then(r => r.json()).catch(() => ({results:[]})));
}
const [data, archiveData] = await Promise.all(fetches);
let results = data.results || [];
const archiveResults = includeArchive && archiveData ? (archiveData.results || []) : [];
if (results.length === 0 && archiveResults.length === 0) {
el.innerHTML = '<div class="empty">No results found</div>';
return;
}
// Spotlight entity annotations
const annotations = data.spotlight_annotations || [];
// Normalize scores relative to top result
const maxScore = results.length > 0 ? Math.max(...results.map(r => r.score)) : 1;
let html = `<div style="color:var(--dim);margin-bottom:0.75rem;font-size:0.85rem">${results.length} results (${mode} mode)${archiveResults.length > 0 ? ` + ${archiveResults.length} archive` : ''}${annotations.length > 0 ? ` — ${annotations.length} entities` : ''}</div>`;
if (annotations.length > 0) {
html += '<div class="entity-badges" style="margin-bottom:0.75rem">' + annotations.map(a =>
`<a class="entity-badge" href="${esc(quipuEntityUrl(a.iri))}" target="_blank" title="${esc(a.surface)} (${(a.confidence * 100).toFixed(0)}% confidence)"><span class="badge-type">${esc(a.entity_type)}</span> ${esc(a.surface)}</a>`
).join('') + '</div>';
}
html += results.map(res => {
const rel = relPath(res.file_path);
const link = repoLink(res.file_path, res.start_line, res.repo);
const pct = maxScore > 0 ? Math.round((res.score / maxScore) * 100) : 0;
return `
<div class="result">
<div class="result-header">
${link ? `<a class="result-path" href="${link}" target="_blank">${esc(rel)}</a>` : `<span class="result-path">${esc(rel)}</span>`}
<span class="result-type">${res.chunk_type || ''}</span>
<span class="result-lines">L${res.start_line}-${res.end_line}</span>
<span class="result-score">${pct}%</span>
</div>
${res.name ? `<div class="result-name">${esc(res.name)}</div>` : ''}
${res.content_preview ? `<div class="result-preview">${esc(res.content_preview)}</div>` : ''}
</div>
`;
}).join('');
// Archive results section
if (archiveResults.length > 0) {
html += '<h3 style="margin-top:1rem;color:var(--amber)">Archive Results</h3>';
html += archiveResults.map(a => `
<div class="result" style="border-left:3px solid var(--amber)">
<div class="result-header">
<span class="result-path">${esc(a.id || a.file_path || '')}</span>
<span class="result-type">archive</span>
${a.score ? `<span class="result-score">${(a.score * 100).toFixed(0)}</span>` : ''}
</div>
${a.title ? `<div class="result-name">${esc(a.title)}</div>` : ''}
${a.snippet || a.content_preview ? `<div class="result-preview">${esc(a.snippet || a.content_preview)}</div>` : ''}
</div>
`).join('');
}
el.innerHTML = html;
// Inject JSON-LD annotations for machine-readable entity data
injectJsonLd(annotations);
} catch(e) {
el.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
function injectJsonLd(annotations) {
// Remove any previous JSON-LD blocks
document.querySelectorAll('script[data-bobbin-jsonld]').forEach(s => s.remove());
if (!annotations || annotations.length === 0) return;
const jsonLd = {
'@context': { '@vocab': 'https://schema.org/', 'quipu': 'https://quipu.dev/ontology#' },
'@graph': annotations.map(a => ({
'@type': 'Thing',
'@id': quipuEntityUrl(a.iri),
'name': a.surface,
'quipu:entityType': a.entity_type,
'quipu:confidence': a.confidence
}))
};
const script = document.createElement('script');
script.type = 'application/ld+json';
script.setAttribute('data-bobbin-jsonld', 'true');
script.textContent = JSON.stringify(jsonLd, null, 2);
document.head.appendChild(script);
}
// Repos
async function loadRepos() {
const el = document.getElementById('repos-list');
el.innerHTML = '<div class="loading"><div class="spinner"></div> Loading repositories...</div>';
try {
const data = await fetch(API + '/repos').then(r => r.json());
window._reposLoaded = true;
if (!data.repos || data.repos.length === 0) {
el.innerHTML = '<div class="empty">No repositories indexed</div>';
return;
}
const totalFiles = data.repos.reduce((s,r) => s + r.file_count, 0);
const totalChunks = data.repos.reduce((s,r) => s + r.chunk_count, 0);
el.innerHTML = `<div style="color:var(--dim);margin-bottom:0.75rem;font-size:0.85rem">${data.count} repos — ${totalFiles.toLocaleString()} files — ${totalChunks.toLocaleString()} chunks</div>` +
'<div class="grid grid-3">' + data.repos.map(repo => {
const topLangs = repo.languages.slice(0, 4);
const langTotal = repo.languages.reduce((s,l) => s + l.chunk_count, 0);
return `
<div class="card" style="cursor:pointer" onclick="loadRepoFiles('${esc(repo.name)}')">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
${repoRootLink(repo.name) ? `<a class="result-path" style="font-size:0.95rem;font-weight:600" href="${repoRootLink(repo.name)}" target="_blank" onclick="event.stopPropagation()">${esc(repo.name)}</a>` : `<span class="result-path" style="font-size:0.95rem;font-weight:600">${esc(repo.name)}</span>`}
</div>
<div style="display:flex;gap:1.5rem;margin-bottom:0.5rem">
<div><span class="stat-value accent" style="font-size:1.2rem">${repo.file_count}</span> <span style="color:var(--dim);font-size:0.8rem">files</span></div>
<div><span class="stat-value green" style="font-size:1.2rem">${repo.chunk_count.toLocaleString()}</span> <span style="color:var(--dim);font-size:0.8rem">chunks</span></div>
</div>
<div class="lang-bar">${topLangs.map(l => `<div class="lang-segment" style="width:${(l.chunk_count/langTotal*100).toFixed(1)}%;background:${langColor(l.language)}" title="${l.language}: ${l.file_count} files"></div>`).join('')}</div>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">${topLangs.map(l => `<span style="font-size:0.7rem;color:var(--dim)"><span class="lang-dot" style="display:inline-block;width:6px;height:6px;background:${langColor(l.language)}"></span> ${l.language}</span>`).join('')}</div>
</div>
`;
}).join('') + '</div>';
} catch(e) {
el.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
// Repo files
async function loadRepoFiles(repoName) {
const section = document.getElementById('repo-files-section');
const el = document.getElementById('file-list');
const title = document.getElementById('repo-files-title');
section.style.display = 'block';
title.textContent = repoName;
el.innerHTML = '<div class="loading"><div class="spinner"></div> Loading files...</div>';
try {
const data = await fetch(`${API}/repos/${encodeURIComponent(repoName)}/files`).then(r => r.json());
window._allFiles = data.files.map(f => ({ path: f.path, lang: f.language, repo: repoName }));
renderFiles(window._allFiles);
section.scrollIntoView({behavior:'smooth'});
} catch(e) {
el.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
function closeRepoFiles() {
document.getElementById('repo-files-section').style.display = 'none';
}
function renderFiles(files) {
const el = document.getElementById('file-list');
if (files.length === 0) {
el.innerHTML = '<div class="empty">No files match filter</div>';
return;
}
el.innerHTML = `<div style="color:var(--dim);font-size:0.8rem;margin-bottom:0.5rem">${files.length} files</div>` +
files.map(f => {
const rel = relPath(f.path);
const link = repoLink(f.path, null, f.repo);
return `
<div class="file-item">
<div class="lang-dot" style="background:${langColor(f.lang)}"></div>
${link ? `<a class="file-path" href="${link}" target="_blank">${esc(rel)}</a>` : `<div class="file-path">${esc(rel)}</div>`}
</div>
`;
}).join('');
}
function filterFiles() {
if (!window._allFiles) return;
const q = document.getElementById('repo-filter').value.toLowerCase();
renderFiles(q ? window._allFiles.filter(f => f.path.toLowerCase().includes(q)) : window._allFiles);
}
// Beads search
async function doBeadsSearch() {
const q = document.getElementById('beads-q').value.trim();
if (!q) return;
const status = document.getElementById('beads-status').value;
const el = document.getElementById('beads-results');
el.innerHTML = '<div class="loading"><div class="spinner"></div> Searching beads...</div>';
try {
let url = `${API}/beads?q=${encodeURIComponent(q)}&limit=30&compact=false`;
if (status) url += `&status=${status}`;
const r = await fetch(url);
const data = await r.json();
if (data.error) {
el.innerHTML = `<div class="empty">${esc(data.error)}</div>`;
return;
}
if (!data.results || data.results.length === 0) {
el.innerHTML = '<div class="empty">No beads found</div>';
return;
}
el.innerHTML = `<div style="color:var(--dim);margin-bottom:0.75rem;font-size:0.85rem">${data.count} results</div>` +
data.results.map(b => `
<div class="bead">
<div>
<span class="bead-priority ${b.priority}">${b.priority}</span>
</div>
<div style="flex:1">
<div class="bead-title"><span class="bead-id">${esc(b.bead_id)}</span> ${esc(b.title)}</div>
<div class="bead-meta">${b.status} | ${b.issue_type || ''} ${b.assignee ? '| @' + b.assignee : ''} ${b.labels && b.labels.length ? '| ' + b.labels.join(', ') : ''}</div>
${b.snippet ? `<div class="result-preview" style="margin-top:0.5rem">${esc(b.snippet)}</div>` : ''}
</div>
</div>
`).join('');
} catch(e) {
el.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
// Hotspots
async function loadHotspots() {
const el = document.getElementById('hotspots-table');
el.innerHTML = '<div class="loading"><div class="spinner"></div> Analyzing hotspots...</div>';
try {
const data = await fetch(`${API}/hotspots?limit=30`).then(r => r.json());
window._hotspotsLoaded = true;
if (!data.hotspots || data.hotspots.length === 0) {
el.innerHTML = '<div class="empty">No hotspots found</div>';
return;
}
const maxScore = Math.max(...data.hotspots.map(h => h.score));
el.innerHTML = `<table>
<thead><tr><th>File</th><th>Language</th><th>Churn</th><th>Complexity</th><th>Score</th><th></th></tr></thead>
<tbody>${data.hotspots.map(h => `<tr>
<td class="mono" style="color:var(--accent)">${h.file}</td>
<td><span class="lang-item"><span class="lang-dot" style="background:${langColor(h.language)}"></span>${h.language}</span></td>
<td class="mono">${h.churn}</td>
<td class="mono">${h.complexity.toFixed(1)}</td>
<td class="mono">${h.score.toFixed(2)}</td>
<td><div style="width:60px;height:4px;background:var(--surface2);border-radius:2px"><div style="width:${(h.score/maxScore*100).toFixed(0)}%;height:100%;background:${h.score/maxScore > 0.7 ? 'var(--red)' : h.score/maxScore > 0.4 ? 'var(--amber)' : 'var(--green)'};border-radius:2px"></div></div></td>
</tr>`).join('')}</tbody>
</table>`;
} catch(e) {
el.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
// Feedback
async function loadFeedback() {
window._feedbackLoaded = true;
await Promise.all([loadFeedbackStats(), loadFeedbackList()]);
}
async function loadFeedbackStats() {
const el = document.getElementById('feedback-stats');
try {
const data = await fetch(API + '/feedback/stats').then(r => r.json());
const total = data.total_feedback || 0;
const inj = data.total_injections || 0;
const rate = inj > 0 ? Math.round(total / inj * 100) + '%' : 'N/A';
el.innerHTML = `
<div class="card"><div class="card-header">Injections</div><div class="stat-value accent">${inj}</div></div>
<div class="card"><div class="card-header">Feedback</div><div class="stat-value green">${total}</div><div style="color:var(--dim);font-size:0.8rem">${rate} coverage</div></div>
<div class="card"><div class="card-header">Useful</div><div class="stat-value" style="color:var(--green)">${data.useful || 0}</div></div>
<div class="card"><div class="card-header">Noise / Harmful</div><div class="stat-value" style="color:var(--amber)">${data.noise || 0}</div> <span style="color:var(--red);font-family:var(--mono);font-size:1rem">${data.harmful || 0}</span></div>
`;
} catch(e) {
el.innerHTML = `<div class="card"><div class="stat-value" style="color:var(--red)">Error</div><div style="color:var(--dim)">${e.message}</div></div>`;
}
}
async function loadFeedbackList() {
const el = document.getElementById('feedback-list');
el.innerHTML = '<div class="loading"><div class="spinner"></div> Loading feedback...</div>';
const rating = document.getElementById('feedback-rating').value;
const agent = document.getElementById('feedback-agent').value.trim();
let url = API + '/feedback?limit=50';
if (rating) url += '&rating=' + encodeURIComponent(rating);
if (agent) url += '&agent=' + encodeURIComponent(agent);
try {
const data = await fetch(url).then(r => r.json());
if (!data || data.length === 0) {
el.innerHTML = '<div class="empty">No feedback records yet</div>';
return;
}
const ratingColors = {useful:'var(--green)',noise:'var(--amber)',harmful:'var(--red)'};
el.innerHTML = `<div style="color:var(--dim);margin-bottom:0.75rem;font-size:0.85rem">${data.length} records</div>` +
data.map(f => {
const ts = f.timestamp ? f.timestamp.slice(0, 19).replace('T', ' ') : '?';
const color = ratingColors[f.rating] || 'var(--dim)';
return `
<div class="result" style="border-left:3px solid ${color};cursor:pointer" onclick="showInjectionDetail('${esc(f.injection_id)}')">
<div class="result-header">
<span class="result-path" style="font-size:0.8rem">${esc(f.injection_id)}</span>
<span class="result-type" style="background:${color}20;color:${color}">${f.rating}</span>
<span style="font-size:0.8rem;color:var(--dim)">${esc(f.agent)}</span>
<span class="result-score">${ts}</span>
</div>
${f.reason ? `<div style="font-size:0.85rem;color:var(--dim);margin-top:0.25rem">${esc(f.reason)}</div>` : ''}
</div>
`;
}).join('');
} catch(e) {
el.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
// -- Autocomplete --
const AC_FIELDS = ['repo','lang','type','file','path','group','tag'];
function acCheck() {
const val = searchInput.value;
const pos = searchInput.selectionStart;
const before = val.slice(0, pos);
// Match field:partial at cursor position
const m = before.match(/(-?)(repo|lang|language|type|kind|file|path|group|tag|label):([^\s]*)$/i);
if (!m) { acClose(); return; }
const field = m[2].toLowerCase();
const prefix = m[3].toLowerCase();
// Map aliases to canonical suggest field names
const fieldMap = {language:'lang',kind:'type',filename:'file',filepath:'path',label:'tag',repository:'repo'};
const suggestField = fieldMap[field] || field;
acFetch(suggestField, prefix);
}
async function acFetch(field, prefix) {
const key = field + ':' + prefix;
if (acCache[key]) { acShow(acCache[key], field, prefix); return; }
try {
const url = `${API}/suggest?field=${field}&q=${encodeURIComponent(prefix)}&limit=12`;
const data = await fetch(url).then(r => r.json());
const values = data.values || [];
acCache[key] = values;
acShow(values, field, prefix);
} catch(e) { acClose(); }
}
function acShow(values, field, prefix) {
if (values.length === 0) { acClose(); return; }
acItems = values;
acSelected = -1;
acDropdown.innerHTML = values.map((v, i) =>
`<div class="ac-item" data-idx="${i}" onmousedown="acAccept('${esc(v)}')">${esc(v)}</div>`
).join('');
acDropdown.classList.add('open');
}
function acNav(dir) {
acSelected += dir;
if (acSelected < 0) acSelected = acItems.length - 1;
if (acSelected >= acItems.length) acSelected = 0;
acDropdown.querySelectorAll('.ac-item').forEach((el, i) => {
el.classList.toggle('selected', i === acSelected);
});
}
function acAccept(value) {
const val = searchInput.value;
const pos = searchInput.selectionStart;
const before = val.slice(0, pos);
const after = val.slice(pos);
// Replace the partial value after the colon
const m = before.match(/(-?(?:repo|lang|language|type|kind|file|path|group|tag|label):)([^\s]*)$/i);
if (m) {
const prefix = before.slice(0, before.length - m[0].length) + m[1] + value;
searchInput.value = prefix + (after.startsWith(' ') ? after : ' ' + after);
searchInput.selectionStart = searchInput.selectionEnd = prefix.length + 1;
}
acClose();
updateChips();
searchInput.focus();
}
function acClose() {
acDropdown.classList.remove('open');
acItems = [];
acSelected = -1;
}
// -- Filter chips --
function updateChips() {
const el = document.getElementById('filter-chips');
const val = searchInput.value;
const chips = [];
// Parse filters from query
const re = /(-?)(repo|lang|language|type|kind|file|path|group|tag|label):("[^"]*"|[^\s]+)/gi;
let match;
while ((match = re.exec(val)) !== null) {
const neg = match[1] === '-';
const field = match[2];
const value = match[3].replace(/^"|"$/g, '');
chips.push({ field, value, neg, raw: match[0] });
}
if (chips.length === 0) { el.innerHTML = ''; return; }
el.innerHTML = chips.map(c =>
`<span class="filter-chip${c.neg ? ' negated' : ''}">${c.neg ? '-' : ''}${c.field}:${esc(c.value)}<span class="chip-x" onclick="removeChip('${esc(c.raw)}')">×</span></span>`
).join('');
}
function removeChip(raw) {
searchInput.value = searchInput.value.replace(raw, '').replace(/\s{2,}/g, ' ').trim();
updateChips();
searchInput.focus();
}
// -- Syntax help --
function toggleSyntaxHelp() {
document.getElementById('syntax-popup').classList.toggle('open');
}
// Close syntax help on outside click
document.addEventListener('click', e => {
const popup = document.getElementById('syntax-popup');
if (popup.classList.contains('open') && !e.target.closest('.syntax-help')) {
popup.classList.remove('open');
}
});
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function quipuEntityUrl(iri) { return `http://quipu.example/entity/${encodeURIComponent(iri)}`; }
// -- Injection detail overlay --
async function showInjectionDetail(injectionId) {
const overlay = document.getElementById('injection-overlay');
const content = document.getElementById('detail-content');
const title = document.getElementById('detail-title');
title.textContent = injectionId;
content.innerHTML = '<div class="loading"><div class="spinner"></div> Loading injection detail...</div>';
overlay.classList.add('open');
// Update deep link so this injection is shareable
history.replaceState(null, '', '#feedback?injection=' + encodeURIComponent(injectionId));
try {
const data = await fetch(`${API}/injections/${encodeURIComponent(injectionId)}`).then(r => {
if (!r.ok) throw new Error(r.status === 404 ? 'Injection record not found — may have been recorded before payload logging was enabled' : `HTTP ${r.status}`);
return r.json();
});
const ratingColors = {useful:'var(--green)',noise:'var(--amber)',harmful:'var(--red)'};
const ts = data.timestamp ? data.timestamp.slice(0, 19).replace('T', ' ') : '—';
let html = `
<div class="detail-section">
<h4>Injection Info</h4>
<dl class="detail-kv">
<dt>Timestamp</dt><dd>${esc(ts)}</dd>
<dt>Agent</dt><dd>${esc(data.agent || '—')}</dd>
<dt>Session</dt><dd>${esc(data.session_id || '—')}</dd>
<dt>Query</dt><dd>${esc(data.query || '—')}</dd>
<dt>Total Chunks</dt><dd>${data.total_chunks}</dd>
<dt>Budget Lines</dt><dd>${data.budget_lines}</dd>
</dl>
</div>`;
if (data.formatted_output) {
html += `
<div class="detail-section">
<h4>Injected Context (as agents see it)</h4>
<div class="card" style="max-height:400px;overflow-y:auto">
<pre style="font-family:var(--mono);font-size:0.75rem;color:var(--dim);white-space:pre-wrap;line-height:1.4;margin:0">${esc(data.formatted_output)}</pre>
</div>
</div>`;
}
if (data.files && data.files.length > 0) {
html += `
<div class="detail-section">
<h4>Files Injected (${data.files.length})</h4>
<div class="card" style="max-height:300px;overflow-y:auto">
${data.files.map(f => {
const link = repoLink(f);
return `<div class="detail-file">${link ? `<a href="${link}" target="_blank">${esc(relPath(f))}</a>` : esc(relPath(f))}</div>`;
}).join('')}
</div>
</div>`;
}
if (data.feedback && data.feedback.length > 0) {
html += `
<div class="detail-section">
<h4>Feedback (${data.feedback.length})</h4>
${data.feedback.map(f => {
const fts = f.timestamp ? f.timestamp.slice(0, 19).replace('T', ' ') : '?';
const color = ratingColors[f.rating] || 'var(--dim)';
return `<div class="result" style="border-left:3px solid ${color};margin-bottom:0.35rem">
<div class="result-header">
<span class="result-type" style="background:${color}20;color:${color}">${f.rating}</span>
<span style="font-size:0.8rem;color:var(--dim)">${esc(f.agent)}</span>
<span class="result-score">${fts}</span>
</div>
${f.reason ? `<div style="font-size:0.85rem;color:var(--dim);margin-top:0.25rem">${esc(f.reason)}</div>` : ''}
</div>`;
}).join('')}
</div>`;
}
content.innerHTML = html;
} catch(e) {
content.innerHTML = `<div class="empty" style="color:var(--red)">${esc(e.message)}</div>`;
}
}
function closeDetail() {
document.getElementById('injection-overlay').classList.remove('open');
// Restore plain page hash when closing overlay
const hash = location.hash;
if (hash.includes('?injection=')) history.replaceState(null, '', '#feedback');
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDetail(); });
// -- Context assembly --
async function doContext() {
const q = document.getElementById('context-q').value.trim();
if (!q) return;
const budget = document.getElementById('context-budget').value;
const limit = document.getElementById('context-limit').value;
const repo = document.getElementById('context-repo').value;
const el = document.getElementById('context-results');
el.innerHTML = '<div class="loading"><div class="spinner"></div> Assembling context...</div>';
updateDeepLink('context');
try {
let url = `${API}/context?q=${encodeURIComponent(q)}&budget=${budget}&limit=${limit}`;
if (repo) url += `&repo=${encodeURIComponent(repo)}`;
const data = await fetch(url).then(r => r.json());
if (data.error) { el.innerHTML = `<div class="empty" style="color:var(--red)">${esc(data.error)}</div>`; return; }
const files = data.files || [];
const summary = data.summary || {};
const budgetInfo = data.budget || {};
let html = `<div style="display:flex;gap:1.5rem;margin-bottom:1rem;flex-wrap:wrap">
<div class="card" style="flex:1;min-width:150px"><div class="card-header">Files</div><div class="stat-value accent">${files.length}</div></div>
<div class="card" style="flex:1;min-width:150px"><div class="card-header">Budget Used</div><div class="stat-value green">${budgetInfo.used_lines || 0}<span style="font-size:0.9rem;color:var(--dim)"> / ${budgetInfo.max_lines || budget}</span></div></div>
<div class="card" style="flex:1;min-width:150px"><div class="card-header">Repos</div><div class="stat-value">${summary.repo_count || new Set(files.map(f => f.repo)).size}</div></div>
</div>`;
if (files.length === 0) {
html += '<div class="empty">No files matched the query above the gate threshold</div>';
} else {
html += files.map((f, i) => {
const rel = relPath(f.path);
const chunks = f.chunks || [];
const totalLines = chunks.reduce((s, c) => s + (c.end_line - c.start_line + 1), 0);
const firstChunk = chunks[0];
const link = firstChunk ? repoLink(f.path, firstChunk.start_line, f.repo) : repoLink(f.path, null, f.repo);
return `
<div class="result">
<div class="result-header">
<span style="font-family:var(--mono);font-size:0.75rem;color:var(--dim);min-width:20px">${i + 1}</span>
${link ? `<a class="result-path" href="${link}" target="_blank">${esc(rel)}</a>` : `<span class="result-path">${esc(rel)}</span>`}
<span class="result-type">${esc(f.language)}</span>
<span class="result-type">${esc(f.relevance)}</span>
<span class="result-score">${totalLines} lines · ${chunks.length} chunks</span>
</div>
${f.coupled_to && f.coupled_to.length > 0 ? `<div style="font-size:0.75rem;color:var(--dim);margin-top:0.25rem">coupled to: ${f.coupled_to.map(c => esc(c)).join(', ')}</div>` : ''}
${chunks.map(c => `
<div style="display:flex;gap:0.5rem;align-items:center;margin-top:0.25rem;padding-left:1.5rem;font-size:0.8rem">
<span class="result-lines">L${c.start_line}-${c.end_line}</span>
${c.name ? `<span class="result-name" style="font-size:0.8rem">${esc(c.name)}</span>` : ''}
<span class="result-type">${esc(c.chunk_type)}</span>
${c.match_type ? `<span style="color:var(--dim);font-size:0.75rem">${esc(c.match_type)}</span>` : ''}
</div>
`).join('')}
</div>`;
}).join('');
}
el.innerHTML = html;
// Knowledge context: show entity graph if quipu is configured
if (_quipuEndpoint && typeof customElements !== 'undefined' && customElements.get('quipu-graph')) {
fetchContextKnowledge(q, el);
}
} catch(e) {
el.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
async function fetchContextKnowledge(query, container) {
try {
const resp = await fetch(_quipuEndpoint.replace(/\/$/, '') + '/spotlight', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: query, confidence: 0.3})
});
const data = await resp.json();
const annotations = data.annotations || [];
if (annotations.length === 0) return;
let section = '<div style="margin-top:1.5rem;border-top:1px solid var(--border);padding-top:1rem">';
section += `<h3 style="color:var(--accent2);font-size:0.9rem;margin-bottom:0.5rem">Knowledge Context (${annotations.length} entities)</h3>`;
section += '<div class="entity-badges" style="margin-bottom:0.75rem">' + annotations.map(a =>
`<a class="entity-badge" href="${esc(quipuEntityUrl(a.iri))}" target="_blank"><span class="badge-type">${esc(a.entity_type)}</span> ${esc(a.surface)}</a>`
).join('') + '</div>';
section += '<quipu-graph endpoint="' + esc(_quipuEndpoint) + '" query="SELECT ?s ?p ?o WHERE { ?s ?p ?o . ?s rdfs:label ?l . FILTER(regex(?l, "' + esc(query) + '", "i")) } LIMIT 50" height="300px"></quipu-graph>';
section += '</div>';
container.insertAdjacentHTML('beforeend', section);
} catch(e) { /* graceful degradation */ }
}
document.getElementById('context-q').addEventListener('keydown', e => { if (e.key === 'Enter') doContext(); });
// Populate context repo dropdown when repos are loaded
async function populateContextRepos() {
try {
const data = await fetch(API + '/repos').then(r => r.json());
const select = document.getElementById('context-repo');
(data.repos || []).forEach(r => {
const opt = document.createElement('option');
opt.value = r.name;
opt.textContent = r.name;
select.appendChild(opt);
});
} catch(e) {}
}
// -- Tags browser --
// -- Bundles --
async function loadBundles() {
window._bundlesLoaded = true;
const grid = document.getElementById('bundles-grid');
const countEl = document.getElementById('bundles-count');
const repoFilter = document.getElementById('bundles-repo-filter').value;
grid.innerHTML = '<div class="loading"><div class="spinner"></div> Loading bundles...</div>';
try {
let url = API + '/bundles';
if (repoFilter) url += '?repo=' + encodeURIComponent(repoFilter);
const data = await fetch(url).then(r => r.json());
const bundles = data.bundles || [];
countEl.textContent = bundles.length + ' bundle' + (bundles.length !== 1 ? 's' : '');
// Populate repo filter if first load
if (!window._bundlesReposPopulated) {
window._bundlesReposPopulated = true;
const repos = new Set();
bundles.forEach(b => (b.repos || []).forEach(r => repos.add(r)));
const sel = document.getElementById('bundles-repo-filter');
repos.forEach(r => {
const opt = document.createElement('option');
opt.value = r; opt.textContent = r;
sel.appendChild(opt);
});
}
if (bundles.length === 0) {
grid.innerHTML = '<div class="empty">No bundles defined. Add [[bundles]] entries to tags.toml</div>';
return;
}
// Group by parent (tree structure)
const roots = bundles.filter(b => !b.parent);
const childMap = {};
bundles.filter(b => b.parent).forEach(b => {
if (!childMap[b.parent]) childMap[b.parent] = [];
childMap[b.parent].push(b);
});
grid.innerHTML = roots.map(b => {
const kids = childMap[b.name] || [];
return `<div class="card" style="cursor:pointer" onclick="showBundleDetail('${esc(b.slug)}')">
<div class="card-header" style="display:flex;justify-content:space-between">
<span>${esc(b.name)}</span>
<span style="font-family:var(--mono);font-size:0.75rem">${b.file_count}f ${b.ref_count}r ${b.doc_count}d</span>
</div>
<div style="color:var(--dim);font-size:0.85rem;margin-bottom:0.5rem">${esc(b.description)}</div>
${b.keywords.length > 0 ? `<div style="display:flex;flex-wrap:wrap;gap:0.25rem;margin-bottom:0.5rem">${b.keywords.slice(0,5).map(k => `<span style="background:var(--surface2);padding:0.1rem 0.4rem;border-radius:3px;font-size:0.75rem;font-family:var(--mono);color:var(--accent)">${esc(k)}</span>`).join('')}${b.keywords.length > 5 ? `<span style="font-size:0.75rem;color:var(--dim)">+${b.keywords.length - 5}</span>` : ''}</div>` : ''}
${b.repos.length > 0 ? `<div style="font-size:0.75rem;color:var(--dim)">repos: ${b.repos.map(r => esc(r)).join(', ')}</div>` : ''}
${kids.length > 0 ? `<div style="margin-top:0.5rem;border-top:1px solid var(--border);padding-top:0.5rem">${kids.map((k,i) => `<div style="display:flex;justify-content:space-between;align-items:center;padding:0.15rem 0;cursor:pointer" onclick="event.stopPropagation();showBundleDetail('${esc(k.slug)}')"><span style="color:var(--accent);font-size:0.8rem">${i === kids.length-1 ? '└──' : '├──'} ${esc(k.name.split('/').pop())}</span><span style="font-family:var(--mono);font-size:0.7rem;color:var(--dim)">${k.file_count}f ${k.ref_count}r</span></div>`).join('')}</div>` : ''}
</div>`;
}).join('');
} catch(e) {
grid.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
async function showBundleDetail(slug) {
const section = document.getElementById('bundle-detail-section');
const title = document.getElementById('bundle-detail-title');
const content = document.getElementById('bundle-detail-content');
section.style.display = 'block';
content.innerHTML = '<div class="loading"><div class="spinner"></div> Loading bundle...</div>';
try {
const data = await fetch(`${API}/bundles/${encodeURIComponent(slug)}`).then(r => r.json());
title.innerHTML = `<span style="font-family:var(--mono);color:var(--accent)">${esc(data.name)}</span> <span style="color:var(--dim);font-size:0.85rem">— ${esc(data.description)}</span>`;
let html = '';
// Keywords
if (data.keywords && data.keywords.length > 0) {
html += `<div style="margin-bottom:1rem"><h3 style="margin-bottom:0.4rem">Keywords</h3><div style="display:flex;flex-wrap:wrap;gap:0.3rem">${data.keywords.map(k => `<span style="background:var(--surface2);padding:0.15rem 0.5rem;border-radius:3px;font-size:0.8rem;font-family:var(--mono);color:var(--accent)">${esc(k)}</span>`).join('')}</div></div>`;
}
// Files
if (data.files && data.files.length > 0) {
html += `<div style="margin-bottom:1rem"><h3 style="margin-bottom:0.4rem">Files (${data.files.length})</h3>${data.files.map(f => {
const link = repoLink(f);
return `<div class="file-item">${link ? `<a class="file-path" href="${link}" target="_blank">${esc(f)}</a>` : `<span class="file-path">${esc(f)}</span>`}</div>`;
}).join('')}</div>`;
}
// Refs
if (data.refs && data.refs.length > 0) {
html += `<div style="margin-bottom:1rem"><h3 style="margin-bottom:0.4rem">Refs (${data.refs.length})</h3>${data.refs.map(r => {
const target = r.target ? ` <span style="color:var(--green);font-size:0.8rem">${esc(r.target)}</span>` : '';
const repo = r.repo ? ` <span style="color:var(--dim);font-size:0.75rem">[${esc(r.repo)}]</span>` : '';
return `<div style="padding:0.15rem 0"><span style="font-family:var(--mono);font-size:0.85rem;color:var(--text)">${esc(r.raw)}</span>${target}${repo}</div>`;
}).join('')}</div>`;
}
// Docs
if (data.docs && data.docs.length > 0) {
html += `<div style="margin-bottom:1rem"><h3 style="margin-bottom:0.4rem">Docs (${data.docs.length})</h3>${data.docs.map(d => `<div class="file-item"><span class="file-path">${esc(d)}</span></div>`).join('')}</div>`;
}
// Includes
if (data.includes && data.includes.length > 0) {
html += `<div style="margin-bottom:1rem"><h3 style="margin-bottom:0.4rem">Includes</h3>${data.includes.map(i => `<div style="padding:0.15rem 0"><span style="color:var(--accent);font-family:var(--mono);font-size:0.85rem;cursor:pointer" onclick="showBundleDetail('${esc(i)}')">${esc(i)}</span></div>`).join('')}</div>`;
}
// Children
if (data.children && data.children.length > 0) {
html += `<div style="margin-bottom:1rem"><h3 style="margin-bottom:0.4rem">Sub-bundles (${data.children.length})</h3>${data.children.map(c => `<div style="padding:0.2rem 0;display:flex;justify-content:space-between"><span style="color:var(--accent);font-family:var(--mono);font-size:0.85rem;cursor:pointer" onclick="showBundleDetail('${esc(c.slug)}')">${esc(c.name)}</span><span style="color:var(--dim);font-size:0.8rem">${esc(c.description)}</span></div>`).join('')}</div>`;
}
// Repos
if (data.repos && data.repos.length > 0) {
html += `<div><h3 style="margin-bottom:0.4rem">Repos</h3><span style="font-size:0.85rem;color:var(--dim)">${data.repos.map(r => esc(r)).join(', ')}</span></div>`;
}
// Member files (expanded)
if (data.member_files && data.member_files.length > 0) {
html += `<details style="margin-top:1rem"><summary style="cursor:pointer;color:var(--dim);font-size:0.85rem">All member files (${data.member_files.length})</summary><div style="margin-top:0.5rem">${data.member_files.map(f => {
const link = repoLink(f);
return `<div class="file-item">${link ? `<a class="file-path" href="${link}" target="_blank">${esc(f)}</a>` : `<span class="file-path">${esc(f)}</span>`}</div>`;
}).join('')}</div></details>`;
}
content.innerHTML = html || '<div class="empty">Empty bundle</div>';
section.scrollIntoView({behavior:'smooth'});
} catch(e) {
content.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
function closeBundleDetail() {
document.getElementById('bundle-detail-section').style.display = 'none';
}
async function loadTags() {
window._tagsLoaded = true;
const grid = document.getElementById('tags-grid');
const summary = document.getElementById('tags-summary');
grid.innerHTML = '<div class="loading"><div class="spinner"></div> Loading tags...</div>';
try {
const data = await fetch(API + '/tags').then(r => r.json());
const tags = data.tags || [];
const tagged = data.tagged_chunks || 0;
const untagged = data.untagged_chunks || 0;
const total = tagged + untagged;
const pct = total > 0 ? Math.round(tagged / total * 100) : 0;
summary.innerHTML = `<div style="display:flex;gap:1.5rem;flex-wrap:wrap">
<div class="card" style="flex:1;min-width:150px"><div class="card-header">Unique Tags</div><div class="stat-value accent">${tags.length}</div></div>
<div class="card" style="flex:1;min-width:150px"><div class="card-header">Tagged Chunks</div><div class="stat-value green">${tagged.toLocaleString()}</div></div>
<div class="card" style="flex:1;min-width:150px"><div class="card-header">Coverage</div><div class="stat-value amber">${pct}%</div><div style="color:var(--dim);font-size:0.8rem">${untagged.toLocaleString()} untagged</div></div>
</div>`;
if (tags.length === 0) {
grid.innerHTML = '<div class="empty">No tags found</div>';
return;
}
// Group tags by prefix (type:, domain:, scope:, etc.)
const groups = {};
tags.forEach(t => {
const colonIdx = t.tag.indexOf(':');
const prefix = colonIdx > 0 ? t.tag.slice(0, colonIdx) : 'other';
if (!groups[prefix]) groups[prefix] = [];
groups[prefix].push(t);
});
grid.innerHTML = Object.entries(groups).sort((a, b) => {
const order = ['type', 'domain', 'scope', 'role', 'other'];
return (order.indexOf(a[0]) === -1 ? 99 : order.indexOf(a[0])) - (order.indexOf(b[0]) === -1 ? 99 : order.indexOf(b[0]));
}).map(([prefix, items]) => `
<div class="card">
<div class="card-header">${esc(prefix)} (${items.length})</div>
${items.sort((a,b) => b.count - a.count).map(t => `
<div style="display:flex;justify-content:space-between;align-items:center;padding:0.2rem 0;cursor:pointer" onclick="loadTagFiles('${esc(t.tag)}')">
<span style="font-family:var(--mono);font-size:0.8rem;color:var(--accent)">${esc(t.tag)}</span>
<span style="font-family:var(--mono);font-size:0.75rem;color:var(--dim)">${t.count.toLocaleString()}</span>
</div>
`).join('')}
</div>
`).join('');
} catch(e) {
grid.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
async function loadTagFiles(tagName) {
const section = document.getElementById('tag-files-section');
const el = document.getElementById('tag-file-list');
const title = document.getElementById('tag-files-title');
section.style.display = 'block';
title.textContent = tagName;
el.innerHTML = '<div class="loading"><div class="spinner"></div> Loading files...</div>';
try {
const data = await fetch(`${API}/tags?tag=${encodeURIComponent(tagName)}`).then(r => r.json());
const files = data.files || [];
if (files.length === 0) {
el.innerHTML = '<div class="empty">No files with this tag</div>';
} else {
el.innerHTML = `<div style="color:var(--dim);font-size:0.8rem;margin-bottom:0.5rem">${files.length} files</div>` +
files.map(f => {
const rel = relPath(f);
const link = repoLink(f);
return `<div class="file-item">${link ? `<a class="file-path" href="${link}" target="_blank">${esc(rel)}</a>` : `<div class="file-path">${esc(rel)}</div>`}</div>`;
}).join('');
}
section.scrollIntoView({behavior:'smooth'});
} catch(e) {
el.innerHTML = `<div class="empty" style="color:var(--red)">Error: ${e.message}</div>`;
}
}
function closeTagFiles() {
document.getElementById('tag-files-section').style.display = 'none';
}
// -- Home dashboard --
function homeSearch() {
const q = document.getElementById('home-search-q').value.trim();
if (!q) return;
document.getElementById('search-q').value = q;
switchTab('search');
updateChips();
doSearch();
}
document.getElementById('home-search-q').addEventListener('keydown', e => { if (e.key === 'Enter') homeSearch(); });
function switchTab(page, updateHash = true) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
const tab = document.querySelector(`.tab[data-page="${page}"]`);
if (tab) tab.classList.add('active');
document.getElementById('page-' + page).classList.add('active');
if (page === 'repos' && !window._reposLoaded) loadRepos();
if (page === 'tags' && !window._tagsLoaded) loadTags();
if (page === 'bundles' && !window._bundlesLoaded) loadBundles();
if (page === 'hotspots' && !window._hotspotsLoaded) loadHotspots();
if (page === 'feedback' && !window._feedbackLoaded) loadFeedback();
if (updateHash) updateDeepLink(page);
}
// -- Deep linking via URL hash --
function updateDeepLink(page) {
let hash = '#' + page;
if (page === 'search') {
const q = document.getElementById('search-q').value.trim();
const mode = document.getElementById('search-mode').value;
if (q) {
const params = new URLSearchParams();
params.set('q', q);
if (mode && mode !== 'auto') params.set('mode', mode);
hash += '?' + params.toString();
}
} else if (page === 'context') {
const q = document.getElementById('context-q').value.trim();
if (q) hash += '?' + new URLSearchParams({q}).toString();
} else if (page === 'beads') {
const q = document.getElementById('beads-q') && document.getElementById('beads-q').value.trim();
if (q) hash += '?' + new URLSearchParams({q}).toString();
}
history.replaceState(null, '', hash);
}
function restoreFromHash() {
const hash = location.hash.slice(1); // remove #
if (!hash) return false;
const [page, queryString] = hash.split('?');
const validPages = ['home','search','context','repos','hotspots','tags','bundles','feedback','beads','knowledge','status'];
if (!validPages.includes(page)) return false;
switchTab(page, false);
if (queryString) {
const params = new URLSearchParams(queryString);
if (page === 'search' && params.get('q')) {
document.getElementById('search-q').value = params.get('q');
if (params.get('mode')) document.getElementById('search-mode').value = params.get('mode');
if (typeof updateChips === 'function') updateChips();
doSearch();
} else if (page === 'context' && params.get('q')) {
document.getElementById('context-q').value = params.get('q');
doContext();
} else if (page === 'feedback' && params.get('injection')) {
// Deep link to specific injection detail
loadFeedback().then(() => showInjectionDetail(params.get('injection')));
} else if (page === 'beads' && params.get('q')) {
const el = document.getElementById('beads-q');
if (el) { el.value = params.get('q'); }
if (typeof doBeadsSearch === 'function') doBeadsSearch();
} else if (page === 'bundles' && params.get('name')) {
// Deep link to specific bundle detail
if (typeof showBundleDetail === 'function') showBundleDetail(params.get('name'));
}
}
return true;
}
window.addEventListener('hashchange', () => restoreFromHash());
async function loadHome() {
// Stats row
try {
const [status, fbStats] = await Promise.all([
fetch(API + '/status').then(r => r.json()),
fetch(API + '/feedback/stats').then(r => r.json()).catch(() => ({}))
]);
_sources = status.sources || null;
if (status.repo_path_prefix) _repoPathPrefix = status.repo_path_prefix.replace(/\/?$/, '/');
const ix = status.index;
const repoCount = ix.languages ? new Set() : 0;
const totalInj = fbStats.total_injections || 0;
const totalFb = fbStats.total_feedback || 0;
const useful = fbStats.useful || 0;
const fbRate = totalFb > 0 ? Math.round(useful / totalFb * 100) : 0;
document.getElementById('home-stats').innerHTML = `
<div class="card"><div class="card-header">Files Indexed</div><div class="stat-value accent">${ix.total_files.toLocaleString()}</div></div>
<div class="card"><div class="card-header">Chunks</div><div class="stat-value green">${ix.total_chunks.toLocaleString()}</div></div>
<div class="card"><div class="card-header">Injections</div><div class="stat-value">${totalInj.toLocaleString()}</div></div>
<div class="card"><div class="card-header">Useful Rate</div><div class="stat-value amber">${totalFb > 0 ? fbRate + '%' : 'N/A'}</div><div style="color:var(--dim);font-size:0.75rem">${totalFb} ratings</div></div>
`;
document.getElementById('pulse').style.background = 'var(--green)';
} catch(e) {
document.getElementById('pulse').style.background = 'var(--red)';
document.getElementById('home-stats').innerHTML = '<div class="card"><div class="stat-value" style="color:var(--red)">Offline</div></div>';
}
// Recent injections
try {
const data = await fetch(API + '/feedback?limit=8').then(r => r.json());
const el = document.getElementById('home-injections');
if (!data || data.length === 0) {
el.innerHTML = '<div class="empty">No injection feedback yet</div>';
} else {
const ratingColors = {useful:'var(--green)',noise:'var(--amber)',harmful:'var(--red)'};
el.innerHTML = data.map(f => {
const ts = f.timestamp ? f.timestamp.slice(0, 16).replace('T', ' ') : '?';
const color = ratingColors[f.rating] || 'var(--dim)';
return `<div class="result" style="border-left:3px solid ${color};padding:0.5rem 0.75rem;margin-bottom:0.35rem;cursor:pointer" onclick="switchTab('feedback')">
<div style="display:flex;align-items:center;gap:0.5rem">
<span class="result-type" style="background:${color}20;color:${color};font-size:0.7rem">${f.rating}</span>
<span style="font-size:0.8rem;color:var(--dim)">${esc(f.agent || '')}</span>
<span style="font-size:0.75rem;color:var(--dim);margin-left:auto">${ts}</span>
</div>
${f.reason ? `<div style="font-size:0.8rem;color:var(--dim);margin-top:0.15rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(f.reason)}</div>` : ''}
</div>`;
}).join('');
}
} catch(e) {
document.getElementById('home-injections').innerHTML = '<div class="empty" style="font-size:0.85rem">Feedback data unavailable</div>';
}
// Top repos (compact)
try {
const data = await fetch(API + '/repos').then(r => r.json());
const el = document.getElementById('home-repos');
if (!data.repos || data.repos.length === 0) {
el.innerHTML = '<div class="empty">No repositories indexed</div>';
} else {
const sorted = data.repos.sort((a, b) => b.chunk_count - a.chunk_count);
el.innerHTML = '<div style="display:flex;flex-wrap:wrap;gap:0.5rem">' + sorted.map(repo => {
const topLang = repo.languages[0];
return `<div class="card" style="cursor:pointer;padding:0.5rem 0.75rem;display:flex;align-items:center;gap:0.5rem" onclick="switchTab('repos')">
${topLang ? `<span class="lang-dot" style="background:${langColor(topLang.language)}"></span>` : ''}
<span style="font-family:var(--mono);font-size:0.85rem;color:var(--accent)">${esc(repo.name)}</span>
<span style="font-size:0.75rem;color:var(--dim)">${repo.chunk_count.toLocaleString()}</span>
</div>`;
}).join('') + '</div>';
}
} catch(e) {
document.getElementById('home-repos').innerHTML = '';
}
}
// Init
loadHome();
loadStatus();
populateContextRepos();
// Restore state from URL hash (deep links)
restoreFromHash();
</script>
</body>
</html>