Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
2 changed files with 103 additions and 26 deletions
Showing only changes of commit 3f74dc26f2 - Show all commits
+14
View File
@@ -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;
}
}
} }
} }
+83 -20
View File
@@ -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,12 +90,15 @@ 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;
// 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._sessionId = id;
this._closeWs(); this._closeWs();
this._fetch(id); this._fetch(id);
} }
}
async _fetch(id) { async _fetch(id) {
this._loading = true; this._loading = true;
@@ -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;