feat(gateway-ui): rewrite all page renderers with Tailwind classes

Convert dashboard, chat, sessions, usage, and settings pages from
legacy CSS to Tailwind utility classes. Responsive grid layouts,
mobile-friendly touch targets, zinc/blue color palette. All element
IDs and event bindings preserved for functional compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-18 13:06:06 -08:00
parent 765e19933d
commit 02d63fe573
5 changed files with 532 additions and 473 deletions
+90 -63
View File
@@ -36,7 +36,7 @@ function escapeHtml(text) {
function highlightCode() {
if (typeof hljs !== 'undefined') {
document.querySelectorAll('.chat-messages pre code').forEach(block => {
document.querySelectorAll('#chat-messages pre code').forEach(block => {
hljs.highlightElement(block);
});
}
@@ -44,10 +44,26 @@ function highlightCode() {
function createMessageEl(role, content) {
const wrapper = document.createElement('div');
wrapper.className = 'message-wrapper';
const roleClasses = {
user: 'flex flex-col gap-1.5 max-w-[85%] md:max-w-[75%] self-end group',
assistant: 'flex flex-col gap-1.5 max-w-[85%] md:max-w-[75%] self-start group',
system: 'flex flex-col gap-1.5 self-stretch max-w-full group',
error: 'flex flex-col gap-1.5 max-w-[85%] md:max-w-[75%] self-start group',
};
wrapper.className = roleClasses[role] || roleClasses.assistant;
const div = document.createElement('div');
div.className = `message ${role}`;
const messageClasses = {
user: 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-blue-500/15 border border-blue-500/25 text-zinc-50',
assistant: 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-zinc-900 border border-zinc-800 text-zinc-50',
system: 'rounded-lg px-3.5 py-2.5 leading-relaxed break-words whitespace-pre-wrap bg-zinc-800 text-zinc-400 border border-zinc-700 text-xs',
error: 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-red-500/15 border border-red-500/30 text-zinc-50',
};
div.className = messageClasses[role] || messageClasses.assistant;
// Keep role as a data attribute for querySelector compatibility
div.dataset.role = role;
if (role === 'assistant' || role === 'system') {
div.innerHTML = renderSafeMarkdown(content);
@@ -67,16 +83,16 @@ function createMessageEl(role, content) {
function createToolEventEl(event, data) {
const group = document.createElement('div');
group.className = 'tool-event-group';
group.className = 'border border-zinc-800 rounded-md my-1 overflow-hidden';
const header = document.createElement('div');
header.className = 'tool-event-header';
header.className = 'flex items-center gap-2 px-3 py-1.5 bg-zinc-800 cursor-pointer text-xs text-zinc-500 hover:text-zinc-400 select-none';
if (event === 'tool_start') {
header.innerHTML = `<span class="spinner"></span> <strong>${escapeHtml(data.tool)}</strong>`;
} else if (event === 'tool_end') {
const icon = data.result?.success ? '&#10003;' : '&#10007;';
const cls = data.result?.success ? 'status-ok' : 'status-error';
const cls = data.result?.success ? 'text-green-500' : 'text-red-500';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
@@ -85,7 +101,7 @@ function createToolEventEl(event, data) {
});
const body = document.createElement('div');
body.className = 'tool-event-body';
body.className = 'tool-event-body px-3 py-2 text-xs text-zinc-400 bg-zinc-900 whitespace-pre-wrap break-all max-h-48 overflow-y-auto font-mono';
if (event === 'tool_start' && data.args) {
body.textContent = JSON.stringify(data.args, null, 2);
@@ -144,17 +160,17 @@ function renderPendingAttachments() {
for (let i = 0; i < _pendingAttachments.length; i++) {
const att = _pendingAttachments[i];
const chip = document.createElement('div');
chip.className = 'attachment-chip';
chip.className = 'inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-zinc-900 border border-zinc-800 text-zinc-400 text-xs';
const name = document.createElement('span');
name.className = 'attachment-name';
name.className = 'max-w-[220px] overflow-hidden text-ellipsis whitespace-nowrap';
name.textContent = att.filename || 'attachment';
const rm = document.createElement('button');
rm.className = 'attachment-remove';
rm.className = 'text-zinc-500 hover:text-zinc-50 cursor-pointer text-base leading-none appearance-none border-0 bg-transparent';
rm.type = 'button';
rm.title = 'Remove attachment';
rm.textContent = '×';
rm.textContent = '\u00d7';
rm.addEventListener('click', () => {
_pendingAttachments.splice(i, 1);
renderPendingAttachments();
@@ -184,23 +200,23 @@ function getMessageText(el) {
function createMessageActions(role) {
const bar = document.createElement('div');
bar.className = 'message-actions';
bar.className = 'flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity';
// Copy button — all messages
const copyBtn = document.createElement('button');
copyBtn.className = 'msg-action-btn';
copyBtn.className = 'inline-flex items-center justify-center w-6 h-6 rounded text-zinc-500 hover:text-zinc-50 hover:bg-zinc-800 transition-colors';
copyBtn.title = 'Copy';
copyBtn.innerHTML = COPY_ICON;
copyBtn.addEventListener('click', () => {
const msg = bar.closest('.message');
if (!msg) {return;}
const text = getMessageText(msg);
const msgEl = bar.previousElementSibling;
if (!msgEl) {return;}
const text = getMessageText(msgEl);
navigator.clipboard.writeText(text).then(() => {
copyBtn.innerHTML = CHECK_ICON;
copyBtn.classList.add('copied');
copyBtn.classList.add('text-green-500');
setTimeout(() => {
copyBtn.innerHTML = COPY_ICON;
copyBtn.classList.remove('copied');
copyBtn.classList.remove('text-green-500');
}, 1500);
});
});
@@ -209,13 +225,13 @@ function createMessageActions(role) {
// Edit button — user messages only
if (role === 'user') {
const editBtn = document.createElement('button');
editBtn.className = 'msg-action-btn';
editBtn.className = 'inline-flex items-center justify-center w-6 h-6 rounded text-zinc-500 hover:text-zinc-50 hover:bg-zinc-800 transition-colors';
editBtn.title = 'Edit';
editBtn.innerHTML = EDIT_ICON;
editBtn.addEventListener('click', () => {
const msg = bar.closest('.message');
if (!msg) {return;}
const text = getMessageText(msg);
const msgEl = bar.previousElementSibling;
if (!msgEl) {return;}
const text = getMessageText(msgEl);
const input = _elements.input;
if (input) {
input.value = text;
@@ -268,8 +284,8 @@ function showSlashPopup(filtered) {
for (let i = 0; i < filtered.length; i++) {
const item = document.createElement('div');
item.className = 'slash-popup-item' + (i === _slashPopupIndex ? ' selected' : '');
item.innerHTML = `<span class="cmd-name">${escapeHtml(filtered[i].name)}</span><span class="cmd-desc">${escapeHtml(filtered[i].desc)}</span>`;
item.className = 'flex items-baseline gap-3 px-3 py-2 cursor-pointer transition-colors' + (i === _slashPopupIndex ? ' bg-zinc-800' : '');
item.innerHTML = `<span class="text-blue-500 font-semibold text-sm min-w-[80px]">${escapeHtml(filtered[i].name)}</span><span class="text-zinc-500 text-xs">${escapeHtml(filtered[i].desc)}</span>`;
item.addEventListener('click', () => {
selectSlashCommand(filtered[i].name);
});
@@ -292,10 +308,14 @@ function hideSlashPopup() {
function updatePopupSelection(_filtered) {
const popup = _elements.slashPopup;
if (!popup) {return;}
const items = popup.querySelectorAll('.slash-popup-item');
items.forEach((el, i) => {
el.classList.toggle('selected', i === _slashPopupIndex);
});
const items = popup.children;
for (let i = 0; i < items.length; i++) {
if (i === _slashPopupIndex) {
items[i].classList.add('bg-zinc-800');
} else {
items[i].classList.remove('bg-zinc-800');
}
}
}
function selectSlashCommand(name) {
@@ -515,7 +535,7 @@ async function loadHistory(client) {
scrollToBottom();
} catch {
msgs.innerHTML = '<div class="empty-state">Could not load history</div>';
msgs.innerHTML = '<div class="text-center py-12 px-6 text-zinc-500 text-sm">Could not load history</div>';
}
}
@@ -559,8 +579,8 @@ async function sendMessage(client, overrideText) {
// Create placeholder for assistant response
const placeholder = document.createElement('div');
placeholder.className = 'message assistant streaming-cursor';
placeholder.innerHTML = '<span class="text-muted">Thinking...</span>';
placeholder.className = 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-zinc-900 border border-zinc-800 text-zinc-50 streaming-cursor';
placeholder.innerHTML = '<span class="text-zinc-500">Thinking...</span>';
_elements.messages.appendChild(placeholder);
scrollToBottom();
@@ -575,17 +595,17 @@ async function sendMessage(client, overrideText) {
stream.on('tool_end', (data) => {
// Replace the last tool_start spinner with completion marker
const events = _elements.messages.querySelectorAll('.tool-event-group');
const events = _elements.messages.querySelectorAll('.border.border-zinc-800.rounded-md');
const last = events[events.length - 1];
if (last) {
const header = last.querySelector('.tool-event-header');
const header = last.firstElementChild;
if (header && data.tool) {
const icon = data.result?.success !== false ? '&#10003;' : '&#10007;';
const cls = data.result?.success !== false ? 'status-ok' : 'status-error';
const cls = data.result?.success !== false ? 'text-green-500' : 'text-red-500';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
// Add result body
const body = last.querySelector('.tool-event-body');
const body = last.lastElementChild;
if (body && data.result) {
body.textContent = data.result.output || data.result.error || '(no output)';
}
@@ -595,7 +615,7 @@ async function sendMessage(client, overrideText) {
stream.on('context_warning', (data) => {
const note = document.createElement('div');
note.className = 'message assistant';
note.className = 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-zinc-900 border border-zinc-800 text-zinc-50';
const text = data?.message || 'Context usage is getting high.';
note.innerHTML = renderSafeMarkdown(`> ${text}`);
_elements.messages.insertBefore(note, placeholder);
@@ -607,11 +627,18 @@ async function sendMessage(client, overrideText) {
placeholder.classList.remove('streaming-cursor');
const content = done?.content ?? done?.text ?? '(no response)';
placeholder.innerHTML = renderSafeMarkdown(content);
placeholder.appendChild(createMessageActions('assistant'));
// Wrap placeholder in a proper message wrapper for action buttons
const wrapper = document.createElement('div');
wrapper.className = 'flex flex-col gap-1.5 max-w-[85%] md:max-w-[75%] self-start group';
placeholder.parentNode.insertBefore(wrapper, placeholder);
wrapper.appendChild(placeholder);
wrapper.appendChild(createMessageActions('assistant'));
setTimeout(highlightCode, 0);
} catch (err) {
placeholder.classList.remove('streaming-cursor');
placeholder.className = 'message error';
placeholder.className = 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-red-500/15 border border-red-500/30 text-zinc-50';
placeholder.textContent = `Error: ${err.message}`;
} finally {
_sending = false;
@@ -623,41 +650,41 @@ async function sendMessage(client, overrideText) {
// ── Search SVG Icon ─────────────────────────────────────────
const SEARCH_ICON = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 3a5.5 5.5 0 0 1 4.38 8.82l4.15 4.15a.75.75 0 0 1-1.06 1.06l-4.15-4.15A5.5 5.5 0 1 1 8.5 3zm0 1.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="currentColor"/></svg>';
const ATTACH_ICON = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 4.75A3.75 3.75 0 0 1 11.25 1h.5A3.25 3.25 0 0 1 15 4.25V12a4.5 4.5 0 0 1-9 0V6.75a2.75 2.75 0 0 1 5.5 0V12a1.25 1.25 0 0 1-2.5 0V6.75a.75.75 0 0 0-1.5 0V12a2.75 2.75 0 0 0 5.5 0V6.75A4.25 4.25 0 0 0 5 6.75V12a6 6 0 0 0 12 0V4.25A4.75 4.75 0 0 0 11.75-.5h-.5A5.25 5.25 0 0 0 6 4.75v8a.75.75 0 0 0 1.5 0z" fill="currentColor"/></svg>';
const COPY_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" fill="currentColor"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" fill="currentColor"/></svg>';
const CHECK_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>';
const EDIT_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm1.414 1.06a.25.25 0 0 0-.354 0L3.463 11.1l-.47 1.64 1.64-.47 8.61-8.61a.25.25 0 0 0 0-.354Z" fill="currentColor"/></svg>';
const SEARCH_ICON = '<svg class="w-3.5 h-3.5 fill-current shrink-0" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 3a5.5 5.5 0 0 1 4.38 8.82l4.15 4.15a.75.75 0 0 1-1.06 1.06l-4.15-4.15A5.5 5.5 0 1 1 8.5 3zm0 1.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="currentColor"/></svg>';
const ATTACH_ICON = '<svg class="w-3.5 h-3.5 fill-current shrink-0" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 4.75A3.75 3.75 0 0 1 11.25 1h.5A3.25 3.25 0 0 1 15 4.25V12a4.5 4.5 0 0 1-9 0V6.75a2.75 2.75 0 0 1 5.5 0V12a1.25 1.25 0 0 1-2.5 0V6.75a.75.75 0 0 0-1.5 0V12a2.75 2.75 0 0 0 5.5 0V6.75A4.25 4.25 0 0 0 5 6.75V12a6 6 0 0 0 12 0V4.25A4.75 4.75 0 0 0 11.75-.5h-.5A5.25 5.25 0 0 0 6 4.75v8a.75.75 0 0 0 1.5 0z" fill="currentColor"/></svg>';
const COPY_ICON = '<svg class="w-3.5 h-3.5 fill-current" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" fill="currentColor"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" fill="currentColor"/></svg>';
const CHECK_ICON = '<svg class="w-3.5 h-3.5 fill-current" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>';
const EDIT_ICON = '<svg class="w-3.5 h-3.5 fill-current" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm1.414 1.06a.25.25 0 0 0-.354 0L3.463 11.1l-.47 1.64 1.64-.47 8.61-8.61a.25.25 0 0 0 0-.354Z" fill="currentColor"/></svg>';
// ── Page Export ──────────────────────────────────────────────
export const ChatPage = {
async render(el, client) {
el.innerHTML = `
<div class="chat-layout">
<div class="chat-header">
<select id="chat-session-select"></select>
<button id="chat-new-session" class="btn btn-secondary">+ New</button>
<button id="chat-load-history" class="btn btn-secondary">History</button>
<div class="flex flex-col h-[calc(100vh-6rem)] md:h-[calc(100vh-3rem)] max-w-3xl">
<div class="flex items-center gap-3 pb-3 border-b border-zinc-800 mb-3 flex-wrap">
<select id="chat-session-select" class="bg-zinc-900 text-zinc-50 border border-zinc-800 rounded-lg px-3 py-1.5 text-sm outline-none focus:border-blue-500"></select>
<button id="chat-new-session" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">+ New</button>
<button id="chat-load-history" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">History</button>
</div>
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-actions">
<button id="chat-search" class="btn-action" title="Search the web">
<div class="flex-1 overflow-y-auto flex flex-col gap-3 py-3" id="chat-messages"></div>
<div class="flex items-center gap-2 py-2 flex-wrap">
<button id="chat-search" class="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700 hover:text-zinc-50 hover:border-zinc-600 transition-colors cursor-pointer select-none" title="Search the web">
${SEARCH_ICON}
<span class="btn-action-label">Search</span>
<span>Search</span>
</button>
<button id="chat-attach" class="btn-action" title="Attach image">
<button id="chat-attach" class="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700 hover:text-zinc-50 hover:border-zinc-600 transition-colors cursor-pointer select-none" title="Attach image">
${ATTACH_ICON}
<span class="btn-action-label">Attach</span>
<span>Attach</span>
</button>
<input id="chat-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple class="hidden" />
<div id="chat-attachments" class="chat-attachments hidden"></div>
<div id="chat-attachments" class="inline-flex items-center gap-1.5 flex-wrap hidden"></div>
</div>
<div class="chat-input-wrapper">
<div id="slash-popup" class="slash-popup hidden"></div>
<div class="chat-input">
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
<button id="chat-send" class="btn btn-primary">Send</button>
<div class="relative">
<div id="slash-popup" class="absolute bottom-full left-0 right-0 mb-1 bg-zinc-900 border border-zinc-800 rounded-lg max-h-60 overflow-y-auto z-50 shadow-lg shadow-black/30 hidden"></div>
<div class="flex gap-2 pt-3 border-t border-zinc-800">
<textarea id="chat-input" class="flex-1 bg-zinc-900 text-zinc-50 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm outline-none resize-none min-h-[42px] max-h-[150px] leading-relaxed focus:border-blue-500 transition-colors" placeholder="Type a message..." rows="1"></textarea>
<button id="chat-send" class="px-4 py-2.5 bg-blue-500 text-zinc-950 font-semibold text-sm rounded-lg hover:opacity-85 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity self-end">Send</button>
</div>
</div>
</div>
@@ -691,7 +718,7 @@ export const ChatPage = {
await loadSessions(client);
_elements.messages.innerHTML = '';
} catch (err) {
_elements.messages.innerHTML = `<div class="empty-state">Failed to create session: ${err.message}</div>`;
_elements.messages.innerHTML = `<div class="text-center py-12 px-6 text-zinc-500 text-sm">Failed to create session: ${err.message}</div>`;
}
});
@@ -795,14 +822,14 @@ export const ChatPage = {
// Dismiss slash popup on outside click
el.addEventListener('click', (e) => {
if (!e.target.closest('.chat-input-wrapper')) {
if (!e.target.closest('.relative')) {
hideSlashPopup();
}
});
// If there's a current session, show welcome
if (!_currentSession) {
_elements.messages.innerHTML = '<div class="empty-state">Select a session or create a new one to start chatting</div>';
_elements.messages.innerHTML = '<div class="text-center py-12 px-6 text-zinc-500 text-sm">Select a session or create a new one to start chatting</div>';
}
},
+235 -222
View File
@@ -126,46 +126,46 @@ function buildRollbackPatchesFromSnapshot(snapshot) {
function renderSkeleton(el) {
el.innerHTML = `
<h1 class="page-title">Live Ops Dashboard</h1>
<h1 class="text-2xl font-semibold text-zinc-50 mb-6">Live Ops Dashboard</h1>
<h2 class="section-title">Core Counters</h2>
<div class="stats-grid" id="ops-counters">
<div class="stat-card"><div class="stat-label">Loading...</div><div class="stat-value">—</div></div>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Core Counters</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8" id="ops-counters">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors"><div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Loading...</div><div class="text-2xl font-bold font-mono text-zinc-50">—</div></div>
</div>
<h2 class="section-title">Model Performance</h2>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Model Performance</h2>
<div id="ops-model-table">
<div class="text-muted text-sm">Loading...</div>
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<h2 class="section-title">Session Analytics</h2>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Session Analytics</h2>
<div id="ops-session-analytics">
<div class="text-muted text-sm">Loading...</div>
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<h2 class="section-title">Context Health</h2>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Context Health</h2>
<div id="ops-context-health">
<div class="text-muted text-sm">Loading...</div>
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<h2 class="section-title">Assistant Health</h2>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Assistant Health</h2>
<div id="ops-assistant-health">
<div class="text-muted text-sm">Loading...</div>
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<h2 class="section-title">Event Stream</h2>
<div class="event-stream" id="ops-events">
<div class="event-row event-level-info">Loading events...</div>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Event Stream</h2>
<div class="max-h-72 overflow-y-auto bg-zinc-900 border border-zinc-800 rounded-lg p-2 font-mono text-xs" id="ops-events">
<div class="px-2 py-1 border-b border-zinc-800/50 last:border-0 break-words text-zinc-400">Loading events...</div>
</div>
<h2 class="section-title">Active Requests</h2>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Active Requests</h2>
<div id="ops-requests">
<div class="text-muted text-sm">Loading...</div>
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<h2 class="section-title">Services</h2>
<div id="ops-services" class="services-grid">
<div class="text-muted text-sm">Loading...</div>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Services</h2>
<div id="ops-services" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div class="text-sm text-zinc-500">Loading...</div>
</div>
`;
}
@@ -185,13 +185,13 @@ function updateCounters(metrics, health) {
{ label: 'Queue Depth', value: String(metrics?.queueDepth ?? 0), cls: '' },
{ label: 'Uptime', value: formatUptime(metrics?.uptime ?? 0), cls: '' },
{ label: 'Active Requests', value: String(metrics?.activeRequests ?? 0), cls: '' },
{ label: 'Errors', value: String(errCount), cls: errCount > 0 ? 'error' : '' },
{ label: 'Errors', value: String(errCount), cls: errCount > 0 ? 'text-red-500' : '' },
];
el.innerHTML = cards.map(c =>
`<div class="stat-card">
<div class="stat-label">${c.label}</div>
<div class="stat-value ${c.cls}">${c.value}</div>
`<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">${c.label}</div>
<div class="text-2xl font-bold font-mono text-zinc-50 ${c.cls}">${c.value}</div>
</div>`,
).join('');
}
@@ -204,7 +204,7 @@ function updateModelTable(metrics) {
const calls = mc?.recentCalls ?? [];
if (calls.length === 0) {
el.innerHTML = '<div class="text-muted text-sm">No model calls recorded yet</div>';
el.innerHTML = '<div class="text-sm text-zinc-500">No model calls recorded yet</div>';
return;
}
@@ -213,41 +213,43 @@ function updateModelTable(metrics) {
const errorRate = mc.errorRate ?? 0;
const summaryHtml = `
<div class="metrics-summary">
<div class="metric"><span>Total Calls:</span> <span class="metric-value">${totalCalls}</span></div>
<div class="metric"><span>Avg Latency:</span> <span class="metric-value">${avgLatency}ms</span></div>
<div class="metric"><span>Error Rate:</span> <span class="metric-value">${(errorRate * 100).toFixed(2)}%</span></div>
<div class="flex flex-wrap gap-4 md:gap-6 mb-3 text-sm text-zinc-400">
<div><span>Total Calls:</span> <span class="font-mono text-zinc-50">${totalCalls}</span></div>
<div><span>Avg Latency:</span> <span class="font-mono text-zinc-50">${avgLatency}ms</span></div>
<div><span>Error Rate:</span> <span class="font-mono text-zinc-50">${(errorRate * 100).toFixed(2)}%</span></div>
</div>
`;
// Show newest first
const rows = [...calls].reverse().map(c => {
const status = c.error ? '<span class="text-error">✗</span>' : '<span class="text-success">✓</span>';
return `<tr>
<td>${timeAgo(c.timestamp)}</td>
<td>${escapeHtml(c.provider)}</td>
<td>${c.latency}ms</td>
<td>${c.tokensPerSec.toFixed(1)}</td>
<td>${c.inputTokens}/${c.outputTokens}</td>
<td>${status}</td>
const status = c.error ? '<span class="text-red-500">✗</span>' : '<span class="text-green-500">✓</span>';
return `<tr class="hover:bg-zinc-800/50">
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${timeAgo(c.timestamp)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${escapeHtml(c.provider)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${c.latency}ms</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${c.tokensPerSec.toFixed(1)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${c.inputTokens}/${c.outputTokens}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${status}</td>
</tr>`;
}).join('');
el.innerHTML = `
${summaryHtml}
<table>
<thead>
<tr>
<th>Time</th>
<th>Provider</th>
<th>Latency</th>
<th>Tokens/sec</th>
<th>In/Out</th>
<th>Status</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Time</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Provider</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Latency</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Tokens/sec</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">In/Out</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Status</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
@@ -258,7 +260,7 @@ function updateEvents(eventsData) {
const events = eventsData?.events ?? [];
if (events.length === 0) {
el.innerHTML = '<div class="event-row event-level-info">No events recorded yet</div>';
el.innerHTML = '<div class="px-2 py-1 border-b border-zinc-800/50 last:border-0 break-words text-zinc-400">No events recorded yet</div>';
return;
}
@@ -268,8 +270,8 @@ function updateEvents(eventsData) {
el.innerHTML = reversed.map(e => {
const time = formatTime(e.timestamp);
const level = (e.level || 'info').toUpperCase();
const cls = `event-level-${e.level || 'info'}`;
return `<div class="event-row ${cls}">[${time}] [${level}] ${escapeHtml(e.source)}: ${escapeHtml(e.message)}</div>`;
const levelColor = e.level === 'error' ? 'text-red-500' : e.level === 'warn' ? 'text-amber-500' : 'text-zinc-400';
return `<div class="px-2 py-1 border-b border-zinc-800/50 last:border-0 break-words ${levelColor}">[${time}] [${level}] ${escapeHtml(e.source)}: ${escapeHtml(e.message)}</div>`;
}).join('');
// Auto-scroll to bottom
@@ -283,7 +285,7 @@ function updateActiveRequests(requestsData) {
const requests = requestsData?.requests ?? [];
if (requests.length === 0) {
el.innerHTML = '<div class="text-muted text-sm">No active requests</div>';
el.innerHTML = '<div class="text-sm text-zinc-500">No active requests</div>';
return;
}
@@ -292,26 +294,28 @@ function updateActiveRequests(requestsData) {
? `${r.durationMs}ms`
: `${(r.durationMs / 1000).toFixed(1)}s`;
const started = formatTime(r.startedAt);
return `<tr>
<td>${escapeHtml(r.sessionId)}</td>
<td>${escapeHtml(r.channel)}</td>
<td>${duration}</td>
<td>${started}</td>
return `<tr class="hover:bg-zinc-800/50">
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${escapeHtml(r.sessionId)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${escapeHtml(r.channel)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${duration}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${started}</td>
</tr>`;
}).join('');
el.innerHTML = `
<table>
<thead>
<tr>
<th>Session</th>
<th>Channel</th>
<th>Duration</th>
<th>Started</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Session</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Channel</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Duration</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Started</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
@@ -329,95 +333,95 @@ function updateSessionAnalytics(analyticsData) {
const avgMessagesPerSession = analyticsData?.averageMessagesPerSession ?? 0;
const summaryHtml = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Sessions (Window)</div>
<div class="stat-value">${formatNumber(totalSessions)}</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Sessions (Window)</div>
<div class="text-2xl font-bold font-mono text-zinc-50">${formatNumber(totalSessions)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Messages (Window)</div>
<div class="stat-value">${formatNumber(totalMessages)}</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Messages (Window)</div>
<div class="text-2xl font-bold font-mono text-zinc-50">${formatNumber(totalMessages)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Avg Session</div>
<div class="stat-value">${formatSessionDurationFromMessages(avgMessagesPerSession)}</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Avg Session</div>
<div class="text-2xl font-bold font-mono text-zinc-50">${formatSessionDurationFromMessages(avgMessagesPerSession)}</div>
</div>
</div>
`;
const topToolsHtml = topTools.length > 0
? `<ul class="tool-list">${topTools.map((tool) =>
`<li><span class="text-accent">${escapeHtml(tool.toolName)}</span> <span class="text-muted">(${formatNumber(tool.executions)})</span></li>`,
).join('')}</ul>`
: '<div class="text-muted text-sm">No tool usage captured in this window</div>';
? `<div>${topTools.map((tool) =>
`<div class="py-1.5 border-b border-zinc-800/50 last:border-0 text-sm"><span class="text-blue-500">${escapeHtml(tool.toolName)}</span> <span class="text-zinc-500">(${formatNumber(tool.executions)})</span></div>`,
).join('')}</div>`
: '<div class="text-sm text-zinc-500">No tool usage captured in this window</div>';
const topTopicsHtml = topTopics.length > 0
? `<ul class="tool-list">${topTopics.map((topic) =>
`<li><span class="text-accent">${escapeHtml(topic.topic)}</span> <span class="text-muted">(${formatNumber(topic.occurrences)})</span></li>`,
).join('')}</ul>`
: '<div class="text-muted text-sm">No indexed topics captured in this window</div>';
? `<div>${topTopics.map((topic) =>
`<div class="py-1.5 border-b border-zinc-800/50 last:border-0 text-sm"><span class="text-blue-500">${escapeHtml(topic.topic)}</span> <span class="text-zinc-500">(${formatNumber(topic.occurrences)})</span></div>`,
).join('')}</div>`
: '<div class="text-sm text-zinc-500">No indexed topics captured in this window</div>';
const topSessionsHtml = topSessions.length > 0
? `<table>
? `<div class="overflow-x-auto"><table class="w-full text-sm">
<thead>
<tr>
<th>Session</th>
<th>Messages</th>
<th>Last Active</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Session</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Messages</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Last Active</th>
</tr>
</thead>
<tbody>
${topSessions.map((session) => `
<tr>
<td>${escapeHtml(session.sessionId)}</td>
<td>${formatNumber(session.messages)}</td>
<td>${timeAgo(session.lastActivity * 1000)}</td>
<tr class="hover:bg-zinc-800/50">
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${escapeHtml(session.sessionId)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${formatNumber(session.messages)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${timeAgo(session.lastActivity * 1000)}</td>
</tr>
`).join('')}
</tbody>
</table>`
: '<div class="text-muted text-sm">No session activity in this window</div>';
</table></div>`
: '<div class="text-sm text-zinc-500">No session activity in this window</div>';
const dailyHtml = daily.length > 0
? `<table>
? `<div class="overflow-x-auto"><table class="w-full text-sm">
<thead>
<tr>
<th>Day</th>
<th>Sessions</th>
<th>Messages</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Day</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Sessions</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Messages</th>
</tr>
</thead>
<tbody>
${daily.slice(0, 7).map((row) => `
<tr>
<td>${formatDay(row.day)}</td>
<td>${formatNumber(row.sessions)}</td>
<td>${formatNumber(row.messages)}</td>
<tr class="hover:bg-zinc-800/50">
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${formatDay(row.day)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${formatNumber(row.sessions)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${formatNumber(row.messages)}</td>
</tr>
`).join('')}
</tbody>
</table>`
: '<div class="text-muted text-sm">No daily activity in this window</div>';
</table></div>`
: '<div class="text-sm text-zinc-500">No daily activity in this window</div>';
el.innerHTML = `
${summaryHtml}
<div class="services-grid">
<div class="service-card">
<span class="service-name">Top Tools</span>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
<div class="text-sm font-semibold text-zinc-50 mb-3">Top Tools</div>
${topToolsHtml}
</div>
<div class="service-card">
<span class="service-name">Top Topics</span>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
<div class="text-sm font-semibold text-zinc-50 mb-3">Top Topics</div>
${topTopicsHtml}
</div>
</div>
<div class="services-grid">
<div class="service-card">
<span class="service-name">Top Sessions</span>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
<div class="text-sm font-semibold text-zinc-50 mb-3">Top Sessions</div>
${topSessionsHtml}
</div>
<div class="service-card">
<span class="service-name">Daily Trend (Last 7 Rows)</span>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
<div class="text-sm font-semibold text-zinc-50 mb-3">Daily Trend (Last 7 Rows)</div>
${dailyHtml}
</div>
</div>
@@ -430,7 +434,7 @@ function updateContextHealth(contextData) {
const sessions = contextData?.sessions ?? [];
if (sessions.length === 0) {
el.innerHTML = '<div class="text-muted text-sm">No active context usage snapshots</div>';
el.innerHTML = '<div class="text-sm text-zinc-500">No active context usage snapshots</div>';
return;
}
@@ -440,18 +444,18 @@ function updateContextHealth(contextData) {
const overThreshold = sessions.filter(s => (s.budget?.shouldCompact ?? false)).length;
const summary = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Highest Usage</div>
<div class="stat-value ${highest >= 90 ? 'error' : ''}">${highest.toFixed(1)}%</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Highest Usage</div>
<div class="text-2xl font-bold font-mono text-zinc-50 ${highest >= 90 ? 'text-red-500' : ''}">${highest.toFixed(1)}%</div>
</div>
<div class="stat-card">
<div class="stat-label">Sessions Near Limit</div>
<div class="stat-value ${overThreshold > 0 ? 'error' : ''}">${overThreshold}</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Sessions Near Limit</div>
<div class="text-2xl font-bold font-mono text-zinc-50 ${overThreshold > 0 ? 'text-red-500' : ''}">${overThreshold}</div>
</div>
<div class="stat-card">
<div class="stat-label">Active Snapshots</div>
<div class="stat-value">${sessions.length}</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Active Snapshots</div>
<div class="text-2xl font-bold font-mono text-zinc-50">${sessions.length}</div>
</div>
</div>
`;
@@ -459,28 +463,30 @@ function updateContextHealth(contextData) {
const rows = top.map((entry) => {
const budget = entry.budget ?? {};
const usage = budget.usagePct ?? 0;
const cls = usage >= 95 ? 'text-error' : usage >= 85 ? 'status-warning' : '';
return `<tr>
<td>${escapeHtml(entry.sessionId)}</td>
<td class="${cls}">${usage.toFixed(1)}%</td>
<td>${formatNumber(budget.estimatedTokens ?? 0)} / ${formatNumber(budget.contextWindow ?? 0)}</td>
<td>${budget.shouldCompact ? 'yes' : 'no'}</td>
const cls = usage >= 95 ? 'text-red-500' : usage >= 85 ? 'text-amber-500' : '';
return `<tr class="hover:bg-zinc-800/50">
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${escapeHtml(entry.sessionId)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono ${cls}">${usage.toFixed(1)}%</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${formatNumber(budget.estimatedTokens ?? 0)} / ${formatNumber(budget.contextWindow ?? 0)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${budget.shouldCompact ? 'yes' : 'no'}</td>
</tr>`;
}).join('');
el.innerHTML = `
${summary}
<table>
<thead>
<tr>
<th>Session</th>
<th>Usage</th>
<th>Estimated Tokens</th>
<th>Should Compact</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Session</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Usage</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Estimated Tokens</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Should Compact</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
@@ -488,7 +494,7 @@ async function applyAssistantPatch(patches, statusEl) {
if (!_dashboardClient) {return;}
if (statusEl) {
statusEl.textContent = 'Saving...';
statusEl.className = 'text-sm text-muted';
statusEl.className = 'text-sm text-zinc-500';
}
try {
const result = await _dashboardClient.call('config.patch', { patches });
@@ -500,22 +506,22 @@ async function applyAssistantPatch(patches, statusEl) {
if (statusEl) {
if (persistError) {
statusEl.textContent = `Save failed: ${persistError}`;
statusEl.className = 'text-sm text-error';
statusEl.className = 'text-sm text-red-500';
} else if (rejected.length > 0) {
statusEl.textContent = `Rejected: ${rejected.join(', ')}`;
statusEl.className = 'text-sm text-error';
statusEl.className = 'text-sm text-red-500';
} else if (!persisted) {
statusEl.textContent = `Runtime saved (${applied.length} updated)`;
statusEl.className = 'text-sm text-muted';
statusEl.className = 'text-sm text-zinc-500';
} else {
statusEl.textContent = `Saved (${applied.length} updated)`;
statusEl.className = 'text-sm text-success';
statusEl.className = 'text-sm text-green-500';
}
}
} catch (error) {
if (statusEl) {
statusEl.textContent = `Save error: ${error instanceof Error ? error.message : String(error)}`;
statusEl.className = 'text-sm text-error';
statusEl.className = 'text-sm text-red-500';
}
}
}
@@ -524,7 +530,7 @@ async function triggerDailyBriefingTest(jobName, statusEl) {
if (!_dashboardClient) {return;}
if (statusEl) {
statusEl.textContent = 'Triggering test briefing...';
statusEl.className = 'text-sm text-muted';
statusEl.className = 'text-sm text-zinc-500';
}
try {
const result = await _dashboardClient.call('tools.invoke', {
@@ -536,7 +542,7 @@ async function triggerDailyBriefingTest(jobName, statusEl) {
const output = typeof result.output === 'string' ? result.output : 'Triggered.';
if (statusEl) {
statusEl.textContent = output;
statusEl.className = 'text-sm text-success';
statusEl.className = 'text-sm text-green-500';
}
_lastBriefingTestAt = Date.now();
return true;
@@ -544,13 +550,13 @@ async function triggerDailyBriefingTest(jobName, statusEl) {
if (statusEl) {
statusEl.textContent = result?.error ?? 'Failed to trigger briefing.';
statusEl.className = 'text-sm text-error';
statusEl.className = 'text-sm text-red-500';
}
return false;
} catch (error) {
if (statusEl) {
statusEl.textContent = `Trigger error: ${error instanceof Error ? error.message : String(error)}`;
statusEl.className = 'text-sm text-error';
statusEl.className = 'text-sm text-red-500';
}
return false;
}
@@ -592,103 +598,103 @@ function updateAssistantHealth(configData) {
];
const chip = (label, value) => `
<div class="assistant-chip">
<span class="assistant-chip-label">${escapeHtml(label)}</span>
<span class="assistant-chip-value ${value ? 'text-success' : 'text-muted'}">${value ? 'ON' : 'OFF'}</span>
<div class="flex justify-between items-center px-3 py-2.5 bg-zinc-900 border border-zinc-800 rounded-lg text-sm">
<span class="text-zinc-400">${escapeHtml(label)}</span>
<span class="font-bold ${value ? 'text-green-500' : 'text-zinc-500'}">${value ? 'ON' : 'OFF'}</span>
</div>
`;
el.innerHTML = `
<div class="assistant-health-grid">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2 mb-4">
${chip('Announce Mode', announce)}
${chip('Daily Briefing', dailyBriefing)}
${chip('Memory Daily Log', memoryDaily)}
${chip('Proactive Extract', memoryProactive)}
${chip('TTS Replies', ttsEnabled)}
<div class="assistant-chip">
<span class="assistant-chip-label">Extract Threshold</span>
<span class="assistant-chip-value">${Number.isFinite(proactiveThreshold) ? proactiveThreshold : 1}</span>
<div class="flex justify-between items-center px-3 py-2.5 bg-zinc-900 border border-zinc-800 rounded-lg text-sm">
<span class="text-zinc-400">Extract Threshold</span>
<span class="font-bold">${Number.isFinite(proactiveThreshold) ? proactiveThreshold : 1}</span>
</div>
</div>
<div class="assistant-actions">
<button class="btn btn-secondary assistant-action-btn" data-action="toggle-announce">
<div class="flex flex-wrap gap-2 mb-4">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="toggle-announce">
${announce ? 'Disable Announce Mode' : 'Enable Announce Mode'}
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="toggle-daily-briefing">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="toggle-daily-briefing">
${dailyBriefing ? 'Disable Daily Briefing' : 'Enable Daily Briefing'}
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="toggle-memory-daily">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="toggle-memory-daily">
${memoryDaily ? 'Disable Daily Log' : 'Enable Daily Log'}
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="toggle-memory-proactive">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="toggle-memory-proactive">
${memoryProactive ? 'Disable Proactive Extract' : 'Enable Proactive Extract'}
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="toggle-tts">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="toggle-tts">
${ttsEnabled ? 'Disable TTS' : 'Enable TTS'}
</button>
</div>
<div class="assistant-playbooks">
<div class="assistant-preview-title">Assistant Playbooks</div>
<div class="assistant-playbook-grid">
<button class="btn btn-secondary assistant-action-btn" data-action="playbook-executive">
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
<div class="text-sm font-semibold text-zinc-50 mb-3">Assistant Playbooks</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 my-3">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="playbook-executive">
Executive
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="playbook-operator">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="playbook-operator">
Operator
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="playbook-focus">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="playbook-focus">
Focus
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="playbook-undo" ${_lastPlaybookRollbackPatches ? '' : 'disabled'}>
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="playbook-undo" ${_lastPlaybookRollbackPatches ? '' : 'disabled'}>
Undo Last Playbook
</button>
</div>
<div class="text-sm text-muted">Executive: announce + voice + aggressive interrupt. Operator: announce + memory-first + steer backlog. Focus: reactive, quieter mode.</div>
<div class="text-sm text-zinc-500">Executive: announce + voice + aggressive interrupt. Operator: announce + memory-first + steer backlog. Focus: reactive, quieter mode.</div>
</div>
<div class="assistant-setup">
<div class="assistant-preview-title">Assistant Activation Checklist</div>
<ul class="assistant-checklist">
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
<div class="text-sm font-semibold text-zinc-50 mb-3">Assistant Activation Checklist</div>
<div class="space-y-1 mb-4">
${checklistRows.map((row) => `
<li class="${row.done ? 'done' : ''}">
<span class="assistant-check">${row.done ? '✓' : '○'}</span>
<div class="flex items-center gap-2 py-1 text-sm ${row.done ? 'text-zinc-50' : 'text-zinc-500'}">
<span class="w-5 text-center font-bold">${row.done ? '✓' : '○'}</span>
<span>${escapeHtml(row.label)}</span>
</li>
</div>
`).join('')}
</ul>
<div class="assistant-setup-grid">
<label class="assistant-field">
<span>Briefing output channel</span>
<input id="assist-brief-channel" type="text" value="${escapeHtml(briefingOutput?.channel ?? '')}" placeholder="telegram" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">Briefing output channel</span>
<input id="assist-brief-channel" type="text" value="${escapeHtml(briefingOutput?.channel ?? '')}" placeholder="telegram" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="assistant-field">
<span>Briefing output peer/chat id</span>
<input id="assist-brief-peer" type="text" value="${escapeHtml(briefingOutput?.peer ?? '')}" placeholder="123456789" />
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">Briefing output peer/chat id</span>
<input id="assist-brief-peer" type="text" value="${escapeHtml(briefingOutput?.peer ?? '')}" placeholder="123456789" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
</div>
<div class="assistant-actions">
<button class="btn btn-secondary assistant-action-btn" data-action="save-briefing-output">
<div class="flex flex-wrap gap-2">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="save-briefing-output">
Save Briefing Output
</button>
</div>
</div>
<div class="assistant-preview">
<div class="assistant-preview-header">
<div class="assistant-preview-title">Morning Brief Preview</div>
<button class="btn btn-secondary assistant-action-btn" data-action="test-daily-briefing" ${briefingReady ? '' : 'disabled'}>
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-semibold text-zinc-50">Morning Brief Preview</div>
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="test-daily-briefing" ${briefingReady ? '' : 'disabled'}>
Send Test Briefing
</button>
</div>
<div class="assistant-preview-meta text-sm text-muted">
<span>name: <code>${escapeHtml(briefingName)}</code></span>
<span>schedule: <code>${escapeHtml(briefingSchedule)}</code></span>
<span>timezone: <code>${escapeHtml(briefingTimezone)}</code></span>
<span>tier: <code>${escapeHtml(briefingModelTier)}</code></span>
<span>output: <code>${escapeHtml(briefingOutputLabel)}</code></span>
<div class="flex flex-wrap gap-3 text-sm text-zinc-500 mb-3">
<span>name: <code class="font-mono">${escapeHtml(briefingName)}</code></span>
<span>schedule: <code class="font-mono">${escapeHtml(briefingSchedule)}</code></span>
<span>timezone: <code class="font-mono">${escapeHtml(briefingTimezone)}</code></span>
<span>tier: <code class="font-mono">${escapeHtml(briefingModelTier)}</code></span>
<span>output: <code class="font-mono">${escapeHtml(briefingOutputLabel)}</code></span>
</div>
<div class="assistant-preview-body"><code>${escapeHtml(briefingPrompt || 'No daily briefing prompt configured.')}</code></div>
${briefingReady ? '' : '<div class="text-sm text-muted">Enable daily briefing and set output channel/peer to test-send.</div>'}
<div class="max-h-44 overflow-y-auto bg-zinc-950 border border-zinc-800 rounded-md p-3"><code class="text-sm text-zinc-400 font-mono whitespace-pre-wrap">${escapeHtml(briefingPrompt || 'No daily briefing prompt configured.')}</code></div>
${briefingReady ? '' : '<div class="text-sm text-zinc-500 mt-2">Enable daily briefing and set output channel/peer to test-send.</div>'}
</div>
<div id="ops-assistant-status" class="text-sm text-muted"></div>
<div id="ops-assistant-status" class="text-sm text-zinc-500 mt-4"></div>
`;
const statusEl = el.querySelector('#ops-assistant-status');
@@ -722,7 +728,7 @@ function updateAssistantHealth(configData) {
if (!_lastPlaybookRollbackPatches) {
if (statusEl) {
statusEl.textContent = 'No playbook changes to undo.';
statusEl.className = 'text-sm text-muted';
statusEl.className = 'text-sm text-zinc-500';
}
return;
}
@@ -734,7 +740,7 @@ function updateAssistantHealth(configData) {
if (!channel || !peer) {
if (statusEl) {
statusEl.textContent = 'Briefing output channel and peer are required.';
statusEl.className = 'text-sm text-error';
statusEl.className = 'text-sm text-red-500';
}
return;
}
@@ -765,7 +771,7 @@ function _updateChannels(channelsData) {
const channels = channelsData?.channels ?? [];
if (channels.length === 0) {
el.innerHTML = '<div class="text-muted text-sm">No channels registered</div>';
el.innerHTML = '<div class="text-sm text-zinc-500">No channels registered</div>';
return;
}
@@ -784,27 +790,34 @@ function updateServices(servicesData) {
const services = servicesData?.services ?? [];
if (services.length === 0) {
el.innerHTML = '<div class="text-muted text-sm">No services configured</div>';
el.innerHTML = '<div class="text-sm text-zinc-500">No services configured</div>';
return;
}
el.innerHTML = services.map(svc => {
const typeIcon = svc.type === 'channel' ? '📡' : svc.type === 'automation' ? '⚙️' : '🔧';
const statusClass = svc.status === 'connected'
? 'connected'
const borderColor = svc.status === 'connected'
? 'border-l-green-500'
: svc.status === 'configured'
? 'configured'
? 'border-l-blue-500'
: svc.status === 'error'
? 'error'
: svc.status === 'not_configured'
? 'not-configured'
: 'disconnected';
? 'border-l-red-500'
: 'border-l-zinc-600 opacity-60';
const statusColor = svc.status === 'connected'
? 'text-green-500'
: svc.status === 'configured'
? 'text-blue-500'
: svc.status === 'error'
? 'text-red-500'
: 'text-zinc-500';
const itemCount = svc.itemCount ? ` (${svc.itemCount})` : '';
return `<div class="service-card service-${statusClass}">
<span class="service-type-icon">${typeIcon}</span>
<span class="service-name">${escapeHtml(svc.name)}${itemCount}</span>
<span class="service-status">${escapeHtml(svc.status)}</span>
<span class="service-description text-muted text-xs">${escapeHtml(svc.description)}</span>
return `<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-3 flex flex-col gap-1 border-l-4 ${borderColor}">
<div class="flex items-center gap-2">
<span class="text-sm shrink-0">${typeIcon}</span>
<span class="text-sm font-semibold text-zinc-50">${escapeHtml(svc.name)}${itemCount}</span>
</div>
<span class="text-xs uppercase ${statusColor}">${escapeHtml(svc.status)}</span>
<span class="text-xs text-zinc-500">${escapeHtml(svc.description)}</span>
</div>`;
}).join('');
}
+46 -36
View File
@@ -53,21 +53,22 @@ async function loadSessionList() {
const sessions = result.sessions ?? [];
if (sessions.length === 0) {
listContainer.innerHTML = '<div class="empty-state">No sessions found</div>';
listContainer.innerHTML = '<div class="text-center py-12 px-6 text-zinc-500 text-sm">No sessions found</div>';
return;
}
let html = `
<table>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th>Session ID</th>
<th>Frontend</th>
<th>Messages</th>
<th>Model</th>
<th>Queue</th>
<th>Last Activity</th>
<th>Actions</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Session ID</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Frontend</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Messages</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Model</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Queue</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Last Activity</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Actions</th>
</tr>
</thead>
<tbody>
@@ -75,22 +76,24 @@ async function loadSessionList() {
for (const s of sessions) {
html += `
<tr>
<td><a href="#" class="session-view-link" data-id="${escapeHtml(s.id)}">${escapeHtml(s.id)}</a></td>
<td>${escapeHtml(s.frontend ?? (String(s.id).split(':')[0] || 'unknown'))}</td>
<td>${s.messageCount ?? 0}</td>
<td>${escapeHtml(s.config?.modelTier ?? 'default')}</td>
<td>${escapeHtml(formatQueue(s.config))}</td>
<td>${escapeHtml(formatTime(s.lastMessageAt))}</td>
<td class="session-actions">
<button class="btn btn-secondary session-view-btn" data-id="${escapeHtml(s.id)}">View</button>
<button class="btn btn-danger session-delete-btn" data-id="${escapeHtml(s.id)}">Delete</button>
<tr class="hover:bg-zinc-800/50">
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50"><a href="#" class="text-blue-500 hover:underline session-view-link" data-id="${escapeHtml(s.id)}">${escapeHtml(s.id)}</a></td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${escapeHtml(s.frontend ?? (String(s.id).split(':')[0] || 'unknown'))}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${s.messageCount ?? 0}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${escapeHtml(s.config?.modelTier ?? 'default')}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${escapeHtml(formatQueue(s.config))}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${escapeHtml(formatTime(s.lastMessageAt))}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">
<div class="flex gap-1.5">
<button class="px-2.5 py-1 text-xs font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors session-view-btn" data-id="${escapeHtml(s.id)}">View</button>
<button class="px-2.5 py-1 text-xs font-medium rounded-md bg-red-500/15 text-red-500 border border-red-500/30 hover:bg-red-500 hover:text-white transition-colors session-delete-btn" data-id="${escapeHtml(s.id)}">Delete</button>
</div>
</td>
</tr>
`;
}
html += '</tbody></table>';
html += '</tbody></table></div>';
listContainer.innerHTML = html;
// Bind view buttons
@@ -108,7 +111,7 @@ async function loadSessionList() {
});
});
} catch (err) {
listContainer.innerHTML = `<div class="empty-state">Failed to load sessions: ${err.message}</div>`;
listContainer.innerHTML = `<div class="text-center py-12 px-6 text-zinc-500 text-sm">Failed to load sessions: ${err.message}</div>`;
}
}
@@ -116,35 +119,42 @@ async function viewSession(sessionId) {
const detailContainer = _el.querySelector('#session-detail');
if (!detailContainer) {return;}
detailContainer.innerHTML = '<div class="empty-state"><span class="spinner"></span> Loading...</div>';
detailContainer.innerHTML = '<div class="text-center py-12 px-6 text-zinc-500 text-sm"><span class="spinner"></span> Loading...</div>';
try {
const result = await _client.call('sessions.history', { sessionId });
const messages = result.messages ?? [];
const roleClasses = {
user: 'rounded-lg px-3 py-2 text-sm bg-blue-500/15 border border-blue-500/25 text-zinc-50',
assistant: 'rounded-lg px-3 py-2 text-sm bg-zinc-900 border border-zinc-800 text-zinc-50',
system: 'rounded-lg px-3 py-2 text-sm bg-zinc-800 text-zinc-400 border border-zinc-700',
};
let html = `
<div class="session-detail">
<div class="session-detail-header">
<h2 class="section-title">${escapeHtml(sessionId)}</h2>
<span class="text-muted text-sm">${messages.length} messages</span>
<div class="mt-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-zinc-50">${escapeHtml(sessionId)}</h2>
<span class="text-sm text-zinc-500">${messages.length} messages</span>
</div>
<div class="message-history">
<div class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
`;
if (messages.length === 0) {
html += '<div class="empty-state">No messages in this session</div>';
html += '<div class="text-center py-12 px-6 text-zinc-500 text-sm">No messages in this session</div>';
} else {
for (const msg of messages) {
const role = msg.role ?? 'system';
const content = msg.content ?? msg.text ?? '';
html += `<div class="message ${escapeHtml(role)}">${escapeHtml(content)}</div>`;
const cls = roleClasses[role] ?? roleClasses.system;
html += `<div class="${cls}">${escapeHtml(content)}</div>`;
}
}
html += '</div></div>';
detailContainer.innerHTML = html;
} catch (err) {
detailContainer.innerHTML = `<div class="empty-state">Failed to load session: ${err.message}</div>`;
detailContainer.innerHTML = `<div class="text-center py-12 px-6 text-zinc-500 text-sm">Failed to load session: ${err.message}</div>`;
}
}
@@ -167,10 +177,10 @@ export const SessionsPage = {
_el = el;
el.innerHTML = `
<h1 class="page-title">Sessions</h1>
<div class="status-row" style="margin-bottom: 0.75rem; gap: 0.75rem; flex-wrap: wrap;">
<label class="text-sm text-muted">Frontend
<select id="sessions-frontend-filter" style="margin-left: 0.35rem;">
<h1 class="text-2xl font-semibold text-zinc-50 mb-6">Sessions</h1>
<div class="flex items-center gap-3 mb-4 flex-wrap">
<label class="text-sm text-zinc-400 flex items-center gap-1.5">Frontend
<select id="sessions-frontend-filter" class="bg-zinc-900 text-zinc-50 border border-zinc-800 rounded-lg px-3 py-1.5 text-sm outline-none focus:border-blue-500">
<option value="">All</option>
<option value="ws">ws</option>
<option value="tui">tui</option>
@@ -180,11 +190,11 @@ export const SessionsPage = {
<option value="mattermost">mattermost</option>
</select>
</label>
<label class="text-sm text-muted">
<label class="text-sm text-zinc-400 flex items-center gap-2 cursor-pointer">
<input id="sessions-include-inactive" type="checkbox" checked />
Include inactive/persisted
</label>
<button id="sessions-refresh-btn" class="btn btn-secondary">Refresh</button>
<button id="sessions-refresh-btn" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">Refresh</button>
</div>
<div id="sessions-list"></div>
<div id="session-detail"></div>
+105 -98
View File
@@ -51,12 +51,12 @@ async function renderPushStatus() {
try {
const status = await getPushStatus();
statusEl.textContent = describePushStatus(status);
statusEl.className = status.subscribed ? 'text-sm text-success' : 'text-sm text-muted';
statusEl.className = status.subscribed ? 'text-sm text-green-500' : 'text-sm text-zinc-500';
enableBtn.disabled = !status.supported || !status.enabled || !status.configured || status.subscribed;
disableBtn.disabled = !status.supported || !status.subscribed;
} catch (err) {
statusEl.textContent = `Push status error: ${err.message}`;
statusEl.className = 'text-sm text-error';
statusEl.className = 'text-sm text-red-500';
enableBtn.disabled = true;
disableBtn.disabled = true;
}
@@ -66,13 +66,13 @@ async function onEnablePush() {
const statusEl = _el.querySelector('#push-status');
if (!statusEl) {return;}
statusEl.textContent = 'Enabling push notifications...';
statusEl.className = 'text-sm text-muted';
statusEl.className = 'text-sm text-zinc-500';
try {
await enablePushNotifications();
await renderPushStatus();
} catch (err) {
statusEl.textContent = `Enable failed: ${err.message}`;
statusEl.className = 'text-sm text-error';
statusEl.className = 'text-sm text-red-500';
}
}
@@ -80,13 +80,13 @@ async function onDisablePush() {
const statusEl = _el.querySelector('#push-status');
if (!statusEl) {return;}
statusEl.textContent = 'Disabling push notifications...';
statusEl.className = 'text-sm text-muted';
statusEl.className = 'text-sm text-zinc-500';
try {
await disablePushNotifications();
await renderPushStatus();
} catch (err) {
statusEl.textContent = `Disable failed: ${err.message}`;
statusEl.className = 'text-sm text-error';
statusEl.className = 'text-sm text-red-500';
}
}
@@ -104,8 +104,8 @@ async function loadSettings() {
]);
} catch (err) {
_el.innerHTML = `
<h1 class="page-title">Settings</h1>
<div class="empty-state">Failed to load settings: ${err.message}</div>
<h1 class="text-2xl font-semibold text-zinc-50 mb-6">Settings</h1>
<div class="text-center py-12 px-6 text-zinc-500 text-sm">Failed to load settings: ${err.message}</div>
`;
return;
}
@@ -140,140 +140,147 @@ async function loadSettings() {
const serviceList = services?.services ?? [];
_el.innerHTML = `
<h1 class="page-title">Settings</h1>
<h1 class="text-2xl font-semibold text-zinc-50 mb-6">Settings</h1>
<h2 class="section-title">Personal Assistant Mode</h2>
<div class="settings-section">
<div class="assistant-mode-grid">
<label class="assistant-toggle">
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Personal Assistant Mode</h2>
<div class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<label class="flex items-center gap-2.5 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm text-zinc-400 cursor-pointer min-h-[44px]">
<input id="assist-delivery-announce" type="checkbox" ${deliveryMode === 'announce' ? 'checked' : ''} />
<span>Automation announce delivery mode</span>
</label>
<label class="assistant-toggle">
<label class="flex items-center gap-2.5 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm text-zinc-400 cursor-pointer min-h-[44px]">
<input id="assist-daily-briefing" type="checkbox" ${dailyBriefingEnabled ? 'checked' : ''} />
<span>Daily briefing enabled</span>
</label>
<label class="assistant-toggle">
<label class="flex items-center gap-2.5 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm text-zinc-400 cursor-pointer min-h-[44px]">
<input id="assist-memory-daily" type="checkbox" ${dailyMemoryEnabled ? 'checked' : ''} />
<span>Daily memory logging</span>
</label>
<label class="assistant-toggle">
<label class="flex items-center gap-2.5 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm text-zinc-400 cursor-pointer min-h-[44px]">
<input id="assist-memory-proactive" type="checkbox" ${proactiveExtractEnabled ? 'checked' : ''} />
<span>Proactive memory extraction</span>
</label>
<label class="assistant-field">
<label class="flex flex-col gap-2 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm text-zinc-400">
<span>Proactive extract tool-call threshold</span>
<input id="assist-memory-min-tools" type="number" min="0" max="50" value="${Number.isFinite(proactiveMinToolCalls) ? proactiveMinToolCalls : 1}" />
<input id="assist-memory-min-tools" type="number" min="0" max="50" value="${Number.isFinite(proactiveMinToolCalls) ? proactiveMinToolCalls : 1}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="assistant-toggle">
<label class="flex items-center gap-2.5 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm text-zinc-400 cursor-pointer min-h-[44px]">
<input id="assist-tts-enabled" type="checkbox" ${ttsEnabled ? 'checked' : ''} />
<span>TTS voice replies enabled</span>
</label>
<label class="assistant-field">
<label class="flex flex-col gap-2 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm text-zinc-400">
<span>TTS channels (comma-separated, blank = all)</span>
<input id="assist-tts-channels" type="text" value="${escapeHtml(ttsChannelText)}" placeholder="telegram,discord,whatsapp" />
<input id="assist-tts-channels" type="text" value="${escapeHtml(ttsChannelText)}" placeholder="telegram,discord,whatsapp" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="assistant-field">
<label class="flex flex-col gap-2 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm text-zinc-400">
<span>Briefing output channel</span>
<input id="assist-briefing-channel" type="text" value="${escapeHtml(briefingOutputChannel)}" placeholder="telegram" />
<input id="assist-briefing-channel" type="text" value="${escapeHtml(briefingOutputChannel)}" placeholder="telegram" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="assistant-field">
<label class="flex flex-col gap-2 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm text-zinc-400">
<span>Briefing output peer/chat id</span>
<input id="assist-briefing-peer" type="text" value="${escapeHtml(briefingOutputPeer)}" placeholder="123456789" />
<input id="assist-briefing-peer" type="text" value="${escapeHtml(briefingOutputPeer)}" placeholder="123456789" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
</div>
<div class="assistant-actions">
<button id="assistant-mode-save" class="btn btn-primary">Save Assistant Mode</button>
<span id="assistant-mode-status" class="text-sm text-muted"></span>
<div class="flex items-center gap-3 mt-2">
<button id="assistant-mode-save" class="px-4 py-2 text-sm font-medium rounded-md bg-blue-500 text-zinc-950 hover:opacity-85 transition-opacity">Save Assistant Mode</button>
<span id="assistant-mode-status" class="text-sm text-zinc-500"></span>
</div>
</div>
<h2 class="section-title">WebChat Push Notifications</h2>
<div class="settings-section">
${isPushSupported() ? '' : '<div class="text-sm text-muted">This browser does not support PushManager APIs.</div>'}
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<button id="push-enable" class="btn btn-primary" type="button">Enable Push</button>
<button id="push-disable" class="btn btn-secondary" type="button">Disable Push</button>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">WebChat Push Notifications</h2>
<div class="mb-8">
${isPushSupported() ? '' : '<div class="text-sm text-zinc-500 mb-2">This browser does not support PushManager APIs.</div>'}
<div class="flex gap-2 mb-2">
<button id="push-enable" class="px-4 py-2 text-sm font-medium rounded-md bg-blue-500 text-zinc-950 hover:opacity-85 transition-opacity" type="button">Enable Push</button>
<button id="push-disable" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors" type="button">Disable Push</button>
</div>
<div id="push-status" class="text-sm text-muted"></div>
<div id="push-status" class="text-sm text-zinc-500"></div>
</div>
<h2 class="section-title">Hook Patterns</h2>
<div class="settings-section">
<div class="hook-editor">
<div class="hook-group">
<label>Confirm (require approval)</label>
<textarea id="hooks-confirm" rows="3">${escapeHtml(confirmPatterns.join('\n'))}</textarea>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Hook Patterns</h2>
<div class="mb-8">
<div class="flex flex-col gap-3">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-3">
<label class="block text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Confirm (require approval)</label>
<textarea id="hooks-confirm" rows="3" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm font-mono focus:border-blue-500 outline-none resize-y min-h-[60px]">${escapeHtml(confirmPatterns.join('\n'))}</textarea>
</div>
<div class="hook-group">
<label>Log (allow + log)</label>
<textarea id="hooks-log" rows="3">${escapeHtml(logPatterns.join('\n'))}</textarea>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-3">
<label class="block text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Log (allow + log)</label>
<textarea id="hooks-log" rows="3" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm font-mono focus:border-blue-500 outline-none resize-y min-h-[60px]">${escapeHtml(logPatterns.join('\n'))}</textarea>
</div>
<div class="hook-group">
<label>Silent (allow silently)</label>
<textarea id="hooks-silent" rows="3">${escapeHtml(silentPatterns.join('\n'))}</textarea>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-3">
<label class="block text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Silent (allow silently)</label>
<textarea id="hooks-silent" rows="3" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm font-mono focus:border-blue-500 outline-none resize-y min-h-[60px]">${escapeHtml(silentPatterns.join('\n'))}</textarea>
</div>
<div>
<button id="hooks-save" class="btn btn-primary">Save Hook Patterns</button>
<span id="hooks-status" class="text-sm text-muted" style="margin-left: 12px;"></span>
<div class="flex items-center gap-3 mt-2">
<button id="hooks-save" class="px-4 py-2 text-sm font-medium rounded-md bg-blue-500 text-zinc-950 hover:opacity-85 transition-opacity">Save Hook Patterns</button>
<span id="hooks-status" class="text-sm text-zinc-500"></span>
</div>
</div>
</div>
<h2 class="section-title">Tools (${toolList.length})</h2>
<div class="settings-section">
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Tools (${toolList.length})</h2>
<div class="mb-8">
${toolList.length > 0 ? `
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
${toolList.map(t => `
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<td><code>${escapeHtml(t.name)}</code></td>
<td class="text-secondary">${escapeHtml(t.description ?? '')}</td>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Name</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Description</th>
</tr>
`).join('')}
</tbody>
</table>
` : '<div class="empty-state">No tools available</div>'}
</thead>
<tbody>
${toolList.map(t => `
<tr>
<td class="px-3 py-2 border-b border-zinc-800/50"><code class="font-mono text-blue-500 text-xs">${escapeHtml(t.name)}</code></td>
<td class="px-3 py-2 border-b border-zinc-800/50 text-zinc-400">${escapeHtml(t.description ?? '')}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : '<div class="text-center py-12 px-6 text-zinc-500 text-sm">No tools available</div>'}
</div>
<h2 class="section-title">Services</h2>
<div class="settings-section">
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Services</h2>
<div class="mb-8">
${serviceList.length > 0 ? `
<div class="services-grid">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
${serviceList.map(svc => {
const typeIcon = svc.type === 'channel' ? '📡' : svc.type === 'automation' ? '⚙️' : '🔧';
const statusClass = svc.status === 'connected'
? 'connected'
const borderClass = svc.status === 'connected'
? 'border-l-green-500'
: svc.status === 'configured'
? 'configured'
? 'border-l-blue-500'
: svc.status === 'error'
? 'error'
: svc.status === 'not_configured'
? 'not-configured'
: 'disconnected';
? 'border-l-red-500'
: 'border-l-zinc-600 opacity-60';
const statusColorClass = svc.status === 'connected'
? 'text-green-500'
: svc.status === 'configured'
? 'text-blue-500'
: svc.status === 'error'
? 'text-red-500'
: 'text-zinc-500';
const itemCount = svc.itemCount ? ` (${svc.itemCount})` : '';
return `
<div class="service-card service-${statusClass}">
<span class="service-type-icon">${typeIcon}</span>
<span class="service-name">${escapeHtml(svc.name)}${itemCount}</span>
<span class="service-status">${escapeHtml(svc.status)}</span>
<span class="service-description text-muted text-xs">${escapeHtml(svc.description ?? '')}</span>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-3 flex flex-col gap-1 border-l-4 ${borderClass}">
<span>${typeIcon}</span>
<span class="text-sm font-semibold text-zinc-50">${escapeHtml(svc.name)}${itemCount}</span>
<span class="text-xs uppercase ${statusColorClass}">${escapeHtml(svc.status)}</span>
<span class="text-xs text-zinc-500">${escapeHtml(svc.description ?? '')}</span>
</div>
`;
}).join('')}
</div>
` : '<div class="text-muted text-sm">No services found</div>'}
` : '<div class="text-sm text-zinc-500">No services found</div>'}
</div>
<h2 class="section-title">Configuration (read-only)</h2>
<div class="settings-section">
<div class="config-view"><code>${escapeHtml(configJson)}</code></div>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Configuration (read-only)</h2>
<div class="mb-8">
<div class="bg-zinc-950 border border-zinc-800 rounded-lg p-4 max-h-96 overflow-y-auto font-mono text-xs text-zinc-400 whitespace-pre-wrap"><code>${escapeHtml(configJson)}</code></div>
</div>
`;
@@ -288,7 +295,7 @@ async function loadSettings() {
async function saveAssistantMode() {
const status = _el.querySelector('#assistant-mode-status');
status.textContent = 'Saving...';
status.className = 'text-sm text-muted';
status.className = 'text-sm text-zinc-500';
const useAnnounce = Boolean(_el.querySelector('#assist-delivery-announce')?.checked);
const dailyBriefing = Boolean(_el.querySelector('#assist-daily-briefing')?.checked);
@@ -330,23 +337,23 @@ async function saveAssistantMode() {
if (rejected.length > 0) {
status.textContent = `Partially saved. Rejected: ${rejected.join(', ')}`;
status.className = 'text-sm text-error';
status.className = 'text-sm text-red-500';
} else if (persistError) {
status.textContent = `Save failed: ${persistError}`;
status.className = 'text-sm text-error';
status.className = 'text-sm text-red-500';
} else if (!persisted) {
status.textContent = `Saved in runtime only (${applied.length} updated)`;
status.className = 'text-sm text-muted';
status.className = 'text-sm text-zinc-500';
} else {
status.textContent = `Saved (${applied.length} updated)`;
status.className = 'text-sm text-success';
status.className = 'text-sm text-green-500';
if (_settingsCache && _settingsCache.automation) {
_settingsCache.automation.delivery_mode = useAnnounce ? 'announce' : 'shared_session';
}
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
status.className = 'text-sm text-error';
status.className = 'text-sm text-red-500';
}
setTimeout(() => {
@@ -357,7 +364,7 @@ async function saveAssistantMode() {
async function saveHooks() {
const status = _el.querySelector('#hooks-status');
status.textContent = 'Saving...';
status.className = 'text-sm text-muted';
status.className = 'text-sm text-zinc-500';
try {
const confirm = _el.querySelector('#hooks-confirm').value.split('\n').map(s => s.trim()).filter(Boolean);
@@ -379,20 +386,20 @@ async function saveHooks() {
if (rejected.length > 0) {
status.textContent = `Partially saved. Rejected: ${rejected.join(', ')}`;
status.className = 'text-sm text-error';
status.className = 'text-sm text-red-500';
} else if (persistError) {
status.textContent = `Save failed: ${persistError}`;
status.className = 'text-sm text-error';
status.className = 'text-sm text-red-500';
} else if (!persisted) {
status.textContent = `Saved in runtime only (${applied.length} updated)`;
status.className = 'text-sm text-muted';
status.className = 'text-sm text-zinc-500';
} else {
status.textContent = `Saved (${applied.length} updated)`;
status.className = 'text-sm text-success';
status.className = 'text-sm text-green-500';
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
status.className = 'text-sm text-error';
status.className = 'text-sm text-red-500';
}
// Clear status after 5s
+56 -54
View File
@@ -32,7 +32,7 @@ async function loadUsage(el, client) {
data = await client.call('system.tokenUsage');
contextData = await client.call('system.contextUsage');
} catch (err) {
el.innerHTML = `<div class="empty-state">Failed to load usage: ${err.message}</div>`;
el.innerHTML = `<div class="text-center py-12 px-6 text-zinc-500 text-sm">Failed to load usage: ${err.message}</div>`;
return;
}
@@ -54,30 +54,30 @@ async function loadUsage(el, client) {
// Summary cards
const summaryHtml = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Input Tokens</div>
<div class="stat-value">${formatNumber(totalInput)}</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Total Input Tokens</div>
<div class="text-2xl font-bold font-mono text-zinc-50">${formatNumber(totalInput)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Output Tokens</div>
<div class="stat-value">${formatNumber(totalOutput)}</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Total Output Tokens</div>
<div class="text-2xl font-bold font-mono text-zinc-50">${formatNumber(totalOutput)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Tokens</div>
<div class="stat-value">${formatNumber(totalInput + totalOutput)}</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Total Tokens</div>
<div class="text-2xl font-bold font-mono text-zinc-50">${formatNumber(totalInput + totalOutput)}</div>
</div>
<div class="stat-card">
<div class="stat-label">API Calls</div>
<div class="stat-value">${formatNumber(totalCalls)}</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">API Calls</div>
<div class="text-2xl font-bold font-mono text-zinc-50">${formatNumber(totalCalls)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Estimated Cost</div>
<div class="stat-value">${formatCost(totalCost)}</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Estimated Cost</div>
<div class="text-2xl font-bold font-mono text-zinc-50">${formatCost(totalCost)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Active Sessions</div>
<div class="stat-value">${sessions.length}</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Active Sessions</div>
<div class="text-2xl font-bold font-mono text-zinc-50">${sessions.length}</div>
</div>
</div>
`;
@@ -85,7 +85,7 @@ async function loadUsage(el, client) {
// Per-session table
let tableHtml = '';
if (sessions.length === 0) {
tableHtml = '<div class="empty-state">No active sessions with usage data</div>';
tableHtml = '<div class="text-center py-12 px-6 text-zinc-500 text-sm">No active sessions with usage data</div>';
} else {
const rows = sessions.map(s => {
const inTok = s.total?.inputTokens ?? 0;
@@ -95,59 +95,61 @@ async function loadUsage(el, client) {
const budget = contextBySession.get(s.sessionId);
const contextCell = budget
? `${budget.usagePct.toFixed(1)}% (${formatNumber(budget.estimatedTokens)}/${formatNumber(budget.contextWindow)})`
: '<span class="text-muted">-</span>';
: '<span class="text-zinc-500">-</span>';
// Build delegation breakdown if present
const delegationEntries = Object.entries(s.delegation ?? {});
let delegationCell = '<span class="text-muted">-</span>';
let delegationCell = '<span class="text-zinc-500">-</span>';
if (delegationEntries.length > 0) {
delegationCell = delegationEntries.map(([tier, stats]) =>
`<span class="badge ok">${tier}</span> ${formatNumber(stats.inputTokens)}/${formatNumber(stats.outputTokens)}`,
`<span class="inline-block px-2 py-0.5 text-xs rounded-full font-semibold bg-green-500/15 text-green-500">${tier}</span> ${formatNumber(stats.inputTokens)}/${formatNumber(stats.outputTokens)}`,
).join('<br>');
}
return `
<tr>
<td title="${s.sessionId}">${truncateId(s.sessionId)}</td>
<td>${formatNumber(inTok)}</td>
<td>${formatNumber(outTok)}</td>
<td>${formatNumber(inTok + outTok)}</td>
<td>${formatNumber(calls)}</td>
<td>${formatCost(cost)}</td>
<td>${contextCell}</td>
<td>${delegationCell}</td>
<tr class="hover:bg-zinc-800/50">
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50" title="${s.sessionId}">${truncateId(s.sessionId)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${formatNumber(inTok)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${formatNumber(outTok)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${formatNumber(inTok + outTok)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${formatNumber(calls)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${formatCost(cost)}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${contextCell}</td>
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${delegationCell}</td>
</tr>
`;
}).join('');
tableHtml = `
<table>
<thead>
<tr>
<th>Session</th>
<th>Input</th>
<th>Output</th>
<th>Total</th>
<th>Calls</th>
<th>Cost</th>
<th>Context</th>
<th>Delegation</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Session</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Input</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Output</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Total</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Calls</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Cost</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Context</th>
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Delegation</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</div>
`;
}
el.innerHTML = `
<div class="usage-header">
<h1 class="page-title">Token Usage</h1>
<button class="btn btn-secondary" id="usage-refresh-btn">Refresh</button>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold text-zinc-50">Token Usage</h1>
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors shrink-0" id="usage-refresh-btn">Refresh</button>
</div>
${summaryHtml}
<h2 class="section-title">Per-Session Breakdown</h2>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-2 pb-2 border-b border-zinc-800">Per-Session Breakdown</h2>
${tableHtml}
`;