From 8f0f9956dcc204f36db7bea5e5e97b3cd99734dd Mon Sep 17 00:00:00 2001 From: Edwin Date: Sat, 30 May 2026 17:00:10 -0700 Subject: [PATCH] Web UI: tail-paginate session transcripts with a progress banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a session in the web UI used to fetch the entire transcript in one `session.transcript` round trip and render every event in a single synchronous main-thread loop. For an `ultracode`-shaped session (1.5 GB transcript on disk, millions of events) that meant several seconds of wire wait, then a UI freeze while ~3.9M events parsed and built DOM, covered only by a static skeleton shimmer. Switch the first round trip to a tail page: a new `tail: Option` field on `TranscriptParams` tells the daemon to return just the most recent N events, and `read_transcript_tail` seeks backward through `pty.log` style 64 KiB chunks to read those N events without scanning the whole file. `total` comes from the live `transcript_count` counter, so even on a multi-GB transcript the response is bounded and instant. Then the web UI: - Renders the tail (500 events) immediately, scrolled to the bottom — the user sees the live tail almost as fast as the previous skeleton would have appeared. - If `total > tail.length`, pins a sticky progress banner ("Loading older history… 1,200 / 12,400 (10%)") to the top of the pane and walks forward from `from: 0` in 500-event pages, prepending each batch above the tail. Scroll position is preserved relative to the bottom so prepends never make the visible content jump. - Yields between pages with `requestAnimationFrame` so the page stays responsive while history fills in. The existing skeleton still flashes if the first tail fetch is slow enough to notice (>150 ms). Tests: - Storage: 5 cases for `read_transcript_tail` (last-N order, chunk-boundary safety, n>total, missing file, n=0). - Session manager: `transcript(.., tail)` returns `total` from the live counter and the correct trailing slice. `Client::transcript` keeps its current shape (passes `tail: None`) so the zarvis / MCP / TUI Rust callers don't need to opt in. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/client/src/lib.rs | 1 + crates/daemon/assets/index.html | 139 ++++++++++++++++++++++++++---- crates/daemon/src/server.rs | 5 +- crates/daemon/src/session.rs | 62 +++++++++++++- crates/daemon/src/storage.rs | 146 ++++++++++++++++++++++++++++++++ crates/protocol/src/lib.rs | 6 ++ 6 files changed, 341 insertions(+), 18 deletions(-) diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index ee64bfc0..53ebf8dd 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -775,6 +775,7 @@ impl Client { session_id: id.to_string(), from, limit, + tail: None, }, ) .await diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index c3cd2997..565e6cac 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -422,6 +422,22 @@ 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + /* Sticky progress banner while older transcript pages stream in. The + pane is `position: relative` (default for scrollers), so `position: + sticky; top: 0` keeps the banner pinned to the top of the viewport + whether the user is at the tail or has scrolled up into history. */ + .transcript-progress { + position: sticky; + top: 0; + z-index: 5; + padding: 6px 14px; + font-size: 12px; + color: var(--dim); + background: color-mix(in srgb, var(--bg-elev) 92%, transparent); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(4px); + pointer-events: none; + } .transcript-pane::-webkit-scrollbar { width: 16px; } @@ -3606,6 +3622,36 @@ return el; } +// Tail page = the most recent N events the user wants to see immediately on +// session open. Older page = each background batch that fills history above +// the tail. Both sized for ~1 frame of render budget at typical event size. +const TRANSCRIPT_TAIL_PAGE = 500; +const TRANSCRIPT_OLDER_PAGE = 500; + +function renderEventsInto(targetEl, events) { + const prevActive = activeTranscriptEl; + activeTranscriptEl = targetEl; + try { + for (const te of events) renderEvent(te.event, te.at); + } finally { + activeTranscriptEl = prevActive; + } +} + +function transcriptProgressBanner(loaded, total) { + const el = document.createElement("div"); + el.className = "transcript-progress"; + el.setAttribute("aria-live", "polite"); + updateTranscriptProgressBanner(el, loaded, total); + return el; +} + +function updateTranscriptProgressBanner(el, loaded, total) { + if (!el) return; + const pct = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0; + el.textContent = `Loading older history… ${loaded.toLocaleString()} / ${total.toLocaleString()} (${pct}%)`; +} + async function loadTranscript(id) { const pane = showTranscriptPane(id); if (pane.dataset.loaded === "true") return; // already built — instant re-select @@ -3618,9 +3664,16 @@ } }, 150); state.byId.set(id, []); + // Fetch the tail page first: a multi-GB transcript would otherwise stall + // the wire + main thread for many seconds; the tail is what the user + // expects to see on open anyway (most-recent activity). Older pages then + // fill in upward without blocking the UI. let result; try { - result = await rpc("session.transcript", { session_id: id, from: 0 }); + result = await rpc("session.transcript", { + session_id: id, + tail: TRANSCRIPT_TAIL_PAGE, + }); } catch (e) { clearTimeout(skeletonTimer); if (state.currentId === id) { @@ -3633,25 +3686,81 @@ // A rapid re-selection may have moved on while we awaited the round-trip; // don't clobber whatever the user is now looking at. if (state.currentId !== id) return; - const events = (result && result.events) || []; - // Build the whole transcript off-DOM, then swap it in and pin to the - // bottom in one synchronous step — the user only ever sees the finished - // transcript already scrolled to the latest, never a blank pane (the - // skeleton covers the wait) nor a top-anchored flash that then jumps. - // renderEvent / append* target `activeTranscriptEl`, so point it at the - // detached builder for the duration of the render. + const tailEvents = (result && result.events) || []; + const total = (result && typeof result.total === "number") ? result.total : tailEvents.length; + // Build the tail off-DOM, then swap it in and pin to the bottom in one + // synchronous step — the user only ever sees a finished transcript already + // scrolled to the latest, never a blank pane (the skeleton covers the + // wait) nor a top-anchored flash that then jumps. const build = document.createElement("div"); - const prevActive = activeTranscriptEl; - activeTranscriptEl = build; - try { - for (const te of events) renderEvent(te.event, te.at); - } finally { - activeTranscriptEl = prevActive; - } + renderEventsInto(build, tailEvents); pane.replaceChildren(...build.childNodes); pane.dataset.loaded = "true"; pane.scrollTop = pane.scrollHeight; pane._atBottom = true; + + // Background-load older pages if there's more history above the tail. + const minTailSeq = tailEvents.length > 0 ? tailEvents[0].seq : 0; + if (total > tailEvents.length && minTailSeq > 0) { + backgroundLoadOlderTranscript(id, pane, minTailSeq, tailEvents.length, total); + } +} + +async function backgroundLoadOlderTranscript(id, pane, minTailSeq, alreadyLoaded, total) { + // Sticky banner pinned to the top of the pane so the user sees progress + // whether they're sitting on the latest tail or scrolling up through + // history. New older events are inserted just below it. + const banner = transcriptProgressBanner(alreadyLoaded, total); + pane.insertBefore(banner, pane.firstChild); + + let from = 0; + let loaded = alreadyLoaded; + try { + while (loaded < total && state.currentId === id) { + let result; + try { + result = await rpc("session.transcript", { + session_id: id, + from, + limit: TRANSCRIPT_OLDER_PAGE, + }); + } catch (e) { + // Older-page fetch failed: the tail is still visible, so just + // silently stop trying to fill history rather than tearing down + // what the user is reading. + return; + } + if (state.currentId !== id) return; + const chunk = (result && result.events) || []; + if (chunk.length === 0) return; + // Drop anything that overlaps the tail page we already rendered. + // (Strictly older = seq < minTailSeq.) + const older = chunk.filter((te) => te.seq < minTailSeq); + if (older.length > 0) { + const build = document.createElement("div"); + renderEventsInto(build, older); + // Preserve the user's scroll position relative to the BOTTOM so + // newly-prepended history doesn't feel like the view is jumping. + const fromBottom = pane.scrollHeight - pane.scrollTop; + const insertionPoint = banner.nextSibling; + while (build.firstChild) { + pane.insertBefore(build.firstChild, insertionPoint); + } + pane.scrollTop = pane.scrollHeight - fromBottom; + loaded += older.length; + updateTranscriptProgressBanner(banner, loaded, total); + } + const lastSeq = chunk[chunk.length - 1].seq; + // We've walked into the tail's range — no more older events exist. + if (lastSeq >= minTailSeq) return; + from = lastSeq + 1; + // Yield to the event loop so the page stays responsive and the + // browser can paint partial progress. + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + } finally { + banner.remove(); + } } function handleNotification(method, params) { diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index d5832e93..0216df0e 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -1378,7 +1378,10 @@ async fn dispatch( } m if m == ipc_method::SESSION_TRANSCRIPT => { let p = params!(TranscriptParams); - match manager.transcript(&p.session_id, p.from, p.limit).await { + match manager + .transcript(&p.session_id, p.from, p.limit, p.tail) + .await + { Ok(r) => ok!(&r), Err(e) => Response::err(id.clone(), ErrorObject::internal(e.to_string())), } diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index cd1ef2ab..6fd5d48f 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -1133,9 +1133,18 @@ impl SessionManager { id: &str, from: u64, limit: Option, + tail: Option, ) -> Result { - if self.get_entry(id).await.is_none() { - return Err(anyhow!("session not found: {}", id)); + let entry = self + .get_entry(id) + .await + .ok_or_else(|| anyhow!("session not found: {}", id))?; + if let Some(n) = tail { + // Tail mode: take `total` from the live counter (cheap, no file + // scan) and read only the last `n` events from disk. + let total = entry.transcript_count.load(Ordering::Relaxed); + let events = self.storage.read_transcript_tail(id, n)?; + return Ok(TranscriptResult { events, total }); } self.storage.read_transcript(id, from, limit) } @@ -3982,6 +3991,55 @@ mod tests { assert_eq!(effective_mode(&create_params(None, None)), "headless"); } + #[tokio::test] + async fn transcript_tail_returns_last_n_with_live_total() { + use chrono::Utc; + use tempfile::tempdir; + + let tmp = tempdir().expect("tempdir"); + let storage = + Arc::new(crate::storage::Storage::new(tmp.path().join("data")).expect("storage")); + let storage_handle = storage.clone(); + let config = Arc::new(crate::config::Config::default()); + let (mgr, _remote_rx, _restart_rx) = + SessionManager::new(storage, config, tmp.path().join("run")) + .await + .expect("session manager"); + + let id = "stail"; + let entry = synthetic_entry(id, agentd_protocol::SessionKind::User, 0); + mgr.sessions.write().await.insert(id.into(), entry.clone()); + + // Simulate 1234 persisted events. The live transcript_count is what + // `transcript(.., tail: …)` must surface as `total` — that's the + // signal the webui uses to decide whether to background-load older + // pages above the tail. + for seq in 1..=1234u64 { + let ev = agentd_protocol::TimestampedEvent { + seq, + at: Utc::now(), + event: agentd_protocol::SessionEvent::Message { + role: agentd_protocol::MessageRole::Assistant, + text: format!("e{seq}"), + }, + }; + storage_handle.append_event(id, &ev).expect("append event"); + entry + .transcript_count + .store(seq, std::sync::atomic::Ordering::Relaxed); + } + + let result = mgr + .transcript(id, 0, None, Some(50)) + .await + .expect("transcript tail"); + + assert_eq!(result.total, 1234, "total must come from the live counter"); + assert_eq!(result.events.len(), 50); + assert_eq!(result.events.first().unwrap().seq, 1185); + assert_eq!(result.events.last().unwrap().seq, 1234); + } + #[tokio::test] async fn pty_replay_returns_full_disk_tail_not_just_old_ring() { use base64::Engine; diff --git a/crates/daemon/src/storage.rs b/crates/daemon/src/storage.rs index 57bf79a5..358444b3 100644 --- a/crates/daemon/src/storage.rs +++ b/crates/daemon/src/storage.rs @@ -403,6 +403,57 @@ impl Storage { Ok(TranscriptResult { events, total }) } + /// Read the most-recent `n` events of the session's transcript without + /// scanning the whole file. Seeks backward in 64 KiB chunks until we've + /// gathered at least `n + 1` newlines (or hit the start), then parses + /// only those lines as JSON. For a multi-GB transcript the cost is + /// O(`n` × average line size), not O(file size), which is what makes + /// the webui's tail-pagination fast enough to render the live tail + /// before the user notices a wait. + pub fn read_transcript_tail(&self, id: &str, n: usize) -> Result> { + if n == 0 { + return Ok(Vec::new()); + } + let path = self.transcript_path(id); + if !path.exists() { + return Ok(Vec::new()); + } + use std::io::{Read, Seek, SeekFrom}; + let mut f = std::fs::File::open(&path)?; + let size = f.metadata()?.len(); + if size == 0 { + return Ok(Vec::new()); + } + const CHUNK: u64 = 64 * 1024; + let mut buf: Vec = Vec::new(); + let mut offset = size; + let mut newlines = 0usize; + // Read at least n + 1 newlines so the first (partial) line we slice + // off doesn't accidentally chop a real event in half. + while offset > 0 && newlines <= n { + let to_read = CHUNK.min(offset); + offset -= to_read; + f.seek(SeekFrom::Start(offset))?; + let mut chunk = vec![0u8; to_read as usize]; + f.read_exact(&mut chunk)?; + newlines += chunk.iter().filter(|&&b| b == b'\n').count(); + chunk.extend_from_slice(&buf); + buf = chunk; + } + // Take the last n non-empty lines. + let text = String::from_utf8_lossy(&buf); + let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); + let start = lines.len().saturating_sub(n); + let mut events: Vec = Vec::with_capacity(lines.len() - start); + for line in &lines[start..] { + match serde_json::from_str(line) { + Ok(e) => events.push(e), + Err(e) => tracing::warn!(%id, error = %e, "skip bad transcript line"), + } + } + Ok(events) + } + pub fn truncate_transcript(&self, id: &str) -> Result<()> { let path = self.transcript_path(id); if !path.exists() { @@ -605,6 +656,101 @@ mod widget_tests { } } +#[cfg(test)] +mod transcript_tail_tests { + use super::*; + use agentd_protocol::SessionEvent; + use chrono::Utc; + + fn make_event(seq: u64) -> TimestampedEvent { + TimestampedEvent { + seq, + at: Utc::now(), + event: SessionEvent::Message { + role: agentd_protocol::MessageRole::Assistant, + text: format!("msg #{seq}"), + }, + } + } + + #[test] + fn tail_returns_last_n_in_order() { + let tmp = tempfile::tempdir().unwrap(); + let storage = Storage::new(tmp.path().join("data")).unwrap(); + for seq in 1..=1000 { + storage.append_event("s1", &make_event(seq)).unwrap(); + } + + let tail = storage.read_transcript_tail("s1", 5).unwrap(); + + assert_eq!(tail.len(), 5); + assert_eq!(tail.first().unwrap().seq, 996); + assert_eq!(tail.last().unwrap().seq, 1000); + } + + #[test] + fn tail_handles_partial_first_chunk_without_chopping_an_event() { + // The seek-back read can land mid-line; the parser must drop that + // partial head, not parse it as garbage. Use long events so a + // 64 KiB chunk easily spans a line boundary that isn't on a chunk + // boundary. + let tmp = tempfile::tempdir().unwrap(); + let storage = Storage::new(tmp.path().join("data")).unwrap(); + let big_text = "x".repeat(2048); + for seq in 1..=100 { + let ev = TimestampedEvent { + seq, + at: Utc::now(), + event: SessionEvent::Message { + role: agentd_protocol::MessageRole::Assistant, + text: format!("{big_text} #{seq}"), + }, + }; + storage.append_event("s1", &ev).unwrap(); + } + + let tail = storage.read_transcript_tail("s1", 3).unwrap(); + + assert_eq!(tail.len(), 3); + assert_eq!(tail.first().unwrap().seq, 98); + assert_eq!(tail.last().unwrap().seq, 100); + } + + #[test] + fn tail_returns_all_events_when_n_exceeds_total() { + let tmp = tempfile::tempdir().unwrap(); + let storage = Storage::new(tmp.path().join("data")).unwrap(); + for seq in 1..=7 { + storage.append_event("s1", &make_event(seq)).unwrap(); + } + + let tail = storage.read_transcript_tail("s1", 100).unwrap(); + + assert_eq!(tail.len(), 7); + } + + #[test] + fn tail_on_missing_transcript_is_empty() { + let tmp = tempfile::tempdir().unwrap(); + let storage = Storage::new(tmp.path().join("data")).unwrap(); + + let tail = storage.read_transcript_tail("never-existed", 10).unwrap(); + + assert!(tail.is_empty()); + } + + #[test] + fn tail_with_zero_n_is_empty() { + let tmp = tempfile::tempdir().unwrap(); + let storage = Storage::new(tmp.path().join("data")).unwrap(); + storage.append_event("s1", &make_event(1)).unwrap(); + + let tail = storage.read_transcript_tail("s1", 0).unwrap(); + + assert!(tail.is_empty()); + } +} + #[cfg(test)] mod memory_tests { use super::*; diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index c11b600d..5a352a6f 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -1063,6 +1063,12 @@ pub struct TranscriptParams { pub from: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, + /// Return the most-recent `tail` events instead of paginating forward + /// from `from`. When set, `from` and `limit` are ignored. Used by the + /// webui to render the live tail of long histories immediately while it + /// background-loads older pages. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tail: Option, } #[derive(Debug, Clone, Serialize, Deserialize)]