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`
${session.source} agent: ${session.agent_id} id: ${session.id} ${session.is_ephemeral ? html`ephemeral` : nothing} ${!session.is_interactive ? html`automated` : nothing} ${this._live ? html`live` : nothing}
${formatDate(session.created_at)}
`; } _renderUserMsg(item, idx) { const time = formatTime(item.created_at); return html`
${item.is_synthetic ? html`synthetic` : nothing} User ${time ? html`${time}` : nothing}
${item.content}
${item.failed ? html`
failed
` : nothing}
`; } _renderAssistantMsg(item, idx) { const key = `ast-${idx}`; const hasReasoning = item.reasoning && item.reasoning.trim().length > 0; const expanded = this._expandedReasons.has(key); const time = formatTime(item.created_at); return html`
Assistant ${time ? html`${time}` : nothing} ${item.input_tokens != null ? html`${item.input_tokens}↑ ${item.output_tokens}↓` : nothing}
${hasReasoning ? html`
this._toggleReason(key)}> Reasoning
${expanded ? html`
${item.reasoning}
` : nothing} ` : nothing} ${item.content ? html`
${item.content}
` : nothing}
`; } _renderThinkingMsg(item, idx) { const key = `think-${item.message_id ?? idx}`; const hasReasoning = item.reasoning && item.reasoning.trim().length > 0; const expanded = this._expandedReasons.has(key); const time = formatTime(item.created_at); return html`
Thinking ${time ? html`${time}` : nothing} ${item.input_tokens != null ? html`${item.input_tokens}↑ ${item.output_tokens}↓` : nothing}
${hasReasoning ? html`
this._toggleReason(key)}> Reasoning
${expanded ? html`
${item.reasoning}
` : nothing} ` : nothing} ${item.content ? html`
${item.content}
` : nothing}
`; } _renderToolMsg(item, idx) { const key = item.tool_call_id ?? idx; const expanded = this._expandedTools.has(key); const statusClass = { done: 'sd-tool--done', error: 'sd-tool--error', pending: 'sd-tool--pending' }[item.status] ?? ''; return html`
this._toggleTool(key)}> ${item.label_short ?? item.name}
${expanded ? html`
${item.label_full && item.label_full !== item.label_short ? html`
${item.label_full}
` : nothing}
${jsonPretty(item.arguments)}
${
              item.result ?? item.error ?? '—'
            }
` : nothing}
`; } _renderAgentFrame(item) { return html`
Sub-agent: ${item.agent_id} depth ${item.depth}
`; } _renderAgentFrameEnd(item) { return html`
end of ${item.agent_id}
`; } _renderMessage(item, idx) { switch (item.kind) { case 'user': return this._renderUserMsg(item, idx); case 'assistant': return this._renderAssistantMsg(item, idx); case 'thinking': return this._renderThinkingMsg(item, idx); case 'tool': return this._renderToolMsg(item, idx); case 'agent': return this._renderAgentFrame(item); case 'agent_end': return this._renderAgentFrameEnd(item); default: return nothing; } } render() { return html`
${this._loading ? html`
Loading session…
` : this._error ? html`
${this._error}
` : !this._data ? html`
No session loaded.
Navigate to #session/{id} to view a session.
` : html` ${this._renderSessionHeader(this._data.session)} ${this._data.messages.length === 0 ? html`
No messages in this session.
` : this._data.messages.map((m, i) => this._renderMessage(m, i)) } `}
`; } }