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'; import { setSlice, clearSlice } from '../lib/view-context.js'; const PAGE_ID = 'session'; /// The view-context slice this page owns (see `lib/view-context.js`). const VIEW_SLICE = 'entity@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(); clearSlice(VIEW_SLICE); } }; 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(); clearSlice(VIEW_SLICE); 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) { clearSlice(VIEW_SLICE); return; } // The entity slice: which conversation is open. Published before the fetch // — a message sent while the transcript loads is still about this session. setSlice(VIEW_SLICE, [{ label: 'Open conversation', value: `#${id}` }]); // 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`
${session.source} ${t('session.agent')} ${session.agent_id} ${t('session.id')} ${session.id} ${session.is_ephemeral ? html`${t('session.ephemeral')}` : nothing} ${!session.is_interactive ? html`${t('session.automated')}` : nothing} ${this._live ? html`${t('session.live')}` : nothing}
${formatDate(session.created_at)}
`; } _renderUserMsg(item, idx) { const time = formatTime(item.created_at); return html`
${item.is_synthetic ? html`${t('session.synthetic')}` : nothing} ${t('session.user_role')} ${time ? html`${time}` : nothing}
${item.content}
${item.failed ? html`
${t('session.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`
${t('session.assistant_role')} ${time ? html`${time}` : nothing} ${item.input_tokens != null ? html`${item.input_tokens}↑ ${item.output_tokens}↓` : nothing}
${hasReasoning ? html`
this._toggleReason(key)}> ${t('session.reasoning_label')}
${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`
${t('session.thinking_role')} ${time ? html`${time}` : nothing} ${item.input_tokens != null ? html`${item.input_tokens}↑ ${item.output_tokens}↓` : nothing}
${hasReasoning ? html`
this._toggleReason(key)}> ${t('session.reasoning_label')}
${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', cancelled: 'sd-tool--stopped', rejected: 'sd-tool--stopped', }[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`
${t('session.sub_agent')} ${item.agent_id} ${t('session.depth', { n: item.depth })}
`; } _renderAgentFrameEnd(item) { return html`
${t('session.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`
${t('session.loading')}
` : this._error ? html`
${this._error}
` : !this._data ? html`
${t('session.no_session')}
${unsafeHTML(t('session.no_session_hint'))}
` : html` ${this._renderSessionHeader(this._data.session)} ${this._data.messages.length === 0 ? html`
${t('session.empty')}
` : this._data.messages.map((m, i) => this._renderMessage(m, i)) } `}
`; } }