From 8f9a6553909084560648070ce23ac5203b2a745b Mon Sep 17 00:00:00 2001 From: SageHaven Date: Thu, 13 Aug 2026 15:00:00 +0100 Subject: [PATCH] Bounty #1524: feat(beacon): add agent search and camera navigation --- site/beacon/data.js | 60 +++++++++++++++ site/beacon/index.html | 18 ++++- site/beacon/styles.css | 113 ++++++++++++++++++++++++++++ site/beacon/ui.js | 146 ++++++++++++++++++++++++++++++++++++- tests/test_beacon_atlas.py | 78 ++++++++++++++++++++ 5 files changed, 413 insertions(+), 2 deletions(-) diff --git a/site/beacon/data.js b/site/beacon/data.js index e8da82227..21abc35a9 100644 --- a/site/beacon/data.js +++ b/site/beacon/data.js @@ -803,6 +803,66 @@ export const CONTRACT_STATE_OPACITY = { // ============================================================ export const REGION_RADIUS = 120; +/** + * Find agents by identity and visible Atlas metadata. + * + * Every whitespace-delimited query token must match somewhere in the agent's + * searchable fields. Exact identities and name-prefix matches rank ahead of + * broader role, capability, provider, source, status, or city matches. + */ +export function searchAgents(query, agents = AGENTS, limit = 8) { + const normalizedQuery = String(query ?? '').trim().toLowerCase(); + if (!normalizedQuery) return []; + + const tokens = normalizedQuery.split(/\s+/).filter(Boolean); + const safeLimit = Number.isFinite(Number(limit)) + ? Math.min(20, Math.max(1, Math.trunc(Number(limit)))) + : 8; + + return agents + .map((agent, index) => { + const city = CITIES.find(candidate => candidate.id === agent.city); + const name = String(agent.name ?? '').toLowerCase(); + const id = String(agent.id ?? '').toLowerCase(); + const beacon = String(agent.beacon ?? agent.relay_agent_id ?? '').toLowerCase(); + const cityName = String(city?.name ?? '').toLowerCase(); + const searchable = [ + name, + id, + beacon, + agent.role, + agent.provider, + agent.model_id, + agent.status, + agent.grade, + agent.city, + cityName, + ...(Array.isArray(agent.capabilities) ? agent.capabilities : []), + ...(Array.isArray(agent.sources) ? agent.sources : []), + ].map(value => String(value ?? '').toLowerCase()).join(' '); + + if (!tokens.every(token => searchable.includes(token))) return null; + + let rank = 6; + if (id === normalizedQuery || beacon === normalizedQuery || name === normalizedQuery) rank = 0; + else if (name.startsWith(normalizedQuery)) rank = 1; + else if (id.startsWith(normalizedQuery) || beacon.startsWith(normalizedQuery)) rank = 2; + else if (name.includes(normalizedQuery)) rank = 3; + else if (cityName.startsWith(normalizedQuery)) rank = 4; + else if (cityName.includes(normalizedQuery)) rank = 5; + + return { agent, index, rank, name }; + }) + .filter(Boolean) + .sort((left, right) => ( + left.rank - right.rank + || left.name.localeCompare(right.name) + || left.index - right.index + )) + .slice(0, safeLimit) + .map(result => result.agent); +} + export function regionPosition(region) { const rad = (region.angle * Math.PI) / 180; return { x: Math.cos(rad) * REGION_RADIUS, z: Math.sin(rad) * REGION_RADIUS }; diff --git a/site/beacon/index.html b/site/beacon/index.html index 25415c738..b5937aebb 100644 --- a/site/beacon/index.html +++ b/site/beacon/index.html @@ -68,12 +68,28 @@
[NEW CONTRACT]
[BOUNTIES]
+
DRAG rotate | SCROLL zoom | RIGHT-DRAG pan
- CLICK agent/city for details | ESC close + CLICK agent/city for details | / search | ESC close
diff --git a/site/beacon/styles.css b/site/beacon/styles.css index 05b060fc1..d93d2820d 100644 --- a/site/beacon/styles.css +++ b/site/beacon/styles.css @@ -90,6 +90,115 @@ html, body { color: var(--green); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.agent-search { + position: relative; + width: min(320px, calc(100vw - 32px)); + margin-top: 10px; + pointer-events: auto; + font-family: var(--font-mono); + text-shadow: none; +} + +.agent-search-input { + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border: 1px solid var(--green-dim); + border-radius: 3px; + outline: none; + background: rgba(0, 12, 0, 0.92); + color: var(--green); + caret-color: var(--amber); + font: 12px/1.4 var(--font-mono); + letter-spacing: 1px; + box-shadow: inset 0 0 12px rgba(0, 255, 0, 0.03); +} + +.agent-search-input::placeholder { + color: var(--text-dim); + opacity: 0.8; +} + +.agent-search-input:focus { + border-color: var(--green); + box-shadow: 0 0 10px rgba(0, 255, 0, 0.14), inset 0 0 12px rgba(0, 255, 0, 0.05); +} + +.agent-search-results { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + max-height: 280px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 3px; + background: rgba(0, 10, 0, 0.97); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.55), 0 0 12px rgba(0, 255, 0, 0.06); +} + +.agent-search-results[hidden] { + display: none; +} + +.agent-search-result { + display: flex; + width: 100%; + padding: 8px 10px; + border: 0; + border-bottom: 1px solid rgba(0, 255, 0, 0.08); + background: transparent; + color: var(--green); + cursor: pointer; + font-family: var(--font-mono); + text-align: left; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.agent-search-result:last-child { + border-bottom: 0; +} + +.agent-search-result:hover, +.agent-search-result.active { + background: rgba(0, 255, 0, 0.09); + color: #8cff8c; +} + +.agent-search-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.agent-search-meta, +.agent-search-empty { + color: var(--text-dim); + font-size: 10px; + white-space: nowrap; +} + +.agent-search-empty { + padding: 9px 10px; + letter-spacing: 1px; +} + /* --- Controls hint (bottom-left) --- */ .controls-hint { position: fixed; @@ -753,6 +862,10 @@ html, body { .hud-title { font-size: 22px; } .hud-stats { font-size: 14px; } + .agent-search { + width: min(300px, calc(100vw - 16px)); + } + .controls-hint { display: none; } } diff --git a/site/beacon/ui.js b/site/beacon/ui.js index bcc27d986..e3ffa5898 100644 --- a/site/beacon/ui.js +++ b/site/beacon/ui.js @@ -4,7 +4,7 @@ import { AGENTS, CITIES, CONTRACTS, CALIBRATIONS, - GRADE_COLORS, cityRegion, addContract, getProviderColor, resolveAgentId, + GRADE_COLORS, cityRegion, addContract, getProviderColor, resolveAgentId, searchAgents, } from './data.js'; import { lerpCameraTo, resetCamera, setClickHandler, setMissHandler, setHoverHandler } from './scene.js'; import { getAgentPosition, highlightAgent } from './agents.js'; @@ -20,6 +20,10 @@ let panel, panelContent, panelPath, tooltip; let selectedAgent = null; let selectedCity = null; let hoveredId = null; +let searchInput = null; +let searchResults = null; +let searchMatches = []; +let activeSearchIndex = -1; function escapeHtml(value) { return String(value ?? '') @@ -111,6 +115,7 @@ export function initUI() { // HUD stats updateHUD(); + initAgentSearch(); // Click handlers setClickHandler(onObjectClick); @@ -137,6 +142,145 @@ export function initUI() { window.addEventListener('hashchange', handleDeepLink); } +function initAgentSearch() { + searchInput = document.getElementById('agent-search-input'); + searchResults = document.getElementById('agent-search-results'); + if (!searchInput || !searchResults) return; + + const refreshResults = () => { + const query = searchInput.value.trim(); + searchMatches = searchAgents(query); + activeSearchIndex = searchMatches.length > 0 ? 0 : -1; + renderAgentSearchResults(query); + }; + + searchInput.addEventListener('input', refreshResults); + searchInput.addEventListener('focus', refreshResults); + searchInput.addEventListener('keydown', event => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (searchMatches.length === 0) return; + event.preventDefault(); + const direction = event.key === 'ArrowDown' ? 1 : -1; + const nextIndex = (activeSearchIndex + direction + searchMatches.length) % searchMatches.length; + setActiveSearchIndex(nextIndex); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + chooseSearchResult(activeSearchIndex); + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + searchInput.value = ''; + searchMatches = []; + hideAgentSearchResults(); + searchInput.blur(); + } + }); + + document.addEventListener('keydown', event => { + const target = event.target; + const isTyping = target instanceof HTMLElement + && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)); + if (event.key === '/' && !isTyping && !event.ctrlKey && !event.metaKey && !event.altKey) { + event.preventDefault(); + searchInput.focus(); + } + }); + + document.addEventListener('pointerdown', event => { + const target = event.target; + if (!(target instanceof Element) || !target.closest('.agent-search')) { + hideAgentSearchResults(); + } + }); +} + +function renderAgentSearchResults(query) { + searchResults.replaceChildren(); + searchInput.setAttribute('aria-expanded', query ? 'true' : 'false'); + + if (!query) { + hideAgentSearchResults(); + return; + } + + searchResults.hidden = false; + + if (searchMatches.length === 0) { + const empty = document.createElement('div'); + empty.className = 'agent-search-empty'; + empty.textContent = 'NO MATCHING AGENTS'; + searchResults.append(empty); + return; + } + + searchMatches.forEach((agent, index) => { + const city = CITIES.find(candidate => candidate.id === agent.city); + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'agent-search-result'; + option.id = `agent-search-result-${index}`; + option.dataset.index = String(index); + option.setAttribute('aria-current', index === activeSearchIndex ? 'true' : 'false'); + + const name = document.createElement('span'); + name.className = 'agent-search-name'; + name.textContent = agent.name || agent.id; + + const meta = document.createElement('span'); + meta.className = 'agent-search-meta'; + const status = agent.status ? ` ยท ${String(agent.status).toUpperCase()}` : ''; + meta.textContent = `${city?.name || agent.city || 'Unknown city'}${status}`; + + option.append(name, meta); + option.addEventListener('pointerenter', () => setActiveSearchIndex(index)); + option.addEventListener('click', () => chooseSearchResult(index)); + searchResults.append(option); + }); + + setActiveSearchIndex(activeSearchIndex, false); +} + +function setActiveSearchIndex(index, scroll = true) { + activeSearchIndex = index; + const options = [...searchResults.querySelectorAll('.agent-search-result')]; + options.forEach((option, optionIndex) => { + const active = optionIndex === activeSearchIndex; + option.classList.toggle('active', active); + option.setAttribute('aria-current', active ? 'true' : 'false'); + }); + + const activeOption = options[activeSearchIndex]; + if (activeOption) { + searchInput.setAttribute('aria-activedescendant', activeOption.id); + if (scroll) activeOption.scrollIntoView({ block: 'nearest' }); + } else { + searchInput.removeAttribute('aria-activedescendant'); + } +} + +function chooseSearchResult(index) { + const agent = searchMatches[index]; + if (!agent) return; + searchInput.value = agent.name || agent.id; + hideAgentSearchResults(); + searchInput.blur(); + selectAgent(agent.id); +} + +function hideAgentSearchResults() { + if (!searchResults || !searchInput) return; + searchResults.hidden = true; + searchInput.setAttribute('aria-expanded', 'false'); + searchInput.removeAttribute('aria-activedescendant'); + activeSearchIndex = -1; +} + function updateHUD() { const el = document.querySelector('.hud-stats'); if (el) { diff --git a/tests/test_beacon_atlas.py b/tests/test_beacon_atlas.py index 034ae1e53..025b05a8f 100644 --- a/tests/test_beacon_atlas.py +++ b/tests/test_beacon_atlas.py @@ -8,9 +8,13 @@ import time import sys import os +import pathlib +import subprocess +import textwrap # Add parent directory to path for imports sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] class TestBeaconAtlasAPI(unittest.TestCase): @@ -231,6 +235,79 @@ def test_state_opacity_mapping(self): self.assertLessEqual(opacity, 1.0, "Opacity must be <= 1") +class TestBeaconAtlasAgentSearch(unittest.TestCase): + """Test the browser-independent agent search/filter behavior.""" + + def test_searches_identity_metadata_and_city_with_stable_ranking(self): + script = textwrap.dedent( + """ + import { searchAgents } from './site/beacon/data.js'; + + const agents = [ + { + id: 'bcn_sophia_elya', name: 'Sophia Elya', role: 'Inference Orchestrator', + city: 'compiler_heights', provider: 'elyan', status: 'active', + capabilities: ['coding', 'automation'], sources: ['beacon'], + }, + { + id: 'bcn_doc_clint', name: 'Doc Clint Otis', role: 'Research Physician', + city: 'tensor_valley', provider: 'anthropic', status: 'active', + capabilities: ['research', 'documentation'], sources: ['beacon', 'bottube'], + }, + { + id: 'bcn_silent_builder', name: 'Builder Zero', role: 'Code Agent', + city: 'compiler_heights', provider: 'openai', status: 'silent', + capabilities: ['coding'], sources: ['beacon'], + }, + ]; + + const result = { + exact: searchAgents('bcn_sophia_elya', agents).map(agent => agent.id), + name: searchAgents('sophia', agents).map(agent => agent.id), + metadata: searchAgents('anthropic active', agents).map(agent => agent.id), + cityAndRole: searchAgents('tensor research', agents).map(agent => agent.id), + bounded: searchAgents('compiler', agents, 1).map(agent => agent.id), + empty: searchAgents(' ', agents).map(agent => agent.id), + missing: searchAgents('no-such-agent', agents).map(agent => agent.id), + }; + console.log(JSON.stringify(result)); + """ + ) + completed = subprocess.run( + [ + "node", + "--experimental-default-type=module", + "--input-type=module", + "-e", + script, + ], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + result = json.loads(completed.stdout) + + self.assertEqual(result["exact"], ["bcn_sophia_elya"]) + self.assertEqual(result["name"], ["bcn_sophia_elya"]) + self.assertEqual(result["metadata"], ["bcn_doc_clint"]) + self.assertEqual(result["cityAndRole"], ["bcn_doc_clint"]) + self.assertEqual(result["bounded"], ["bcn_silent_builder"]) + self.assertEqual(result["empty"], []) + self.assertEqual(result["missing"], []) + + def test_search_ui_is_accessible_and_uses_safe_dom_rendering(self): + index = (REPO_ROOT / "site/beacon/index.html").read_text(encoding="utf-8") + ui = (REPO_ROOT / "site/beacon/ui.js").read_text(encoding="utf-8") + + self.assertIn('id="agent-search-input"', index) + self.assertIn('role="combobox"', index) + self.assertIn('id="agent-search-results"', index) + self.assertIn("searchResults.replaceChildren()", ui) + self.assertIn("name.textContent = agent.name || agent.id", ui) + self.assertIn("selectAgent(agent.id)", ui) + + class TestBeaconAtlasDataIntegrity(unittest.TestCase): """Test data integrity and consistency.""" @@ -377,6 +454,7 @@ def run_tests(): # Add test classes suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasAPI)) suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasVisualization)) + suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasAgentSearch)) suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasDataIntegrity)) suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasIntegration))