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
+4 -2
View File
@@ -586,6 +586,9 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
systemPrompt = `${systemPrompt}\n\n# Available Skills\n\n${skillAdditions}`;
}
// Initialize channel registry (created early so the gateway can reference it)
const channelRegistry = new ChannelRegistry();
// Initialize gateway WebSocket server
const gateway = new GatewayServer({
port: config.server.port,
@@ -602,6 +605,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
authHttp: config.server.auth_http,
uiDir: resolve(import.meta.dirname, '../gateway/ui'),
config,
channelRegistry,
restart: async () => {
console.log('Restart requested via gateway');
await lifecycle.shutdown();
@@ -616,8 +620,6 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
// ── Channel Registry ──────────────────────────────────────────
const channelRegistry = new ChannelRegistry();
// Set up the unified message handler
channelRegistry.setMessageHandler(createMessageRouter({
sessionManager,
+41
View File
@@ -1,9 +1,11 @@
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
import type { SessionManager } from '../../session/manager.js';
import type { SessionBridge } from '../session-bridge.js';
export interface SessionHandlerDeps {
sessionManager: SessionManager;
sessionBridge?: SessionBridge;
}
export function createSessionHandlers(deps: SessionHandlerDeps) {
@@ -55,5 +57,44 @@ export function createSessionHandlers(deps: SessionHandlerDeps) {
return makeResponse(request.id, { sessionId });
},
'sessions.delete': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
const { sessionId } = params;
const parts = sessionId.split(':');
const frontend = parts[0];
const userId = parts.slice(1).join(':');
const session = deps.sessionManager.getSession(frontend, userId);
session.clear();
return makeResponse(request.id, { deleted: true, sessionId });
},
'sessions.switch': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string; connectionId?: string } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
if (!deps.sessionBridge) {
return makeError(request.id, ErrorCode.InternalError, 'Session switching not available');
}
const connectionId = params.connectionId as string;
if (!connectionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'connectionId is required');
}
try {
deps.sessionBridge.switchSession(connectionId, params.sessionId);
return makeResponse(request.id, { switched: true, sessionId: params.sessionId });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to switch session';
return makeError(request.id, ErrorCode.InternalError, message);
}
},
};
}
+19
View File
@@ -9,6 +9,8 @@ export interface SystemHandlerDeps {
getConnectionCount: () => number;
/** Optional callback to trigger a graceful restart. If not provided, system.restart returns an error. */
restart?: () => Promise<void>;
getChannels?: () => Array<{ name: string; status: string }>;
getUsage?: () => { totalSessions: number; activeConnections: number };
}
export function createSystemHandlers(deps: SystemHandlerDeps) {
@@ -41,5 +43,22 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
return response;
},
'system.channels': async (request: GatewayRequest): Promise<OutboundMessage> => {
if (!deps.getChannels) {
return makeResponse(request.id, { channels: [] });
}
return makeResponse(request.id, { channels: deps.getChannels() });
},
'system.usage': async (request: GatewayRequest): Promise<OutboundMessage> => {
const uptime = Math.floor((Date.now() - deps.startTime) / 1000);
const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 };
return makeResponse(request.id, {
uptime,
...usage,
tools: deps.getToolCount(),
});
},
};
}
+9
View File
@@ -41,6 +41,7 @@ export interface GatewayServerConfig {
config?: Config;
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
restart?: () => Promise<void>;
channelRegistry?: { list(): Array<{ readonly name: string; readonly status: string }> };
}
export class GatewayServer {
@@ -75,10 +76,18 @@ export class GatewayServer {
getToolCount: () => this.config.toolRegistry.list().length,
getConnectionCount: () => this.sessionBridge.connectionCount,
restart: this.config.restart,
getChannels: this.config.channelRegistry
? () => this.config.channelRegistry!.list().map(a => ({ name: a.name, status: a.status }))
: undefined,
getUsage: () => ({
totalSessions: this.config.sessionManager.listSessions().length,
activeConnections: this.sessionBridge.connectionCount,
}),
});
const sessionHandlers = createSessionHandlers({
sessionManager: this.config.sessionManager,
sessionBridge: this.sessionBridge,
});
const toolHandlers = createToolHandlers({
+4
View File
@@ -7,8 +7,12 @@ const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
};
/**
+76
View File
@@ -0,0 +1,76 @@
/**
* Flynn SPA Router
*
* Hash-based routing with page lifecycle management.
*/
import { getClient } from './lib/ws-client.js';
const routes = new Map();
let currentPage = null;
let contentEl = null;
export function registerPage(path, page) {
routes.set(path, page);
}
export function navigate(path) {
window.location.hash = path;
}
function getPath() {
const hash = window.location.hash.slice(1) || '/';
return hash;
}
async function render() {
const path = getPath();
const page = routes.get(path);
if (!page) {
contentEl.innerHTML = '<div class="page-error"><h2>Page not found</h2></div>';
return;
}
// Teardown previous page
if (currentPage?.teardown) {
currentPage.teardown();
}
// Update nav
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.getAttribute('href') === `#${path}`);
});
// Render new page
currentPage = page;
contentEl.innerHTML = '';
const pageEl = document.createElement('div');
pageEl.className = 'page';
contentEl.appendChild(pageEl);
await page.render(pageEl, getClient());
}
export function initRouter() {
contentEl = document.getElementById('content');
window.addEventListener('hashchange', render);
render();
}
// Connection status indicator
export function initStatusIndicator() {
const statusEl = document.getElementById('conn-status');
const client = getClient();
client.onStatusChange((status) => {
statusEl.textContent = status === 'connected' ? 'Connected' :
status === 'connecting' ? 'Connecting...' : 'Disconnected';
statusEl.className = `conn-status ${status}`;
});
// Set initial status
statusEl.textContent = client.status === 'connected' ? 'Connected' :
client.status === 'connecting' ? 'Connecting...' : 'Disconnected';
statusEl.className = `conn-status ${client.status}`;
}
+42 -205
View File
@@ -3,217 +3,54 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flynn Dashboard</title>
<title>Flynn</title>
<link rel="stylesheet" href="style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
</head>
<body>
<div class="container" style="height:100vh;overflow-y:auto">
<header>
<h1>Flynn Dashboard</h1>
<span id="status">Connecting...</span>
<a href="/chat.html">Chat</a>
</header>
<section class="dashboard" id="health">
<!-- Health cards rendered by JS -->
</section>
<section>
<h2>Sessions</h2>
<div id="sessions" class="session-list">Loading...</div>
</section>
<section>
<h2>Tools</h2>
<div id="tools" class="tool-list">Loading...</div>
</section>
<div class="app-shell">
<nav class="sidebar">
<div class="sidebar-header">
<h1 class="logo">Flynn</h1>
</div>
<div class="nav-links">
<a href="#/" class="nav-link active" data-page="dashboard">
<span class="nav-icon">&#9632;</span> Dashboard
</a>
<a href="#/chat" class="nav-link" data-page="chat">
<span class="nav-icon">&#9993;</span> Chat
</a>
<a href="#/sessions" class="nav-link" data-page="sessions">
<span class="nav-icon">&#9776;</span> Sessions
</a>
<a href="#/settings" class="nav-link" data-page="settings">
<span class="nav-icon">&#9881;</span> Settings
</a>
</div>
<div class="sidebar-footer">
<span id="conn-status" class="conn-status disconnected">Disconnected</span>
</div>
</nav>
<main id="content" class="content">
<!-- Pages rendered here by router -->
</main>
</div>
<script>
// --- Request ID counter and pending callback map ---
let requestId = 0;
const pending = new Map();
<script type="module">
import { registerPage, initRouter, initStatusIndicator } from './app.js';
import { DashboardPage } from './pages/dashboard.js';
import { ChatPage } from './pages/chat.js';
import { SessionsPage } from './pages/sessions.js';
import { SettingsPage } from './pages/settings.js';
// --- WebSocket connection ---
const ws = new WebSocket(`ws://${location.host}`);
registerPage('/', DashboardPage);
registerPage('/chat', ChatPage);
registerPage('/sessions', SessionsPage);
registerPage('/settings', SettingsPage);
const statusEl = document.getElementById('status');
const healthEl = document.getElementById('health');
const sessionsEl = document.getElementById('sessions');
const toolsEl = document.getElementById('tools');
ws.addEventListener('open', () => {
statusEl.textContent = 'Connected';
statusEl.className = 'status-ok';
// Fetch all data on connect
fetchHealth();
fetchSessions();
fetchTools();
});
ws.addEventListener('close', () => {
statusEl.textContent = 'Disconnected';
statusEl.className = 'status-error';
});
ws.addEventListener('error', () => {
statusEl.textContent = 'Connection error';
statusEl.className = 'status-error';
});
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data);
// Match response to pending request by ID
if (msg.id != null && pending.has(msg.id)) {
const callback = pending.get(msg.id);
pending.delete(msg.id);
if (msg.result) {
callback(null, msg.result);
} else if (msg.error) {
callback(msg.error, null);
}
}
} catch {
// Ignore malformed messages
}
});
// --- Auto-refresh health every 10 seconds ---
setInterval(fetchHealth, 10000);
// --- RPC helper: send a JSON-RPC request and register a callback ---
function rpcCall(method, params, callback) {
requestId++;
const id = requestId;
pending.set(id, callback);
const message = { id, method };
if (params) {
message.params = params;
}
ws.send(JSON.stringify(message));
}
// --- Uptime formatting: converts seconds to human-readable string ---
function formatUptime(totalSeconds) {
const s = Math.floor(totalSeconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${h}h ${m}m ${sec}s`;
}
// --- Fetch and render system health ---
function fetchHealth() {
rpcCall('system.health', null, (err, result) => {
if (err) {
healthEl.innerHTML = '<div class="card"><h2>Error</h2><div class="value">' +
escapeHtml(err.message || 'Failed to fetch health') + '</div></div>';
return;
}
renderHealth(result);
});
}
function renderHealth(data) {
const isOk = data.status === 'ok';
const statusClass = isOk ? 'status-ok' : 'status-error';
const statusLabel = isOk ? '● Healthy' : '● Unhealthy';
healthEl.innerHTML = `
<div class="card">
<h2>Status</h2>
<div class="value ${statusClass}">${statusLabel}</div>
</div>
<div class="card">
<h2>Version</h2>
<div class="value">${escapeHtml(data.version || 'unknown')}</div>
</div>
<div class="card">
<h2>Uptime</h2>
<div class="value">${formatUptime(data.uptime || 0)}</div>
</div>
<div class="card">
<h2>Connections</h2>
<div class="value">${data.connections ?? 0}</div>
</div>
<div class="card">
<h2>Sessions</h2>
<div class="value">${data.sessions ?? 0}</div>
</div>
<div class="card">
<h2>Tools</h2>
<div class="value">${data.tools ?? 0}</div>
</div>
`;
}
// --- Fetch and render sessions list ---
function fetchSessions() {
rpcCall('sessions.list', null, (err, result) => {
if (err) {
sessionsEl.textContent = 'Error: ' + (err.message || 'Failed to fetch sessions');
return;
}
renderSessions(result.sessions || []);
});
}
function renderSessions(sessions) {
if (sessions.length === 0) {
sessionsEl.textContent = 'No active sessions';
return;
}
const rows = sessions.map(s => `
<tr>
<td>${escapeHtml(s.id)}</td>
<td>${s.messageCount ?? 0}</td>
<td>${s.lastActivity ? new Date(s.lastActivity).toLocaleString() : '—'}</td>
</tr>
`).join('');
sessionsEl.innerHTML = `
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Messages</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
}
// --- Fetch and render tools list ---
function fetchTools() {
rpcCall('tools.list', null, (err, result) => {
if (err) {
toolsEl.textContent = 'Error: ' + (err.message || 'Failed to fetch tools');
return;
}
renderTools(result.tools || []);
});
}
function renderTools(tools) {
if (tools.length === 0) {
toolsEl.textContent = 'No tools registered';
return;
}
const items = tools.map(t => `
<div class="tool-item">
<strong>${escapeHtml(t.name)}</strong>
${t.description ? '<span> — ' + escapeHtml(t.description) + '</span>' : ''}
</div>
`).join('');
toolsEl.innerHTML = items;
}
// --- XSS protection: escape HTML entities ---
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
initStatusIndicator();
initRouter();
</script>
</body>
</html>
+195
View File
@@ -0,0 +1,195 @@
/**
* Flynn WebSocket RPC Client
*
* Promise-based JSON-RPC client with auto-reconnect, event streaming,
* and connection lifecycle management.
*/
export class FlynnClient {
constructor(url) {
this._url = url || `ws://${location.host}`;
this._ws = null;
this._requestId = 0;
this._pending = new Map(); // id -> { resolve, reject }
this._listeners = new Map(); // id -> { events: Map<event, callback[]> }
this._reconnectDelay = 1000;
this._maxReconnectDelay = 30000;
this._onStatusChange = null;
this._status = 'disconnected';
this._autoReconnect = true;
}
get status() { return this._status; }
onStatusChange(callback) {
this._onStatusChange = callback;
}
connect() {
this._autoReconnect = true;
this._doConnect();
}
disconnect() {
this._autoReconnect = false;
if (this._ws) {
this._ws.close();
this._ws = null;
}
this._setStatus('disconnected');
}
_doConnect() {
this._setStatus('connecting');
// Build URL with token from URL search params if present
let wsUrl = this._url;
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
const sep = wsUrl.includes('?') ? '&' : '?';
wsUrl = `${wsUrl}${sep}token=${encodeURIComponent(token)}`;
}
this._ws = new WebSocket(wsUrl);
this._ws.onopen = () => {
this._setStatus('connected');
this._reconnectDelay = 1000;
};
this._ws.onmessage = (event) => {
this._handleMessage(event.data);
};
this._ws.onclose = () => {
this._ws = null;
this._setStatus('disconnected');
// Reject all pending requests
for (const [id, pending] of this._pending) {
pending.reject(new Error('WebSocket closed'));
}
this._pending.clear();
this._listeners.clear();
if (this._autoReconnect) {
setTimeout(() => this._doConnect(), this._reconnectDelay);
this._reconnectDelay = Math.min(this._reconnectDelay * 2, this._maxReconnectDelay);
}
};
this._ws.onerror = () => {
// Error is always followed by close
};
}
_setStatus(status) {
if (this._status !== status) {
this._status = status;
this._onStatusChange?.(status);
}
}
_handleMessage(raw) {
let msg;
try {
msg = JSON.parse(raw);
} catch {
return;
}
// Streamed event (has 'event' field)
if (msg.event && msg.id != null) {
const listener = this._listeners.get(msg.id);
if (listener) {
const callbacks = listener.events.get(msg.event) || [];
for (const cb of callbacks) {
cb(msg.data);
}
// Also fire wildcard listeners
const wildcards = listener.events.get('*') || [];
for (const cb of wildcards) {
cb(msg.event, msg.data);
}
}
return;
}
// Response or error (matches pending request)
if (msg.id != null && this._pending.has(msg.id)) {
const pending = this._pending.get(msg.id);
this._pending.delete(msg.id);
if (msg.error) {
pending.reject(new Error(msg.error.message));
} else {
pending.resolve(msg.result);
}
}
}
/**
* Send an RPC call and return a promise for the result.
* For streaming methods (like agent.send), use stream() instead.
*/
async call(method, params) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected');
}
const id = ++this._requestId;
return new Promise((resolve, reject) => {
this._pending.set(id, { resolve, reject });
this._ws.send(JSON.stringify({ id, method, params }));
});
}
/**
* Send a streaming RPC call. Returns an object with:
* - on(event, callback): listen for streaming events
* - result: promise that resolves when 'done' event fires or rejects on 'error'
*/
stream(method, params) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected');
}
const id = ++this._requestId;
const events = new Map();
this._listeners.set(id, { events });
const handle = {
on(event, callback) {
if (!events.has(event)) events.set(event, []);
events.get(event).push(callback);
return handle;
},
result: new Promise((resolve, reject) => {
// Auto-wire done/error to resolve/reject the promise
if (!events.has('done')) events.set('done', []);
events.get('done').push((data) => {
this._listeners.delete(id);
resolve(data);
});
if (!events.has('error')) events.set('error', []);
events.get('error').push((data) => {
this._listeners.delete(id);
reject(new Error(data.message || 'Agent error'));
});
}),
};
this._ws.send(JSON.stringify({ id, method, params }));
return handle;
}
}
// Singleton instance
let _instance = null;
export function getClient() {
if (!_instance) {
_instance = new FlynnClient();
_instance.connect();
}
return _instance;
}
+292
View File
@@ -0,0 +1,292 @@
/**
* Flynn Chat Page
*
* Session selector, message input, streaming tool events,
* and markdown-rendered responses.
*/
/* global marked, hljs */
let _currentSession = null;
let _sending = false;
let _elements = {};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderMarkdown(text) {
try {
if (typeof marked !== 'undefined') {
return marked.parse(text);
}
} catch {
// Fall through to plain text
}
return `<p>${escapeHtml(text)}</p>`;
}
function highlightCode() {
if (typeof hljs !== 'undefined') {
document.querySelectorAll('.chat-messages pre code').forEach(block => {
hljs.highlightElement(block);
});
}
}
function createMessageEl(role, content) {
const div = document.createElement('div');
div.className = `message ${role}`;
if (role === 'assistant') {
div.innerHTML = renderMarkdown(content);
setTimeout(highlightCode, 0);
} else {
div.textContent = content;
}
return div;
}
function createToolEventEl(event, data) {
const group = document.createElement('div');
group.className = 'tool-event-group';
const header = document.createElement('div');
header.className = 'tool-event-header';
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';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
header.addEventListener('click', () => {
body.classList.toggle('open');
});
const body = document.createElement('div');
body.className = 'tool-event-body';
if (event === 'tool_start' && data.args) {
body.textContent = JSON.stringify(data.args, null, 2);
} else if (event === 'tool_end' && data.result) {
body.textContent = data.result.output || data.result.error || '(no output)';
}
group.appendChild(header);
group.appendChild(body);
return group;
}
function scrollToBottom() {
const msgs = _elements.messages;
if (msgs) {
msgs.scrollTop = msgs.scrollHeight;
}
}
async function loadSessions(client) {
const select = _elements.sessionSelect;
if (!select) return;
try {
const result = await client.call('sessions.list');
const sessions = result.sessions ?? [];
// Preserve current selection
const current = _currentSession;
select.innerHTML = '';
if (sessions.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No sessions';
select.appendChild(opt);
} else {
for (const s of sessions) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = `${s.id} (${s.messageCount} msgs)`;
if (s.id === current) opt.selected = true;
select.appendChild(opt);
}
}
// Update current session
_currentSession = select.value || null;
} catch {
// Ignore — sessions may not be available
}
}
async function loadHistory(client) {
const msgs = _elements.messages;
if (!msgs || !_currentSession) return;
msgs.innerHTML = '';
try {
const result = await client.call('sessions.history', { sessionId: _currentSession });
const messages = result.messages ?? [];
for (const msg of messages) {
const role = msg.role ?? 'assistant';
const content = msg.content ?? msg.text ?? '';
msgs.appendChild(createMessageEl(role, content));
}
scrollToBottom();
} catch {
msgs.innerHTML = '<div class="empty-state">Could not load history</div>';
}
}
async function sendMessage(client) {
const input = _elements.input;
const text = input?.value?.trim();
if (!text || _sending) return;
_sending = true;
_elements.sendBtn.disabled = true;
input.value = '';
// Show user message
_elements.messages.appendChild(createMessageEl('user', text));
scrollToBottom();
// Create placeholder for assistant response
const placeholder = document.createElement('div');
placeholder.className = 'message assistant streaming-cursor';
placeholder.innerHTML = '<span class="text-muted">Thinking...</span>';
_elements.messages.appendChild(placeholder);
scrollToBottom();
try {
const stream = client.stream('agent.send', { message: text });
stream.on('tool_start', (data) => {
const el = createToolEventEl('tool_start', data);
_elements.messages.insertBefore(el, placeholder);
scrollToBottom();
});
stream.on('tool_end', (data) => {
// Replace the last tool_start spinner with completion marker
const events = _elements.messages.querySelectorAll('.tool-event-group');
const last = events[events.length - 1];
if (last) {
const header = last.querySelector('.tool-event-header');
if (header && data.tool) {
const icon = data.result?.success !== false ? '&#10003;' : '&#10007;';
const cls = data.result?.success !== false ? 'status-ok' : 'status-error';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
// Add result body
const body = last.querySelector('.tool-event-body');
if (body && data.result) {
body.textContent = data.result.output || data.result.error || '(no output)';
}
}
scrollToBottom();
});
const done = await stream.result;
// Replace placeholder with actual response
placeholder.classList.remove('streaming-cursor');
const content = done?.content ?? done?.text ?? '(no response)';
placeholder.innerHTML = renderMarkdown(content);
setTimeout(highlightCode, 0);
} catch (err) {
placeholder.classList.remove('streaming-cursor');
placeholder.className = 'message error';
placeholder.textContent = `Error: ${err.message}`;
} finally {
_sending = false;
if (_elements.sendBtn) _elements.sendBtn.disabled = false;
scrollToBottom();
}
}
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 Session</button>
<button id="chat-load-history" class="btn btn-secondary">Load History</button>
</div>
<div class="chat-messages" id="chat-messages"></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>
</div>
`;
_elements = {
sessionSelect: el.querySelector('#chat-session-select'),
messages: el.querySelector('#chat-messages'),
input: el.querySelector('#chat-input'),
sendBtn: el.querySelector('#chat-send'),
};
// Load sessions into dropdown
await loadSessions(client);
// Event: session change
_elements.sessionSelect.addEventListener('change', () => {
_currentSession = _elements.sessionSelect.value || null;
});
// Event: new session
el.querySelector('#chat-new-session').addEventListener('click', async () => {
try {
const result = await client.call('sessions.create');
_currentSession = result.sessionId;
await loadSessions(client);
_elements.messages.innerHTML = '';
} catch (err) {
_elements.messages.innerHTML = `<div class="empty-state">Failed to create session: ${err.message}</div>`;
}
});
// Event: load history
el.querySelector('#chat-load-history').addEventListener('click', () => {
loadHistory(client);
});
// Event: send message
_elements.sendBtn.addEventListener('click', () => sendMessage(client));
// Event: Enter to send (Shift+Enter for newline)
_elements.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(client);
}
});
// Auto-resize textarea
_elements.input.addEventListener('input', () => {
const ta = _elements.input;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
});
// 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>';
}
},
teardown() {
_currentSession = null;
_sending = false;
_elements = {};
},
};
+110
View File
@@ -0,0 +1,110 @@
/**
* Flynn Dashboard Page
*
* Shows system health cards, channel status, and usage stats.
* Auto-refreshes every 10 seconds.
*/
let _timer = null;
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
parts.push(`${s}s`);
return parts.join(' ');
}
async function loadDashboard(el, client) {
let health, channels, usage;
try {
[health, channels, usage] = await Promise.all([
client.call('system.health'),
client.call('system.channels'),
client.call('system.usage'),
]);
} catch (err) {
el.innerHTML = `<div class="empty-state">Failed to load dashboard: ${err.message}</div>`;
return;
}
// Build stats grid
const stats = [
{ label: 'Status', value: health.status?.toUpperCase() ?? 'UNKNOWN', cls: health.status === 'ok' ? 'ok' : 'error' },
{ label: 'Version', value: health.version ?? '-', cls: '' },
{ label: 'Uptime', value: formatUptime(health.uptime ?? 0), cls: '' },
{ label: 'Connections', value: String(health.connections ?? 0), cls: '' },
{ label: 'Sessions', value: String(health.sessions ?? 0), cls: '' },
{ label: 'Tools', value: String(health.tools ?? 0), cls: '' },
];
const statsHtml = stats.map(s =>
`<div class="stat-card">
<div class="stat-label">${s.label}</div>
<div class="stat-value ${s.cls}">${s.value}</div>
</div>`
).join('');
// Build channels grid
const channelList = channels?.channels ?? [];
let channelsHtml = '';
if (channelList.length > 0) {
channelsHtml = channelList.map(ch =>
`<div class="channel-card">
<span class="channel-dot ${ch.status}"></span>
<span class="channel-name">${ch.name}</span>
</div>`
).join('');
} else {
channelsHtml = '<div class="text-muted text-sm">No channels registered</div>';
}
// Build usage section
const usageItems = [
{ label: 'Total Sessions', value: String(usage?.totalSessions ?? 0) },
{ label: 'Active Connections', value: String(usage?.activeConnections ?? 0) },
{ label: 'Available Tools', value: String(usage?.tools ?? 0) },
{ label: 'Uptime', value: formatUptime(usage?.uptime ?? 0) },
];
const usageHtml = usageItems.map(u =>
`<div class="stat-card">
<div class="stat-label">${u.label}</div>
<div class="stat-value">${u.value}</div>
</div>`
).join('');
el.innerHTML = `
<h1 class="page-title">Dashboard</h1>
<h2 class="section-title">System Health</h2>
<div class="stats-grid">${statsHtml}</div>
<h2 class="section-title">Channels</h2>
<div class="channels-grid">${channelsHtml}</div>
<h2 class="section-title">Usage</h2>
<div class="stats-grid">${usageHtml}</div>
`;
}
export const DashboardPage = {
async render(el, client) {
await loadDashboard(el, client);
// Auto-refresh every 10 seconds
_timer = setInterval(() => {
loadDashboard(el, client).catch(() => {});
}, 10000);
},
teardown() {
if (_timer) {
clearInterval(_timer);
_timer = null;
}
},
};
+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;
},
};
+172
View File
@@ -0,0 +1,172 @@
/**
* Flynn Settings Page
*
* Read-only config view (redacted), editable hook patterns,
* tool list, and channel overview.
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
let _client = null;
let _el = null;
async function loadSettings() {
if (!_client || !_el) return;
let config, tools, channels;
try {
[config, tools, channels] = await Promise.all([
_client.call('config.get'),
_client.call('tools.list'),
_client.call('system.channels'),
]);
} catch (err) {
_el.innerHTML = `
<h1 class="page-title">Settings</h1>
<div class="empty-state">Failed to load settings: ${err.message}</div>
`;
return;
}
// Extract hooks from config
const hooks = config?.hooks ?? {};
const confirmPatterns = hooks.confirm ?? [];
const logPatterns = hooks.log ?? [];
const silentPatterns = hooks.silent ?? [];
// Build config view (redacted JSON)
const configJson = JSON.stringify(config, null, 2);
// Build tool list
const toolList = tools?.tools ?? [];
// Build channel list
const channelList = channels?.channels ?? [];
_el.innerHTML = `
<h1 class="page-title">Settings</h1>
<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>
</div>
<div class="hook-group">
<label>Log (allow + log)</label>
<textarea id="hooks-log" rows="3">${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>
<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>
</div>
</div>
<h2 class="section-title">Tools (${toolList.length})</h2>
<div class="settings-section">
${toolList.length > 0 ? `
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
${toolList.map(t => `
<tr>
<td><code>${escapeHtml(t.name)}</code></td>
<td class="text-secondary">${escapeHtml(t.description ?? '')}</td>
</tr>
`).join('')}
</tbody>
</table>
` : '<div class="empty-state">No tools available</div>'}
</div>
<h2 class="section-title">Channels</h2>
<div class="settings-section">
${channelList.length > 0 ? `
<div class="channels-grid">
${channelList.map(ch => `
<div class="channel-card">
<span class="channel-dot ${ch.status}"></span>
<span class="channel-name">${escapeHtml(ch.name)}</span>
</div>
`).join('')}
</div>
` : '<div class="text-muted text-sm">No channels registered</div>'}
</div>
<h2 class="section-title">Configuration (read-only)</h2>
<div class="settings-section">
<div class="config-view"><code>${escapeHtml(configJson)}</code></div>
</div>
`;
// Bind save hooks
_el.querySelector('#hooks-save').addEventListener('click', saveHooks);
}
async function saveHooks() {
const status = _el.querySelector('#hooks-status');
status.textContent = 'Saving...';
status.className = 'text-sm text-muted';
try {
const confirm = _el.querySelector('#hooks-confirm').value.split('\n').map(s => s.trim()).filter(Boolean);
const log = _el.querySelector('#hooks-log').value.split('\n').map(s => s.trim()).filter(Boolean);
const silent = _el.querySelector('#hooks-silent').value.split('\n').map(s => s.trim()).filter(Boolean);
const result = await _client.call('config.patch', {
patches: {
'hooks.confirm': confirm,
'hooks.log': log,
'hooks.silent': silent,
},
});
const applied = result.applied ?? [];
const rejected = result.rejected ?? [];
if (rejected.length > 0) {
status.textContent = `Partially saved. Rejected: ${rejected.join(', ')}`;
status.className = 'text-sm text-error';
} else {
status.textContent = `Saved (${applied.length} updated)`;
status.className = 'text-sm text-success';
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
status.className = 'text-sm text-error';
}
// Clear status after 5s
setTimeout(() => {
if (status) status.textContent = '';
}, 5000);
}
export const SettingsPage = {
async render(el, client) {
_client = client;
_el = el;
await loadSettings();
},
teardown() {
_client = null;
_el = null;
},
};
+618
View File
@@ -530,3 +530,621 @@ header #status.status-ok {
font-size: var(--font-size-lg);
}
}
/* ==========================================================================
SPA Shell Layout
========================================================================== */
.app-shell {
display: flex;
height: 100vh;
overflow: hidden;
}
/* Sidebar navigation */
.sidebar {
width: 220px;
min-width: 220px;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
}
.logo {
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--accent);
letter-spacing: 1px;
}
.nav-links {
flex: 1;
padding: 8px 0;
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-base);
transition: all var(--transition);
border-left: 3px solid transparent;
}
.nav-link:hover {
color: var(--text-primary);
background-color: var(--bg-tertiary);
text-decoration: none;
opacity: 1;
}
.nav-link.active {
color: var(--accent);
background-color: var(--accent-muted);
border-left-color: var(--accent);
}
.nav-icon {
font-size: var(--font-size-base);
width: 20px;
text-align: center;
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
}
.conn-status {
font-size: var(--font-size-sm);
display: flex;
align-items: center;
gap: 6px;
}
.conn-status::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--text-muted);
}
.conn-status.connected::before {
background-color: var(--success);
}
.conn-status.connecting::before {
background-color: var(--warning);
}
.conn-status.disconnected::before {
background-color: var(--error);
}
/* Main content area */
.content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.page {
max-width: 1200px;
margin: 0 auto;
}
/* Page titles */
.page-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
/* ── Dashboard Cards ────────────────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
transition: border-color var(--transition);
}
.stat-card:hover {
border-color: var(--text-muted);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.stat-value {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--text-primary);
}
.stat-value.ok { color: var(--success); }
.stat-value.error { color: var(--error); }
.stat-value.warning { color: var(--warning); }
/* ── Channels Grid ──────────────────────────────────────────── */
.channels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 32px;
}
.channel-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.channel-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.channel-dot.connected { background-color: var(--success); }
.channel-dot.connecting { background-color: var(--warning); }
.channel-dot.disconnected { background-color: var(--text-muted); }
.channel-dot.error { background-color: var(--error); }
.channel-name {
font-weight: 600;
color: var(--text-primary);
text-transform: capitalize;
}
/* ── Section Headers ────────────────────────────────────────── */
.section-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
margin-top: 24px;
}
/* ── Data Tables ────────────────────────────────────────────── */
table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
th, td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border-light);
}
th {
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom-color: var(--border);
}
td {
color: var(--text-primary);
}
tr:hover td {
background-color: var(--bg-tertiary);
}
/* ── Chat Page ──────────────────────────────────────────────── */
.chat-layout {
display: flex;
flex-direction: column;
height: calc(100vh - 48px);
max-width: var(--container-max);
}
.chat-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.chat-header select {
background-color: var(--bg-input);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
outline: none;
}
.chat-header select:focus {
border-color: var(--accent);
}
.chat-header button {
padding: 6px 12px;
background-color: var(--bg-tertiary);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all var(--transition);
}
.chat-header button:hover {
background-color: var(--accent-muted);
border-color: var(--accent);
}
.chat-messages {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0;
}
.chat-input {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.chat-input textarea {
flex: 1;
padding: 10px 12px;
background-color: var(--bg-input);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--font-size-base);
border: 1px solid var(--border);
border-radius: var(--radius);
outline: none;
resize: none;
min-height: 42px;
max-height: 150px;
line-height: var(--line-height);
transition: border-color var(--transition);
}
.chat-input textarea::placeholder {
color: var(--text-muted);
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input button {
padding: 10px 18px;
background-color: var(--accent);
color: var(--bg-primary);
font-family: var(--font-mono);
font-size: var(--font-size-base);
font-weight: 600;
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: opacity var(--transition);
align-self: flex-end;
}
.chat-input button:hover {
opacity: 0.85;
}
.chat-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Streaming text cursor */
.streaming-cursor::after {
content: '|';
animation: blink 1s step-end infinite;
color: var(--accent);
}
@keyframes blink {
50% { opacity: 0; }
}
/* Tool event in chat (collapsible) */
.tool-event-group {
border: 1px solid var(--border-light);
border-radius: var(--radius);
margin: 4px 0;
overflow: hidden;
}
.tool-event-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background-color: var(--bg-tertiary);
cursor: pointer;
font-size: var(--font-size-sm);
color: var(--text-muted);
user-select: none;
}
.tool-event-header:hover {
color: var(--text-secondary);
}
.tool-event-body {
padding: 8px 10px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
background-color: var(--bg-secondary);
display: none;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
.tool-event-body.open {
display: block;
}
/* Code blocks in assistant messages */
.message.assistant pre {
background-color: var(--bg-tertiary);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 12px;
overflow-x: auto;
margin: 8px 0;
}
.message.assistant code {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
.message.assistant p code {
background-color: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
}
/* ── Settings Page ──────────────────────────────────────────── */
.settings-section {
margin-bottom: 32px;
}
.config-view {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
white-space: pre-wrap;
font-size: var(--font-size-sm);
color: var(--text-secondary);
max-height: 400px;
overflow-y: auto;
}
.hook-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.hook-group {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
}
.hook-group label {
display: block;
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 600;
text-transform: uppercase;
}
.hook-group textarea {
width: 100%;
padding: 8px;
background-color: var(--bg-input);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
outline: none;
resize: vertical;
min-height: 60px;
}
.hook-group textarea:focus {
border-color: var(--accent);
}
.btn {
padding: 8px 16px;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
font-weight: 600;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all var(--transition);
}
.btn-primary {
background-color: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
}
.btn-primary:hover {
opacity: 0.85;
}
.btn-danger {
background-color: var(--error-muted);
color: var(--error);
border-color: rgba(248, 81, 73, 0.35);
}
.btn-danger:hover {
background-color: var(--error);
color: white;
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover {
background-color: var(--accent-muted);
border-color: var(--accent);
}
/* ── Sessions Page ──────────────────────────────────────────── */
.session-actions {
display: flex;
gap: 6px;
}
.session-actions button {
padding: 4px 10px;
font-size: var(--font-size-sm);
}
.session-detail {
margin-top: 24px;
}
.session-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.message-history {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
padding: 12px;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
}
/* ── Empty States ───────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
font-size: var(--font-size-base);
}
/* ── Responsive: Mobile ─────────────────────────────────────── */
@media (max-width: 768px) {
.sidebar {
width: 60px;
min-width: 60px;
}
.sidebar-header {
padding: 12px 8px;
text-align: center;
}
.logo {
font-size: var(--font-size-base);
}
.nav-link {
padding: 12px;
justify-content: center;
}
.nav-link span:not(.nav-icon) {
display: none;
}
.nav-icon {
margin: 0;
}
.sidebar-footer {
padding: 8px;
text-align: center;
}
.conn-status {
font-size: 0;
}
.content {
padding: 16px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}