diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index b4aef2c9..befaa8f1 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -4863,6 +4863,13 @@
// full-screen PTY apps like codex repaint on resize, which looks // like terminal history replaying from the top after Insert. composerResizeSuppressPtyResizeUntil: 0, + // Layout churn (session-list toggle, keyboard show/hide, grid resizes) + // clamps the xterm viewport's scrollTop and fires scroll events the user + // never made. Those must not be read as "the user scrolled to the top", + // or the lazy history loader replays the raw backlog over a terminal + // that was quietly following live output (issue: mobile menu toggle + // visibly replayed the session and parked the scroll at the top). + terminalScrollChurnUntil: 0, // PTY events that arrived during the `pty_replay` round-trip get // queued here, then drained after the snapshot is written. Keeps // history-before-live ordering correct without a race window. @@ -4975,6 +4982,12 @@ const WIDGET_AUTOHIDE_MS = 15000; const PTY_REPLAY_PAGE_BYTES = 64 * 1024; const PTY_REPLAY_TOP_THRESHOLD_ROWS = 20; +// How long after a layout change scroll events are treated as browser +// fallout instead of user scrolling (see noteTerminalScrollChurn). Long +// enough to cover the pty-resize debounce (120ms) plus a remote echo +// round-trip; each churn stage refreshes the window, so it does not need +// to cover the whole sequence in one span. +const TERMINAL_SCROLL_CHURN_SUPPRESS_MS = 600; const TERMINAL_TRANSCRIPT_TAIL_EVENTS = 200; const TERMINAL_TRANSCRIPT_PAGE_EVENTS = 200; const PLAYBOOK_HOVER_REPLAY_BYTES = 32 * 1024; @@ -11865,7 +11878,12 @@ // claims daemon PTY geometry; hidden cached terminals just keep their // local dimensions ready for instant reveal. new ResizeObserver(() => { - if (state.currentId === id) refitTerminal(); + if (state.currentId !== id) return; + // Any host geometry change (list toggle, keyboard, composer growth) + // can clamp the viewport's scrollTop; those scroll events are layout + // fallout, not the user reading history. + noteTerminalScrollChurn(); + refitTerminal(); }).observe(host); host.addEventListener("wheel", (ev) => { @@ -12103,11 +12121,33 @@ if (handle.transcriptHistoryBeforeSeq <= 1) return false; } else if (!hasOlderPtyHistory(handle)) return false; if (force) return true; + // Implicit triggers come from scroll events. During layout churn the + // browser clamps the viewport's scrollTop itself, so a scroll event is + // not evidence of user intent — and misreading one replays raw history + // over a live terminal (see terminalScrollChurnUntil). + if (performance.now() <= state.terminalScrollChurnUntil) return false; const active = handle.term && handle.term.buffer && handle.term.buffer.active; if (!active) return false; + // A bottom-following terminal is watching live output, not reading + // history. When the whole rendered buffer is shorter than the top + // threshold (a TUI child repaints in place, so its rendered scrollback + // is tiny), "at the bottom" is also "near the top" — without this + // guard any scroll event would trigger a history replay. + if (active.viewportY >= active.baseY) return false; return active.viewportY <= PTY_REPLAY_TOP_THRESHOLD_ROWS; } +// Stamp the start of a window in which terminal scroll events are assumed +// to be browser layout fallout rather than user scrolling. Refreshed by +// every stage of a layout change (CSS toggle, host ResizeObserver, grid +// resize), so the window covers the whole toggle → refit → PTY echo → +// grid-adopt sequence even when the stages are spread over slow rpc +// round-trips. +function noteTerminalScrollChurn() { + state.terminalScrollChurnUntil = + performance.now() + TERMINAL_SCROLL_CHURN_SUPPRESS_MS; +} + async function maybeLoadOlderPtyReplay(id, opts = {}) { const handle = terminalHandleForSession(id); if (!shouldLoadOlderPtyReplay(handle, opts)) return; @@ -12338,6 +12378,9 @@ if (term.cols === cols && term.rows === rows) return; const active = term.buffer && term.buffer.active; const wasAtBottom = !!active && active.viewportY >= active.baseY; + // A grid resize reflows the buffer and emits scroll events of its own; + // keep them inside the churn window too. + if (term === state.term) noteTerminalScrollChurn(); term.resize(cols, rows); if (wasAtBottom) restoreTerminalBottomFor(term); } @@ -12965,6 +13008,11 @@ function setSessionListVisible(visible, persist = true) { state.sessionListVisible = !!visible; + // Collapsing/expanding the list reshapes the terminal host, which can + // clamp the xterm viewport's scrollTop. Stamp the churn window BEFORE + // the class flip so the resulting scroll events are never mistaken for + // the user scrolling into history. + noteTerminalScrollChurn(); if (isNarrowLayout()) { sessionListEl.classList.toggle("collapsed", !visible); } else { diff --git a/crates/e2e/tests/web_mobile_list_toggle.rs b/crates/e2e/tests/web_mobile_list_toggle.rs new file mode 100644 index 00000000..a2595649 --- /dev/null +++ b/crates/e2e/tests/web_mobile_list_toggle.rs @@ -0,0 +1,306 @@ +//! Mobile-layout regression (menu toggle vs terminal scroll): opening and +//! closing the session-list menu reshapes the terminal host, and the browser +//! clamps the xterm viewport's scrollTop while the grid catches up. Those +//! synthetic scroll events must not be read as "the user scrolled to the +//! top": they used to trip the lazy history loader, which visibly replayed +//! the raw PTY backlog over a live terminal and parked the scroll position +//! at the top of the scrollback. +//! +//! The session shape matters: a TUI-agent session accumulates megabytes of +//! pty.log while its *rendered* scrollback stays a few dozen rows (in-place +//! repaints), so "at the bottom" is also within the loader's near-top +//! threshold. The awk spinner below reproduces that shape. + +use std::time::{Duration, Instant}; + +use chromiumoxide::browser::{Browser, BrowserConfig}; +use chromiumoxide::cdp::browser_protocol::emulation::{ + SetDeviceMetricsOverrideParams, SetTouchEmulationEnabledParams, +}; +use chromiumoxide::page::Page; +use construct_e2e::Daemon; +use construct_protocol::{CreateSessionParams, PtySize, TunnelProvider}; +use futures::StreamExt; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mobile_list_toggle_does_not_replay_terminal_history() { + let d = Daemon::spawn().await.expect("daemon"); + let r = d + .client + .remote_start(TunnelProvider::None, None) + .await + .expect("remote.start"); + let cwd = std::env::temp_dir().to_string_lossy().to_string(); + let id = d + .client + .create(shell_session_params(&cwd, "mobile scroll")) + .await + .expect("create shell session"); + + // Megabytes of pty.log (so older raw history exists past the screen + // snapshot's render span) but only ~60 rendered scrollback rows. + d.client + .pty_input( + &id, + b"awk 'BEGIN{for(i=0;i<60000;i++) printf \"\\rspinner %d aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\", i}'; echo; seq 1 60; echo ALLDONE\r".to_vec(), + ) + .await + .expect("pty_input awk"); + wait_for_pty_output(&d, &id, "ALLDONE").await; + + let Some((browser, mut handler)) = launch_browser().await else { + eprintln!("skipping web_mobile_list_toggle: could not launch Chromium"); + return; + }; + let _handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} }); + let page = browser.new_page("about:blank").await.expect("new page"); + set_mobile_viewport(&page, 420, 900).await; + page.goto(inject_userinfo(&r.local_url, "remote", &r.password)) + .await + .expect("goto WebUI"); + wait_conn_open(&page).await; + wait_for_bool( + &page, + &format!("state.sessions.some((s) => s.id === {id:?})"), + ) + .await; + + // Select the session the way a phone user does (this also auto-collapses + // the list on narrow layouts) and wait for terminal hydration. + page.evaluate(format!("selectSession({id:?}); true")) + .await + .expect("select session"); + wait_for_bool( + &page, + &format!( + "(() => {{ const h = terminalHandleForSession({id:?}); \ + return state.currentId === {id:?} && state.mode === 'terminal' && !!h?.loaded; }})()" + ), + ) + .await; + // Let hydration's geometry claim / resize echoes settle. + tokio::time::sleep(Duration::from_millis(1500)).await; + + // Record any history-replay RPC issued after this point. + page.evaluate( + r#" + (() => { + window.__replayCalls = []; + const realRpc = rpc; + rpc = (method, params) => { + if (method === "session.pty_replay" || method === "session.transcript") { + window.__replayCalls.push({ method, params: { ...params } }); + } + return realRpc(method, params); + }; + return true; + })() + "#, + ) + .await + .expect("install rpc trace"); + + let before = scroll_probe(&page).await; + assert!( + before.base_y > 0, + "test needs rendered scrollback to be meaningful: {before:?}" + ); + assert!( + before.viewport_y >= before.base_y, + "terminal should start at the bottom: {before:?}" + ); + + // Open the session list, let layout + refit + PTY echo settle, close it + // again, and let everything settle once more. + toggle_session_list(&page).await; + tokio::time::sleep(Duration::from_millis(1200)).await; + toggle_session_list(&page).await; + tokio::time::sleep(Duration::from_millis(2000)).await; + + let after = scroll_probe(&page).await; + save_screenshot(&page, "web_mobile_list_toggle_after.png").await; + let replay_calls = page + .evaluate("JSON.stringify(window.__replayCalls)") + .await + .ok() + .and_then(|v| v.into_value::