feat: add web UI dashboard SPA with dashboard, chat, sessions, and settings pages

- Add SPA shell with hash-based router, sidebar navigation, and WebSocket RPC client
- Add dashboard page with system health cards, channel status, and auto-refresh
- Add chat page with session selector, streaming tool events, and markdown rendering
- Add sessions page with list, history viewer, and delete functionality
- Add settings page with hook pattern editor, tool list, and config viewer
- Add backend handlers: sessions.delete, sessions.switch, system.channels, system.usage
- Wire channelRegistry into gateway server for channel status reporting
- Extend static file server with .mjs, .png, .ico, .woff2 content types
This commit is contained in:
William Valentin
2026-02-07 10:07:45 -08:00
parent f7d889e35e
commit 22230a3e3f
14 changed files with 1836 additions and 207 deletions
+146
View File
@@ -0,0 +1,146 @@
/**
* Flynn Sessions Page
*
* Lists all sessions, allows viewing history and deleting sessions.
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
let _client = null;
let _el = null;
async function loadSessionList() {
if (!_client || !_el) return;
const listContainer = _el.querySelector('#sessions-list');
const detailContainer = _el.querySelector('#session-detail');
if (detailContainer) detailContainer.innerHTML = '';
try {
const result = await _client.call('sessions.list');
const sessions = result.sessions ?? [];
if (sessions.length === 0) {
listContainer.innerHTML = '<div class="empty-state">No sessions found</div>';
return;
}
let html = `
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Messages</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
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>${s.messageCount ?? 0}</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>
</td>
</tr>
`;
}
html += '</tbody></table>';
listContainer.innerHTML = html;
// Bind view buttons
listContainer.querySelectorAll('.session-view-btn, .session-view-link').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
viewSession(btn.dataset.id);
});
});
// Bind delete buttons
listContainer.querySelectorAll('.session-delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
deleteSession(btn.dataset.id);
});
});
} catch (err) {
listContainer.innerHTML = `<div class="empty-state">Failed to load sessions: ${err.message}</div>`;
}
}
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>';
try {
const result = await _client.call('sessions.history', { sessionId });
const messages = result.messages ?? [];
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>
<div class="message-history">
`;
if (messages.length === 0) {
html += '<div class="empty-state">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>`;
}
}
html += '</div></div>';
detailContainer.innerHTML = html;
} catch (err) {
detailContainer.innerHTML = `<div class="empty-state">Failed to load session: ${err.message}</div>`;
}
}
async function deleteSession(sessionId) {
if (!confirm(`Delete session "${sessionId}"? This will clear all message history.`)) {
return;
}
try {
await _client.call('sessions.delete', { sessionId });
await loadSessionList();
} catch (err) {
alert(`Failed to delete session: ${err.message}`);
}
}
export const SessionsPage = {
async render(el, client) {
_client = client;
_el = el;
el.innerHTML = `
<h1 class="page-title">Sessions</h1>
<div id="sessions-list"></div>
<div id="session-detail"></div>
`;
await loadSessionList();
},
teardown() {
_client = null;
_el = null;
},
};