import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; const PAGE_ID = 'session'; function formatDate(iso) { if (!iso) return '—'; return new Date(iso).toLocaleString(undefined, { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } function formatTime(iso) { if (!iso) return null; return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', }); } function sourceBadgeClass(source) { const map = { tic: 'bg-warning text-dark', cron: 'bg-info text-dark', web: 'bg-primary', telegram: 'bg-success', mobile: 'bg-secondary' }; return map[source] ?? 'bg-secondary'; } function jsonPretty(val) { if (val == null) return '—'; if (typeof val === 'string') return val; return JSON.stringify(val, null, 2); } export class SessionDetailPage extends LightElement { static properties = { _open: { state: true }, _sessionId: { state: true }, _data: { state: true }, _loading: { state: true }, _error: { state: true }, _live: { state: true }, _expandedTools: { state: true }, _expandedReasons: { state: true }, }; constructor() { super(); this._open = false; this._sessionId = null; this._data = null; this._loading = false; this._error = null; this._live = false; this._expandedTools = new Set(); this._expandedReasons = new Set(); this._ws = null; this._wsReconnectTimer = null; } connectedCallback() { super.connectedCallback(); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === PAGE_ID; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._loadFromHash(); else this._closeWs(); }); window.addEventListener('hashchange', () => { if (this._open) this._loadFromHash(); }); } disconnectedCallback() { super.disconnectedCallback(); this._closeWs(); } _idFromHash() { const parts = location.hash.replace('#', '').split('/'); if (parts[0] === PAGE_ID && parts[1]) return parseInt(parts[1], 10); return null; } _loadFromHash() { const id = this._idFromHash(); if (id != null && id !== this._sessionId) { this._sessionId = id; this._closeWs(); this._fetch(id); } } async _fetch(id) { this._loading = true; this._error = null; this._data = null; this._expandedTools = new Set(); this._expandedReasons = new Set(); try { const res = await fetch(`/api/sessions/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._data = await res.json(); this._connectWs(id); } catch (e) { this._error = e.message; } finally { this._loading = false; } } // ── Live WebSocket ───────────────────────────────────────────────────────── _connectWs(id) { this._closeWs(); const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${proto}://${location.host}/api/ws/session/${id}`); this._ws = ws; ws.onopen = () => { this._live = true; }; ws.onmessage = (e) => { try { this._handleEvent(JSON.parse(e.data)); } catch {} }; ws.onclose = () => { this._live = false; this._ws = null; // Reconnect after 3 s if the page is still open and showing this session. if (this._open && this._sessionId === id) { this._wsReconnectTimer = setTimeout(() => this._connectWs(id), 3000); } }; ws.onerror = () => ws.close(); } _closeWs() { clearTimeout(this._wsReconnectTimer); if (this._ws) { this._ws.onclose = null; this._ws.close(); this._ws = null; } this._live = false; } _handleEvent(ev) { if (!this._data) return; const msgs = [...this._data.messages]; const now = new Date().toISOString(); switch (ev.type) { case 'tool_start': msgs.push({ kind: 'tool', tool_call_id: ev.tool_call_id, message_id: ev.message_id, name: ev.name, label_short: ev.label_short, label_full: ev.label_full, arguments: ev.arguments, result: null, error: null, status: 'pending', created_at: now, }); break; case 'tool_done': { const i = msgs.findIndex(m => m.kind === 'tool' && m.tool_call_id === ev.tool_call_id); if (i >= 0) msgs[i] = { ...msgs[i], result: ev.result, status: 'done' }; break; } case 'tool_error': { const i = msgs.findIndex(m => m.kind === 'tool' && m.tool_call_id === ev.tool_call_id); if (i >= 0) msgs[i] = { ...msgs[i], error: ev.error, status: 'error' }; break; } case 'thinking': msgs.push({ kind: 'thinking', message_id: ev.message_id, content: ev.content, reasoning: '', input_tokens: ev.input_tokens ?? null, output_tokens: ev.output_tokens ?? null, created_at: now, }); break; case 'done': msgs.push({ kind: 'assistant', message_id: ev.message_id, content: ev.content, reasoning: '', input_tokens: ev.input_tokens ?? null, output_tokens: ev.output_tokens ?? null, created_at: now, }); break; case 'user_message': msgs.push({ kind: 'user', content: ev.content, is_synthetic: false, created_at: now, }); break; case 'agent_start': msgs.push({ kind: 'agent', agent_id: ev.agent_id, depth: ev.depth, }); break; case 'agent_done': msgs.push({ kind: 'agent_end', agent_id: ev.agent_id, }); break; default: return; // ignore unknown events } this._data = { ...this._data, messages: msgs }; } _toggleTool(id) { const next = new Set(this._expandedTools); next.has(id) ? next.delete(id) : next.add(id); this._expandedTools = next; } _toggleReason(id) { const next = new Set(this._expandedReasons); next.has(id) ? next.delete(id) : next.add(id); this._expandedReasons = next; } // ── Renderers ───────────────────────────────────────────────────────────────── _back() { history.back(); } _renderSessionHeader(session) { return html`
${item.reasoning}` : nothing}
` : nothing}
${item.content ? html`${item.reasoning}` : nothing}
` : nothing}
${item.content ? html`${jsonPretty(item.arguments)}
${
item.result ?? item.error ?? '—'
}
#session/{id} to view a session.