import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.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 = { 'event-triage': '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(); this.__onLocaleChanged = () => this.requestUpdate(); this.__onPageChange = (e) => { this._open = e.detail.page === PAGE_ID; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._loadFromHash(); else this._closeWs(); }; this.__onHashChange = () => { if (this._open) this._loadFromHash(); }; window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', this.__onPageChange); window.addEventListener('hashchange', this.__onHashChange); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); window.removeEventListener('llm-page-change', this.__onPageChange); window.removeEventListener('hashchange', this.__onHashChange); this._closeWs(); super.disconnectedCallback(); } _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) return; // Reload on the same id too when the socket is down: leaving the page closes // it, so coming back to the session we already hold would otherwise show a // frozen snapshot with nothing streaming into it. if (id === this._sessionId && this._ws) return; 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 { this._data = await this._fetchDetail(id); this._connectWs(id); } catch (e) { this._error = e.message; } finally { this._loading = false; } } // Re-read the transcript without disturbing the view (no spinner, no // collapsing of what the user opened). The bus has no replay, so every event // broadcast while the socket was down is lost — a resync is the only repair. async _resync(id) { try { const data = await this._fetchDetail(id); if (this._sessionId === id) this._data = data; } catch { /* the socket is up; the next event or resync will catch up */ } } async _fetchDetail(id) { const res = await fetch(`/api/sessions/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } // ── Live WebSocket ───────────────────────────────────────────────────────── _connectWs(id, resync = false) { 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; // A reconnect means events were missed while we were away. if (resync) this._resync(id); }; 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, true), 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; } // True when the transcript is scrolled to (or near) its end — the only case // in which a new event should pull the view along. _atBottom() { const box = this.querySelector('.sd-container'); if (!box) return true; return box.scrollHeight - box.scrollTop - box.clientHeight < 120; } _scrollToBottom() { this.updateComplete.then(() => { const box = this.querySelector('.sd-container'); if (box) box.scrollTop = box.scrollHeight; }); } _handleEvent(ev) { if (!this._data) return; const msgs = [...this._data.messages]; const now = new Date().toISOString(); const stick = this._atBottom(); 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; } // Terminal but not failures. Without these a stopped or denied call stays // on "pending" until the page is reloaded. case 'tool_cancelled': { const i = msgs.findIndex(m => m.kind === 'tool' && m.tool_call_id === ev.tool_call_id); if (i >= 0) msgs[i] = { ...msgs[i], status: 'cancelled' }; break; } case 'tool_rejected': { 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.reason, status: 'rejected' }; 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 }; if (stick) this._scrollToBottom(); } _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 ?? '—'
}