diff --git a/docs/plans/2026-02-07-web-ui-dashboard.md b/docs/plans/2026-02-07-web-ui-dashboard.md new file mode 100644 index 0000000..729002d --- /dev/null +++ b/docs/plans/2026-02-07-web-ui-dashboard.md @@ -0,0 +1,108 @@ +# Flynn Web UI / Control Dashboard + +**Date:** 2026-02-07 +**Priority:** P7 +**Status:** In Progress + +## Overview + +Upgrade the existing minimal web UI (`src/gateway/ui/`) into a proper control dashboard with: +1. **Dashboard** — system health, sessions, tools, channels, usage stats +2. **Chat** — real-time streaming chat with token-by-token content, session management +3. **Sessions** — browse/switch/delete sessions, view history +4. **Settings** — view/edit configuration, manage channels, view/edit hooks + +## Architecture + +### Tech Stack + +- **No build step** — vanilla HTML/CSS/JS (consistent with existing UI) +- **SPA routing** — hash-based (`#/`, `#/chat`, `#/sessions`, `#/settings`) +- **WebSocket** — reuse existing JSON-RPC protocol for all data +- **CSS** — extend existing `style.css` design system (dark theme, design tokens) +- **Marked.js** — CDN for markdown rendering (already used) +- **Highlight.js** — CDN for syntax highlighting in code blocks + +### File Structure + +``` +src/gateway/ui/ +├── index.html # SPA shell (nav + router) +├── style.css # Extended design system +├── app.js # SPA router + WebSocket client +├── pages/ +│ ├── dashboard.js # Dashboard page +│ ├── chat.js # Chat page +│ ├── sessions.js # Sessions browser +│ └── settings.js # Settings page +└── lib/ + └── ws-client.js # WebSocket RPC client (shared) +``` + +### Backend Changes + +1. **`content` streaming events** — `agent.send` should emit `content` events with text chunks as the model generates them. Currently only emits `tool_start`, `tool_end`, `done`. Requires wiring the model client's streaming iterator through the agent. + +2. **New gateway methods**: + - `sessions.delete` — clear a session's history + - `sessions.switch` — switch the connection's active session + - `system.channels` — list active channel adapters and their status + - `system.usage` — aggregated usage stats (tokens, cost) + - `config.channels` — channel-specific configuration view + +3. **Static file serving** — extend `static.ts` to serve `.js` files from subdirectories (already supports `.js`, but need to handle nested paths under `pages/` and `lib/`) + +4. **Content-type additions** — add `.png`, `.ico`, `.woff2` to `CONTENT_TYPES` in `static.ts` (for future assets). + +## Implementation Phases + +### Phase 1: Backend enhancements +- Add `sessions.delete` handler +- Add `sessions.switch` handler +- Add `system.channels` handler +- Add `system.usage` handler +- Add streaming `content` events to `agent.send` +- Extend `static.ts` for nested paths +- Tests for all new handlers + +### Phase 2: SPA shell + WebSocket client +- Rewrite `index.html` as SPA shell with hash router and nav +- Create `lib/ws-client.js` — promise-based RPC client with reconnect +- Create `app.js` — page router, WebSocket lifecycle + +### Phase 3: Dashboard page +- Health cards (status, version, uptime, connections, sessions, tools) +- Channel status cards +- Usage summary (tokens, cost, calls) +- Auto-refresh on interval + +### Phase 4: Chat page +- Session selector dropdown +- New session button +- Real-time streaming text (token by token via `content` events) +- Tool events display (collapsible) +- Markdown rendering with syntax highlighting +- Session history loading on page entry +- Input area with send/cancel + +### Phase 5: Sessions page +- Session list with message count, last activity +- Click to view session history +- Delete session button +- Session detail view with full message history + +### Phase 6: Settings page +- Read-only config view (with redacted secrets) +- Editable hook patterns (confirm/log/silent) +- Channel status overview +- Tool list with descriptions + +## Estimated Effort + +- Phase 1: Backend — 1 hour +- Phase 2: SPA shell — 30 min +- Phase 3: Dashboard — 30 min +- Phase 4: Chat — 1 hour +- Phase 5: Sessions — 30 min +- Phase 6: Settings — 30 min +- **Total: ~4 hours** diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 14bd960..9294fb0 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -586,6 +586,9 @@ export async function startDaemon(config: Config): Promise { 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 { 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 { // ── Channel Registry ────────────────────────────────────────── - const channelRegistry = new ChannelRegistry(); - // Set up the unified message handler channelRegistry.setMessageHandler(createMessageRouter({ sessionManager, diff --git a/src/gateway/handlers/sessions.ts b/src/gateway/handlers/sessions.ts index 3e10df2..eab3c74 100644 --- a/src/gateway/handlers/sessions.ts +++ b/src/gateway/handlers/sessions.ts @@ -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 => { + 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 => { + 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); + } + }, }; } diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index 8a77fe2..2c9c3a1 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -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; + 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 => { + if (!deps.getChannels) { + return makeResponse(request.id, { channels: [] }); + } + return makeResponse(request.id, { channels: deps.getChannels() }); + }, + + 'system.usage': async (request: GatewayRequest): Promise => { + 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(), + }); + }, }; } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 8897c2e..b0deba1 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -41,6 +41,7 @@ export interface GatewayServerConfig { config?: Config; /** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */ restart?: () => Promise; + 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({ diff --git a/src/gateway/static.ts b/src/gateway/static.ts index 18f1b01..cdf7d79 100644 --- a/src/gateway/static.ts +++ b/src/gateway/static.ts @@ -7,8 +7,12 @@ const CONTENT_TYPES: Record = { '.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', }; /** diff --git a/src/gateway/ui/app.js b/src/gateway/ui/app.js new file mode 100644 index 0000000..dc3acc7 --- /dev/null +++ b/src/gateway/ui/app.js @@ -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 = '

Page not found

'; + 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}`; +} diff --git a/src/gateway/ui/index.html b/src/gateway/ui/index.html index d1641d8..fcf0e4a 100644 --- a/src/gateway/ui/index.html +++ b/src/gateway/ui/index.html @@ -3,217 +3,54 @@ - Flynn Dashboard + Flynn + + + -
-
-

Flynn Dashboard

- Connecting... - Chat -
-
- -
-
-

Sessions

-
Loading...
-
-
-

Tools

-
Loading...
-
+
+ +
+ +
- diff --git a/src/gateway/ui/lib/ws-client.js b/src/gateway/ui/lib/ws-client.js new file mode 100644 index 0000000..87945ff --- /dev/null +++ b/src/gateway/ui/lib/ws-client.js @@ -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 } + 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; +} diff --git a/src/gateway/ui/pages/chat.js b/src/gateway/ui/pages/chat.js new file mode 100644 index 0000000..1bd7cba --- /dev/null +++ b/src/gateway/ui/pages/chat.js @@ -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 `

${escapeHtml(text)}

`; +} + +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 = ` ${escapeHtml(data.tool)}`; + } else if (event === 'tool_end') { + const icon = data.result?.success ? '✓' : '✗'; + const cls = data.result?.success ? 'status-ok' : 'status-error'; + header.innerHTML = `${icon} ${escapeHtml(data.tool)}`; + } + + 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 = '
Could not load history
'; + } +} + +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 = 'Thinking...'; + _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 ? '✓' : '✗'; + const cls = data.result?.success !== false ? 'status-ok' : 'status-error'; + header.innerHTML = `${icon} ${escapeHtml(data.tool)}`; + } + // 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 = ` +
+
+ + + +
+
+
+ + +
+
+ `; + + _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 = `
Failed to create session: ${err.message}
`; + } + }); + + // 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 = '
Select a session or create a new one to start chatting
'; + } + }, + + teardown() { + _currentSession = null; + _sending = false; + _elements = {}; + }, +}; diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js new file mode 100644 index 0000000..3595180 --- /dev/null +++ b/src/gateway/ui/pages/dashboard.js @@ -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 = `
Failed to load dashboard: ${err.message}
`; + 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 => + `
+
${s.label}
+
${s.value}
+
` + ).join(''); + + // Build channels grid + const channelList = channels?.channels ?? []; + let channelsHtml = ''; + if (channelList.length > 0) { + channelsHtml = channelList.map(ch => + `
+ + ${ch.name} +
` + ).join(''); + } else { + channelsHtml = '
No channels registered
'; + } + + // 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 => + `
+
${u.label}
+
${u.value}
+
` + ).join(''); + + el.innerHTML = ` +

Dashboard

+

System Health

+
${statsHtml}
+

Channels

+
${channelsHtml}
+

Usage

+
${usageHtml}
+ `; +} + +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; + } + }, +}; diff --git a/src/gateway/ui/pages/sessions.js b/src/gateway/ui/pages/sessions.js new file mode 100644 index 0000000..7ef371f --- /dev/null +++ b/src/gateway/ui/pages/sessions.js @@ -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 = '
No sessions found
'; + return; + } + + let html = ` + + + + + + + + + + `; + + for (const s of sessions) { + html += ` + + + + + + `; + } + + html += '
Session IDMessagesActions
${escapeHtml(s.id)}${s.messageCount ?? 0} + + +
'; + 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 = `
Failed to load sessions: ${err.message}
`; + } +} + +async function viewSession(sessionId) { + const detailContainer = _el.querySelector('#session-detail'); + if (!detailContainer) return; + + detailContainer.innerHTML = '
Loading...
'; + + try { + const result = await _client.call('sessions.history', { sessionId }); + const messages = result.messages ?? []; + + let html = ` +
+
+

${escapeHtml(sessionId)}

+ ${messages.length} messages +
+
+ `; + + if (messages.length === 0) { + html += '
No messages in this session
'; + } else { + for (const msg of messages) { + const role = msg.role ?? 'system'; + const content = msg.content ?? msg.text ?? ''; + html += `
${escapeHtml(content)}
`; + } + } + + html += '
'; + detailContainer.innerHTML = html; + } catch (err) { + detailContainer.innerHTML = `
Failed to load session: ${err.message}
`; + } +} + +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 = ` +

Sessions

+
+
+ `; + + await loadSessionList(); + }, + + teardown() { + _client = null; + _el = null; + }, +}; diff --git a/src/gateway/ui/pages/settings.js b/src/gateway/ui/pages/settings.js new file mode 100644 index 0000000..e11f98a --- /dev/null +++ b/src/gateway/ui/pages/settings.js @@ -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 = ` +

Settings

+
Failed to load settings: ${err.message}
+ `; + 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 = ` +

Settings

+ +

Hook Patterns

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +

Tools (${toolList.length})

+
+ ${toolList.length > 0 ? ` + + + + + + + + + ${toolList.map(t => ` + + + + + `).join('')} + +
NameDescription
${escapeHtml(t.name)}${escapeHtml(t.description ?? '')}
+ ` : '
No tools available
'} +
+ +

Channels

+
+ ${channelList.length > 0 ? ` +
+ ${channelList.map(ch => ` +
+ + ${escapeHtml(ch.name)} +
+ `).join('')} +
+ ` : '
No channels registered
'} +
+ +

Configuration (read-only)

+
+
${escapeHtml(configJson)}
+
+ `; + + // 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; + }, +}; diff --git a/src/gateway/ui/style.css b/src/gateway/ui/style.css index ff63bba..e2828b9 100644 --- a/src/gateway/ui/style.css +++ b/src/gateway/ui/style.css @@ -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); + } +}