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;