From 3f74dc26f2856f5e6dcab7956863a4fa9f0fe424 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Tue, 4 Aug 2026 19:55:22 +0100 Subject: [PATCH] fix: keep the session-detail page live, instead of freezing on a snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/frontend/api/ws_session.rs | 14 ++++ web/components/session-detail.js | 115 ++++++++++++++++++++++++------- 2 files changed, 103 insertions(+), 26 deletions(-) diff --git a/src/frontend/api/ws_session.rs b/src/frontend/api/ws_session.rs index 015761d..2a45cbe 100644 --- a/src/frontend/api/ws_session.rs +++ b/src/frontend/api/ws_session.rs @@ -35,6 +35,14 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, session_id: i64 }; 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 { tokio::select! { // Detect client disconnect. @@ -63,6 +71,12 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc, session_id: i64 Err(broadcast::error::RecvError::Closed) => break, } } + + _ = keepalive.tick() => { + if socket.send(Message::Ping(Default::default())).await.is_err() { + break; + } + } } } diff --git a/web/components/session-detail.js b/web/components/session-detail.js index 8410af4..a534d25 100644 --- a/web/components/session-detail.js +++ b/web/components/session-detail.js @@ -60,27 +60,26 @@ export class SessionDetailPage extends LightElement { connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); - window.addEventListener('locale-changed', this.__onLocaleChanged); - window.addEventListener('llm-page-change', (e) => { + 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(); - }); - window.addEventListener('hashchange', () => { + }; + 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); - super.disconnectedCallback(); + window.removeEventListener('locale-changed', this.__onLocaleChanged); + window.removeEventListener('llm-page-change', this.__onPageChange); + window.removeEventListener('hashchange', this.__onHashChange); this._closeWs(); - } - - disconnectedCallback() { super.disconnectedCallback(); - this._closeWs(); } _idFromHash() { @@ -91,11 +90,14 @@ export class SessionDetailPage extends LightElement { _loadFromHash() { const id = this._idFromHash(); - if (id != null && id !== this._sessionId) { - this._sessionId = id; - this._closeWs(); - this._fetch(id); - } + 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) { @@ -105,9 +107,7 @@ export class SessionDetailPage extends LightElement { 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._data = await this._fetchDetail(id); this._connectWs(id); } catch (e) { 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 ───────────────────────────────────────────────────────── - _connectWs(id) { + _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; }; + 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 {} @@ -135,7 +155,7 @@ export class SessionDetailPage extends LightElement { 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); + this._wsReconnectTimer = setTimeout(() => this._connectWs(id, true), 3000); } }; @@ -148,10 +168,26 @@ export class SessionDetailPage extends LightElement { 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 msgs = [...this._data.messages]; + const now = new Date().toISOString(); + const stick = this._atBottom(); switch (ev.type) { case 'tool_start': @@ -182,6 +218,20 @@ export class SessionDetailPage extends LightElement { 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', @@ -235,6 +285,7 @@ export class SessionDetailPage extends LightElement { } this._data = { ...this._data, messages: msgs }; + if (stick) this._scrollToBottom(); } _toggleTool(id) { @@ -346,12 +397,23 @@ export class SessionDetailPage extends LightElement { _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] ?? ''; + 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} @@ -500,6 +562,7 @@ export class SessionDetailPage extends LightElement { .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--stopped { border-left: 3px solid var(--bs-secondary-color); opacity: 0.75; } .sd-tool-header { display: flex; align-items: center;