First Version
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
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`
|
||||
<div class="sd-session-header">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-secondary sd-back-btn" @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</button>
|
||||
<span class="badge ${sourceBadgeClass(session.source)}">${session.source}</span>
|
||||
<span class="fw-semibold font-monospace">agent: ${session.agent_id}</span>
|
||||
<span class="text-secondary small">id: ${session.id}</span>
|
||||
${session.is_ephemeral ? html`<span class="badge bg-light text-dark border">ephemeral</span>` : nothing}
|
||||
${!session.is_interactive ? html`<span class="badge bg-light text-dark border">automated</span>` : nothing}
|
||||
${this._live
|
||||
? html`<span class="sd-live-badge"><span class="sd-live-dot"></span>live</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="text-secondary small mt-1">${formatDate(session.created_at)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderUserMsg(item, idx) {
|
||||
const time = formatTime(item.created_at);
|
||||
return html`
|
||||
<div class="sd-msg sd-msg--user ${item.is_synthetic ? 'sd-msg--synthetic' : ''}">
|
||||
<div class="sd-msg-role">
|
||||
${item.is_synthetic
|
||||
? html`<span class="badge bg-warning text-dark me-1" style="font-size:0.65rem">synthetic</span>`
|
||||
: nothing}
|
||||
<span>User</span>
|
||||
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
|
||||
</div>
|
||||
<div class="sd-msg-content">${item.content}</div>
|
||||
${item.failed ? html`<div class="sd-msg-failed">failed</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_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`
|
||||
<div class="sd-msg sd-msg--assistant ${item.failed ? 'sd-msg--failed' : ''}">
|
||||
<div class="sd-msg-role">
|
||||
Assistant
|
||||
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
|
||||
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}↑ ${item.output_tokens}↓</span>` : nothing}
|
||||
</div>
|
||||
${hasReasoning ? html`
|
||||
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
|
||||
<i class="bi bi-brain me-1"></i>
|
||||
Reasoning
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
|
||||
</div>
|
||||
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
|
||||
` : nothing}
|
||||
${item.content ? html`<div class="sd-msg-content">${item.content}</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_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`
|
||||
<div class="sd-msg sd-msg--thinking ${item.failed ? 'sd-msg--failed' : ''}">
|
||||
<div class="sd-msg-role">
|
||||
<i class="bi bi-lightning-charge me-1"></i>Thinking
|
||||
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
|
||||
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}↑ ${item.output_tokens}↓</span>` : nothing}
|
||||
</div>
|
||||
${hasReasoning ? html`
|
||||
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
|
||||
<i class="bi bi-brain me-1"></i>
|
||||
Reasoning
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
|
||||
</div>
|
||||
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
|
||||
` : nothing}
|
||||
${item.content ? html`<div class="sd-msg-content">${item.content}</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_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`
|
||||
<div class="sd-tool ${statusClass}">
|
||||
<div class="sd-tool-header" @click=${() => this._toggleTool(key)}>
|
||||
<span class="sd-tool-icon">
|
||||
<i class="bi bi-${item.status === 'done' ? 'check-circle' : item.status === 'error' ? 'x-circle' : 'hourglass-split'}"></i>
|
||||
</span>
|
||||
<span class="sd-tool-name">${item.label_short ?? item.name}</span>
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-auto"></i>
|
||||
</div>
|
||||
${expanded ? html`
|
||||
<div class="sd-tool-body">
|
||||
${item.label_full && item.label_full !== item.label_short
|
||||
? html`<div class="sd-tool-label-full text-secondary small mb-2">${item.label_full}</div>`
|
||||
: nothing}
|
||||
<div class="sd-tool-section-label">Arguments</div>
|
||||
<pre class="sd-code-block">${jsonPretty(item.arguments)}</pre>
|
||||
<div class="sd-tool-section-label mt-2">
|
||||
${item.status === 'error' ? 'Error' : 'Result'}
|
||||
</div>
|
||||
<pre class="sd-code-block ${item.status === 'error' ? 'sd-code-block--error' : ''}">${
|
||||
item.result ?? item.error ?? '—'
|
||||
}</pre>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderAgentFrame(item) {
|
||||
return html`
|
||||
<div class="sd-agent-frame-start">
|
||||
<i class="bi bi-robot me-1"></i>
|
||||
<span>Sub-agent: <strong>${item.agent_id}</strong></span>
|
||||
<span class="text-secondary small ms-2">depth ${item.depth}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderAgentFrameEnd(item) {
|
||||
return html`<div class="sd-agent-frame-end">end of ${item.agent_id}</div>`;
|
||||
}
|
||||
|
||||
_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`
|
||||
<style>
|
||||
.sd-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
gap: 0;
|
||||
}
|
||||
.sd-back-btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
.sd-session-header {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.sd-msg {
|
||||
border-left: 3px solid transparent;
|
||||
padding: 0.625rem 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
.sd-msg--user { border-left-color: var(--bs-primary); background: var(--bs-tertiary-bg); }
|
||||
.sd-msg--synthetic { opacity: 0.75; border-left-color: var(--bs-warning); }
|
||||
.sd-msg--assistant { border-left-color: var(--bs-success); }
|
||||
.sd-msg--thinking { border-left-color: var(--bs-info); background: var(--bs-tertiary-bg); }
|
||||
.sd-msg--failed { border-left-color: var(--bs-danger) !important; opacity: 0.7; }
|
||||
.sd-msg-role {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.sd-msg-time {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
.sd-tokens {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-left: auto;
|
||||
}
|
||||
.sd-msg-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sd-msg-failed { font-size: 0.7rem; color: var(--bs-danger); margin-top: 0.25rem; }
|
||||
.sd-reasoning-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
color: var(--bs-secondary-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
user-select: none;
|
||||
}
|
||||
.sd-reasoning-toggle:hover { background: var(--bs-tertiary-bg); }
|
||||
.sd-reasoning-block {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.5rem;
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.sd-tool {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.sd-tool--done { border-left: 3px solid var(--bs-success); }
|
||||
.sd-tool--error { border-left: 3px solid var(--bs-danger); }
|
||||
.sd-tool--pending { border-left: 3px solid var(--bs-warning); }
|
||||
.sd-tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sd-tool-header:hover { background: var(--bs-tertiary-bg); }
|
||||
.sd-tool-icon { color: var(--bs-secondary-color); }
|
||||
.sd-tool-name { font-family: var(--bs-font-monospace); font-size: 0.8rem; }
|
||||
.sd-tool-body {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
.sd-tool-section-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.sd-tool-label-full { font-style: italic; }
|
||||
.sd-code-block {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.sd-code-block--error { border-color: var(--bs-danger); color: var(--bs-danger); }
|
||||
.sd-agent-frame-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-secondary-color);
|
||||
border-top: 1px dashed var(--bs-border-color);
|
||||
padding: 0.375rem 0;
|
||||
margin: 0.25rem 0 0.25rem 1rem;
|
||||
}
|
||||
.sd-agent-frame-end {
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-secondary-color);
|
||||
border-bottom: 1px dashed var(--bs-border-color);
|
||||
padding: 0.25rem 0;
|
||||
margin: 0 0 0.375rem 1rem;
|
||||
}
|
||||
.sd-live-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-success);
|
||||
border: 1px solid var(--bs-success);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
.sd-live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-success);
|
||||
animation: sd-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sd-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.25; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="sd-container">
|
||||
${this._loading ? html`
|
||||
<div class="text-center text-secondary py-5">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div>Loading session…
|
||||
</div>
|
||||
` : this._error ? html`
|
||||
<div class="alert alert-danger">${this._error}</div>
|
||||
` : !this._data ? html`
|
||||
<div class="text-secondary text-center py-5">No session loaded.<br>
|
||||
<span class="small">Navigate to <code>#session/{id}</code> to view a session.</span>
|
||||
</div>
|
||||
` : html`
|
||||
${this._renderSessionHeader(this._data.session)}
|
||||
${this._data.messages.length === 0
|
||||
? html`<div class="text-secondary text-center py-4">No messages in this session.</div>`
|
||||
: this._data.messages.map((m, i) => this._renderMessage(m, i))
|
||||
}
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user