fix: keep the session-detail page live, instead of freezing on a snapshot
Nightly Build / build (push) Successful in 7m49s
Nightly Build / build (push) Successful in 7m49s
Leaving `#session/{id}` closes its watch socket, but coming back never
reopened it: the loader bailed out on an unchanged id, so the page showed
the transcript as it was when you left, with nothing streaming into it.
Reload whenever the socket is down, not only when the id changes.
The socket also had no keepalive, unlike the chat one — and a watched
session can go minutes without an event, which is exactly what an idle
proxy drops. Ping every 25s, and resync from the API on reconnect, since
the bus is a broadcast with no replay and everything sent during the gap
is gone.
Also: remove the duplicate `disconnectedCallback` that shadowed the first
and leaked the locale listener, handle `tool_cancelled`/`tool_rejected`
(a stopped or denied call stayed on "pending" forever), and follow the
tail only when the reader is already at the bottom.
This commit is contained in:
@@ -35,6 +35,14 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, session_id: i64
|
|||||||
};
|
};
|
||||||
let mut rx = ctx.chat_hub.events("session-watch");
|
let mut rx = ctx.chat_hub.events("session-watch");
|
||||||
|
|
||||||
|
// Keepalive, for the same reason the chat socket has one: a session being
|
||||||
|
// watched can go minutes without an event (a slow `execute_cmd`), and a
|
||||||
|
// silent socket is what an idle proxy or the browser drops — leaving the
|
||||||
|
// page frozen on a snapshot with no `onclose` to trigger its reconnect.
|
||||||
|
let mut keepalive = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||||
|
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
keepalive.tick().await; // consume the immediate first tick (don't ping on connect)
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// Detect client disconnect.
|
// Detect client disconnect.
|
||||||
@@ -63,6 +71,12 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, session_id: i64
|
|||||||
Err(broadcast::error::RecvError::Closed) => break,
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = keepalive.tick() => {
|
||||||
|
if socket.send(Message::Ping(Default::default())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,27 +60,26 @@ export class SessionDetailPage extends LightElement {
|
|||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.__onLocaleChanged = () => this.requestUpdate();
|
this.__onLocaleChanged = () => this.requestUpdate();
|
||||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
this.__onPageChange = (e) => {
|
||||||
window.addEventListener('llm-page-change', (e) => {
|
|
||||||
this._open = e.detail.page === PAGE_ID;
|
this._open = e.detail.page === PAGE_ID;
|
||||||
this.style.display = this._open ? 'flex' : 'none';
|
this.style.display = this._open ? 'flex' : 'none';
|
||||||
if (this._open) this._loadFromHash();
|
if (this._open) this._loadFromHash();
|
||||||
else this._closeWs();
|
else this._closeWs();
|
||||||
});
|
};
|
||||||
window.addEventListener('hashchange', () => {
|
this.__onHashChange = () => {
|
||||||
if (this._open) this._loadFromHash();
|
if (this._open) this._loadFromHash();
|
||||||
});
|
};
|
||||||
|
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||||
|
window.addEventListener('llm-page-change', this.__onPageChange);
|
||||||
|
window.addEventListener('hashchange', this.__onHashChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||||
super.disconnectedCallback();
|
window.removeEventListener('llm-page-change', this.__onPageChange);
|
||||||
|
window.removeEventListener('hashchange', this.__onHashChange);
|
||||||
this._closeWs();
|
this._closeWs();
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this._closeWs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_idFromHash() {
|
_idFromHash() {
|
||||||
@@ -91,11 +90,14 @@ export class SessionDetailPage extends LightElement {
|
|||||||
|
|
||||||
_loadFromHash() {
|
_loadFromHash() {
|
||||||
const id = this._idFromHash();
|
const id = this._idFromHash();
|
||||||
if (id != null && id !== this._sessionId) {
|
if (id == null) return;
|
||||||
this._sessionId = id;
|
// Reload on the same id too when the socket is down: leaving the page closes
|
||||||
this._closeWs();
|
// it, so coming back to the session we already hold would otherwise show a
|
||||||
this._fetch(id);
|
// 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) {
|
async _fetch(id) {
|
||||||
@@ -105,9 +107,7 @@ export class SessionDetailPage extends LightElement {
|
|||||||
this._expandedTools = new Set();
|
this._expandedTools = new Set();
|
||||||
this._expandedReasons = new Set();
|
this._expandedReasons = new Set();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${id}`);
|
this._data = await this._fetchDetail(id);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
this._data = await res.json();
|
|
||||||
this._connectWs(id);
|
this._connectWs(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._error = e.message;
|
this._error = e.message;
|
||||||
@@ -116,15 +116,35 @@ export class SessionDetailPage extends LightElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ─────────────────────────────────────────────────────────
|
// ── Live WebSocket ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_connectWs(id) {
|
_connectWs(id, resync = false) {
|
||||||
this._closeWs();
|
this._closeWs();
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const ws = new WebSocket(`${proto}://${location.host}/api/ws/session/${id}`);
|
const ws = new WebSocket(`${proto}://${location.host}/api/ws/session/${id}`);
|
||||||
this._ws = ws;
|
this._ws = ws;
|
||||||
|
|
||||||
ws.onopen = () => { this._live = true; };
|
ws.onopen = () => {
|
||||||
|
this._live = true;
|
||||||
|
// A reconnect means events were missed while we were away.
|
||||||
|
if (resync) this._resync(id);
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
try { this._handleEvent(JSON.parse(e.data)); } catch {}
|
try { this._handleEvent(JSON.parse(e.data)); } catch {}
|
||||||
@@ -135,7 +155,7 @@ export class SessionDetailPage extends LightElement {
|
|||||||
this._ws = null;
|
this._ws = null;
|
||||||
// Reconnect after 3 s if the page is still open and showing this session.
|
// Reconnect after 3 s if the page is still open and showing this session.
|
||||||
if (this._open && this._sessionId === id) {
|
if (this._open && this._sessionId === id) {
|
||||||
this._wsReconnectTimer = setTimeout(() => this._connectWs(id), 3000);
|
this._wsReconnectTimer = setTimeout(() => this._connectWs(id, true), 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,10 +168,26 @@ export class SessionDetailPage extends LightElement {
|
|||||||
this._live = false;
|
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) {
|
_handleEvent(ev) {
|
||||||
if (!this._data) return;
|
if (!this._data) return;
|
||||||
const msgs = [...this._data.messages];
|
const msgs = [...this._data.messages];
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const stick = this._atBottom();
|
||||||
|
|
||||||
switch (ev.type) {
|
switch (ev.type) {
|
||||||
case 'tool_start':
|
case 'tool_start':
|
||||||
@@ -182,6 +218,20 @@ export class SessionDetailPage extends LightElement {
|
|||||||
break;
|
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':
|
case 'thinking':
|
||||||
msgs.push({
|
msgs.push({
|
||||||
kind: 'thinking',
|
kind: 'thinking',
|
||||||
@@ -235,6 +285,7 @@ export class SessionDetailPage extends LightElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._data = { ...this._data, messages: msgs };
|
this._data = { ...this._data, messages: msgs };
|
||||||
|
if (stick) this._scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
_toggleTool(id) {
|
_toggleTool(id) {
|
||||||
@@ -346,12 +397,23 @@ export class SessionDetailPage extends LightElement {
|
|||||||
_renderToolMsg(item, idx) {
|
_renderToolMsg(item, idx) {
|
||||||
const key = item.tool_call_id ?? idx;
|
const key = item.tool_call_id ?? idx;
|
||||||
const expanded = this._expandedTools.has(key);
|
const expanded = this._expandedTools.has(key);
|
||||||
const statusClass = { done: 'sd-tool--done', error: 'sd-tool--error', pending: 'sd-tool--pending' }[item.status] ?? '';
|
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`
|
return html`
|
||||||
<div class="sd-tool ${statusClass}">
|
<div class="sd-tool ${statusClass}">
|
||||||
<div class="sd-tool-header" @click=${() => this._toggleTool(key)}>
|
<div class="sd-tool-header" @click=${() => this._toggleTool(key)}>
|
||||||
<span class="sd-tool-icon">
|
<span class="sd-tool-icon">
|
||||||
<i class="bi bi-${item.status === 'done' ? 'check-circle' : item.status === 'error' ? 'x-circle' : 'hourglass-split'}"></i>
|
<i class="bi bi-${{
|
||||||
|
done: 'check-circle',
|
||||||
|
error: 'x-circle',
|
||||||
|
cancelled: 'slash-circle',
|
||||||
|
rejected: 'slash-circle',
|
||||||
|
}[item.status] ?? 'hourglass-split'}"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="sd-tool-name">${item.label_short ?? item.name}</span>
|
<span class="sd-tool-name">${item.label_short ?? item.name}</span>
|
||||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-auto"></i>
|
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-auto"></i>
|
||||||
@@ -500,6 +562,7 @@ export class SessionDetailPage extends LightElement {
|
|||||||
.sd-tool--done { border-left: 3px solid var(--bs-success); }
|
.sd-tool--done { border-left: 3px solid var(--bs-success); }
|
||||||
.sd-tool--error { border-left: 3px solid var(--bs-danger); }
|
.sd-tool--error { border-left: 3px solid var(--bs-danger); }
|
||||||
.sd-tool--pending { border-left: 3px solid var(--bs-warning); }
|
.sd-tool--pending { border-left: 3px solid var(--bs-warning); }
|
||||||
|
.sd-tool--stopped { border-left: 3px solid var(--bs-secondary-color); opacity: 0.75; }
|
||||||
.sd-tool-header {
|
.sd-tool-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user