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>';
}
},