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()); updateToggleBtn(getTheme());
const btn = document.getElementById('theme-toggle'); const btn = document.getElementById('theme-toggle');
if (btn) btn.addEventListener('click', cycleTheme); 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'); const app = document.getElementById('app');
@@ -184,6 +252,7 @@
function route() { function route() {
cleanupLiveViews(); cleanupLiveViews();
renderBreadcrumbs();
const path = window.location.pathname; const path = window.location.pathname;
if (path === '/') { if (path === '/') {
@@ -204,6 +273,44 @@
updateActiveNav(); 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) { function navigate(path) {
history.pushState(null, '', path); history.pushState(null, '', path);
route(); route();
@@ -293,8 +400,8 @@
} }
function statusIcon(status) { function statusIcon(status) {
if (status === 'success') return '<span class="status-badge status-success"><span class="status-dot"></span>success</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"><span class="status-dot"></span>error</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>'; return '<span class="status-badge status-unknown"><span class="status-dot"></span>unknown</span>';
} }
@@ -445,7 +552,7 @@
: (active ? 'Open session' : 'Session ended'); : (active ? 'Open session' : 'Session ended');
return ` return `
<tr class="clickable ${active ? 'active' : ''}" data-session="${escapeHTML(s.session_id)}"> <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><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
<td>${escapeHTML(s.host || '-')}</td> <td>${escapeHTML(s.host || '-')}</td>
<td>${s.run_count}</td> <td>${s.run_count}</td>
@@ -546,7 +653,7 @@
? 'Currently active session' ? 'Currently active session'
: (active ? 'Open session' : 'Session ended'); : (active ? 'Open session' : 'Session ended');
return ` 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><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
<td>${escapeHTML(s.host || '-')}</td> <td>${escapeHTML(s.host || '-')}</td>
<td>${s.run_count}</td> <td>${s.run_count}</td>
@@ -668,7 +775,7 @@
app.innerHTML = ` app.innerHTML = `
<a href="/sessions" class="back-link">&larr; Back to Sessions</a> <a href="/sessions" class="back-link">&larr; Back to Sessions</a>
<div class="page-header"> <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"> <div class="session-status-line">
<span class="fw-dot ${escapeHTML((s.framework || 'unknown').replace(/[^a-z0-9-]/g, '-'))} ${active ? 'active' : 'ended'}"></span> <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> <span class="session-status-text">${active ? 'Active' : 'Ended'}</span>
@@ -806,7 +913,7 @@
return spans.map((sp, i) => { return spans.map((sp, i) => {
const kindClass = sp.kind || 'unknown'; const kindClass = sp.kind || 'unknown';
return ` 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> <td>
<span class="expand-icon"></span> <span class="expand-icon"></span>
<span class="span-kind-badge ${escapeHTML(kindClass)}">${escapeHTML(sp.kind || '?')}</span> <span class="span-kind-badge ${escapeHTML(kindClass)}">${escapeHTML(sp.kind || '?')}</span>
@@ -871,7 +978,7 @@
app.innerHTML = ` app.innerHTML = `
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">&larr; Back to Session</a> <a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">&larr; Back to Session</a>
<div class="page-header"> <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-tiles">
<div class="meta-tile"> <div class="meta-tile">
<div class="meta-tile-label">Started</div> <div class="meta-tile-label">Started</div>
@@ -1061,8 +1168,8 @@
` : '<div class="empty-state" style="padding:0.5rem 0">No spans yet</div>'; ` : '<div class="empty-state" style="padding:0.5rem 0">No spans yet</div>';
return ` return `
<tr class="clickable expandable-run" data-run="${escapeHTML(r.run_id)}" data-index="${i}"> <tr class="clickable expandable-run ${r.status === 'error' ? 'tr-error' : ''}" 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> <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>${statusIcon(r.status)}</td>
<td><span class="model-badge">${modelLabel}</span></td> <td><span class="model-badge">${modelLabel}</span></td>
<td>${r.tool_count || 0}</td> <td>${r.tool_count || 0}</td>
+5
View File
@@ -18,10 +18,15 @@
</div> </div>
<nav><a href="/">Dashboard</a><a href="/sessions">Sessions</a><a href="/agents">Agents</a><a href="/infrastructure">Infra</a></nav> <nav><a href="/">Dashboard</a><a href="/sessions">Sessions</a><a href="/agents">Agents</a><a href="/infrastructure">Infra</a></nav>
<div class="header-right"> <div class="header-right">
<div class="header-search">
<input type="text" id="global-search" placeholder="Search ID..." spellcheck="false" autocomplete="off">
<kbd>/</kbd>
</div>
<span class="ws-dot" id="ws-dot" title="Disconnected"></span> <span class="ws-dot" id="ws-dot" title="Disconnected"></span>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button> <button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
</div> </div>
</header> </header>
<div id="breadcrumbs" class="breadcrumbs"></div>
<main id="app"> <main id="app">
<p>Loading...</p> <p>Loading...</p>
</main> </main>
+164
View File
@@ -150,6 +150,86 @@ header nav a.active::after {
border-radius: 1px; border-radius: 1px;
} }
/* ── Header Search ────────────────────────────────────────── */
.header-search {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
margin-right: 0.5rem;
}
.header-search input {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 0.35rem 0.75rem;
padding-right: 2.2rem;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.78rem;
width: 140px;
transition: width 0.2s ease, border-color 0.15s, box-shadow 0.15s;
outline: none;
}
.header-search input:focus {
width: 220px;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.header-search kbd {
position: absolute;
right: 0.6rem;
top: 50%;
transform: translateY(-50%);
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
pointer-events: none;
opacity: 0.7;
}
.header-search input:focus + kbd {
display: none;
}
/* ── Breadcrumbs ─────────────────────────────────────────── */
.breadcrumbs {
max-width: 1240px;
margin: 0 auto;
padding: 1rem 2rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
color: var(--text-dim);
}
.breadcrumbs a {
color: var(--text-dim);
text-decoration: none;
transition: color 0.15s;
}
.breadcrumbs a:hover {
color: var(--text-bright);
}
.breadcrumbs .sep {
opacity: 0.3;
}
.breadcrumbs .current {
color: var(--text-bright);
font-weight: 500;
}
/* ── Main ──────────────────────────────────────────────────── */ /* ── Main ──────────────────────────────────────────────────── */
main { main {
max-width: 1240px; max-width: 1240px;
@@ -348,6 +428,36 @@ tr.clickable:hover td:first-child {
padding-left: calc(1.25rem - 2px); padding-left: calc(1.25rem - 2px);
} }
tr.tr-error td {
color: var(--error);
}
tr.tr-error .id-cell {
color: var(--error);
opacity: 0.8;
}
.skeleton-line {
height: 0.8rem;
background: var(--surface-2);
border-radius: 4px;
position: relative;
overflow: hidden;
}
.skeleton-line::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent);
animation: skeletonPulse 1.5s infinite;
}
@keyframes skeletonPulse {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* ── Status badges ─────────────────────────────────────────── */ /* ── Status badges ─────────────────────────────────────────── */
.status-badge { .status-badge {
display: inline-flex; display: inline-flex;
@@ -406,6 +516,60 @@ tr.clickable:hover td:first-child {
border-radius: 3px; border-radius: 3px;
} }
.copy-btn {
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 0.1rem;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin-left: 0.25rem;
opacity: 0;
}
tr:hover .copy-btn,
.meta-tile:hover .copy-btn {
opacity: 1;
}
.copy-btn:hover {
color: var(--accent);
background: var(--accent-dim);
}
/* ── Toast notifications ───────────────────────────────────── */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 1000;
padding: 0.75rem 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
color: var(--text-bright);
font-size: 0.85rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
transform: translateY(1rem);
opacity: 0;
transition: transform 0.2s cubic-bezier(0.17, 0.67, 0.83, 0.67), opacity 0.2s;
pointer-events: none;
}
.toast.visible {
transform: translateY(0);
opacity: 1;
}
.toast-success { border-left: 3px solid var(--success); }
.toast-error { border-left: 3px solid var(--error); }
.toast-info { border-left: 3px solid var(--accent); }
/* ── Load more ─────────────────────────────────────────────── */ /* ── Load more ─────────────────────────────────────────────── */
.load-more { .load-more {
display: block; display: block;