feat: improve web UI UX with global search, breadcrumbs, and better feedback

This commit is contained in:
William Valentin
2026-03-26 14:24:52 -07:00
parent 8bca99573b
commit c53283ac07
3 changed files with 285 additions and 9 deletions
+116 -9
View File
@@ -35,7 +35,75 @@
updateToggleBtn(getTheme());
const btn = document.getElementById('theme-toggle');
if (btn) btn.addEventListener('click', cycleTheme);
// Global Search
const searchInput = document.getElementById('global-search');
if (searchInput) {
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const val = searchInput.value.trim();
if (val) handleGlobalSearch(val);
}
});
}
// Keyboard Shortcuts
document.addEventListener('keydown', (e) => {
// '/' to focus search
if (e.key === '/' && document.activeElement !== searchInput && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
e.preventDefault();
searchInput.focus();
}
});
});
async function handleGlobalSearch(id) {
if (id.length < 8) {
showToast('Search ID must be at least 8 characters', 'info');
return;
}
try {
// Try fetching as session first
const sessionData = await api('/v1/sessions/' + id).catch(() => null);
if (sessionData && sessionData.session) {
navigate('/sessions/' + id);
return;
}
// Then try as run
const runData = await api('/v1/runs/' + id).catch(() => null);
if (runData && runData.run) {
navigate('/runs/' + id);
return;
}
showToast('ID not found', 'error');
} catch (e) {
showToast('Search failed: ' + e.message, 'error');
}
}
function copyToClipboard(text, el) {
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
showToast('Copied to clipboard', 'success');
if (el) {
const originalText = el.textContent;
el.textContent = 'Copied!';
setTimeout(() => { el.textContent = originalText; }, 1500);
}
}).catch(err => {
console.error('Failed to copy:', err);
showToast('Copy failed', 'error');
});
}
function renderCopyButton(text) {
return `<button class="copy-btn" title="Copy to clipboard" onclick="event.stopPropagation(); copyToClipboard('${escapeHTML(text)}', this)">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="9" height="9" rx="1.5"/><path d="M10.5 1.5H2.5A1.5 1.5 0 0 0 1 3v8"/></svg>
</button>`;
}
// ─────────────────────────────────────────────────────────
const app = document.getElementById('app');
@@ -184,6 +252,7 @@
function route() {
cleanupLiveViews();
renderBreadcrumbs();
const path = window.location.pathname;
if (path === '/') {
@@ -204,6 +273,44 @@
updateActiveNav();
}
function renderBreadcrumbs() {
const el = document.getElementById('breadcrumbs');
if (!el) return;
const path = window.location.pathname;
const parts = path.split('/').filter(Boolean);
if (parts.length === 0) {
el.innerHTML = '';
return;
}
const items = [{ label: 'Dashboard', path: '/' }];
let currentPath = '';
parts.forEach((part, i) => {
currentPath += '/' + part;
let label = part.charAt(0).toUpperCase() + part.slice(1);
// Special labels for IDs
if (part.length > 20 || /^[a-f0-9-]{32,}$/.test(part)) {
label = part.substring(0, 8) + '…';
}
if (i === parts.length - 1) {
items.push({ label, current: true });
} else {
items.push({ label, path: currentPath });
}
});
el.innerHTML = items.map(item => {
if (item.current) {
return `<span class="current">${escapeHTML(item.label)}</span>`;
}
return `<a href="${item.path}" onclick="event.preventDefault(); navigate('${item.path}')">${escapeHTML(item.label)}</a><span class="sep">/</span>`;
}).join('');
}
function navigate(path) {
history.pushState(null, '', path);
route();
@@ -293,8 +400,8 @@
}
function statusIcon(status) {
if (status === 'success') return '<span class="status-badge status-success"><span class="status-dot"></span>success</span>';
if (status === 'error') return '<span class="status-badge status-error"><span class="status-dot"></span>error</span>';
if (status === 'success') return '<span class="status-badge status-success"><svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px"><polyline points="13.5 4.5 6.5 11.5 2.5 7.5"/></svg>success</span>';
if (status === 'error') return '<span class="status-badge status-error"><svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px"><line x1="13.5" y1="2.5" x2="2.5" y2="13.5"/><line x1="2.5" y1="2.5" x2="13.5" y2="13.5"/></svg>error</span>';
return '<span class="status-badge status-unknown"><span class="status-dot"></span>unknown</span>';
}
@@ -445,7 +552,7 @@
: (active ? 'Open session' : 'Session ended');
return `
<tr class="clickable ${active ? 'active' : ''}" data-session="${escapeHTML(s.session_id)}">
<td class="id-cell">${escapeHTML(s.session_id.substring(0, 12))}…</td>
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}${renderCopyButton(s.session_id)}</td>
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
<td>${escapeHTML(s.host || '-')}</td>
<td>${s.run_count}</td>
@@ -546,7 +653,7 @@
? 'Currently active session'
: (active ? 'Open session' : 'Session ended');
return `
<td class="id-cell">${escapeHTML(s.session_id.substring(0, 12))}…</td>
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}${renderCopyButton(s.session_id)}</td>
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
<td>${escapeHTML(s.host || '-')}</td>
<td>${s.run_count}</td>
@@ -668,7 +775,7 @@
app.innerHTML = `
<a href="/sessions" class="back-link">&larr; Back to Sessions</a>
<div class="page-header">
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(sessionID.substring(0, 16))}...</span></h2>
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)" title="${escapeHTML(sessionID)}">${escapeHTML(sessionID.substring(0, 16))}...</span>${renderCopyButton(sessionID)}</h2>
<div class="session-status-line">
<span class="fw-dot ${escapeHTML((s.framework || 'unknown').replace(/[^a-z0-9-]/g, '-'))} ${active ? 'active' : 'ended'}"></span>
<span class="session-status-text">${active ? 'Active' : 'Ended'}</span>
@@ -806,7 +913,7 @@
return spans.map((sp, i) => {
const kindClass = sp.kind || 'unknown';
return `
<tr class="expandable run-span-row" data-index="${i}">
<tr class="expandable run-span-row ${sp.status === 'error' ? 'tr-error' : ''}" data-index="${i}">
<td>
<span class="expand-icon"></span>
<span class="span-kind-badge ${escapeHTML(kindClass)}">${escapeHTML(sp.kind || '?')}</span>
@@ -871,7 +978,7 @@
app.innerHTML = `
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">&larr; Back to Session</a>
<div class="page-header">
<h2>Run <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(runID.substring(0, 16))}…</span> ${statusIcon(r.status)}</h2>
<h2>Run <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)" title="${escapeHTML(runID)}">${escapeHTML(runID.substring(0, 16))}…</span>${renderCopyButton(runID)} ${statusIcon(r.status)}</h2>
<div class="meta-tiles">
<div class="meta-tile">
<div class="meta-tile-label">Started</div>
@@ -1061,8 +1168,8 @@
` : '<div class="empty-state" style="padding:0.5rem 0">No spans yet</div>';
return `
<tr class="clickable expandable-run" data-run="${escapeHTML(r.run_id)}" data-index="${i}">
<td class="id-cell"><span class="expand-icon"></span>${escapeHTML(r.run_id.substring(0, 12))}...</td>
<tr class="clickable expandable-run ${r.status === 'error' ? 'tr-error' : ''}" data-run="${escapeHTML(r.run_id)}" data-index="${i}">
<td class="id-cell" title="${escapeHTML(r.run_id)}"><span class="expand-icon"></span>${escapeHTML(r.run_id.substring(0, 12))}...${renderCopyButton(r.run_id)}</td>
<td>${statusIcon(r.status)}</td>
<td><span class="model-badge">${modelLabel}</span></td>
<td>${r.tool_count || 0}</td>