diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 09f220dc..e4bcd107 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -6,6 +6,7 @@ - The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits. ### Fixed +- Sidebar agent lists keep scrolling when differently sized clients are attached to the same session. (#2255, thanks @aiworkflowpro) - `pane send-keys` and `agent send-keys` now preserve Shift when sending `shift+tab`, allowing agent permission modes to be cycled programmatically. (#1561, thanks @keinstn and @tomohisa) ## [0.8.0] - 2026-08-03 diff --git a/src/server/headless.rs b/src/server/headless.rs index f72d1a94..752f8e8c 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -4072,6 +4072,12 @@ impl HeadlessServer { } else { crate::kitty_graphics::HostCellSize::default() }; + let preserved_scroll = (!is_foreground).then_some(( + self.app.state.workspace_scroll, + self.app.state.agent_panel_scroll, + self.app.state.tab_scroll, + self.app.state.mobile_switcher_scroll, + )); let (buffer, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, @@ -4080,6 +4086,12 @@ impl HeadlessServer { is_foreground, render_cell_size, ); + if let Some((workspace, agent_panel, tab, mobile_switcher)) = preserved_scroll { + self.app.state.workspace_scroll = workspace; + self.app.state.agent_panel_scroll = agent_panel; + self.app.state.tab_scroll = tab; + self.app.state.mobile_switcher_scroll = mobile_switcher; + } crate::render_prof::duration_since( "full_render.render_virtual", render_started, diff --git a/tests/multi_client.rs b/tests/multi_client.rs index 6e367eaf..20deedaf 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -273,6 +273,19 @@ fn create_workspace_and_root_pane(socket_path: &Path, label: &str) -> (String, S (workspace_id, pane_id) } +fn report_idle_agent(socket_path: &Path, pane_id: &str) { + let response = send_json_request( + socket_path, + &format!( + r#"{{"id":"report_agent","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"idle","source":"multi-client-test"}}}}"# + ), + ); + assert!( + response.get("error").is_none(), + "pane.report_agent should succeed: {response}" + ); +} + fn pane_send_input(socket_path: &Path, pane_id: &str, text: &str) { let request = format!( "{{\"id\":\"send_input\",\"method\":\"pane.send_input\",\"params\":{{\"pane_id\":\"{pane_id}\",\"text\":\"{}\",\"keys\":[\"Enter\"]}}}}", @@ -747,6 +760,15 @@ fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool { frame_text(frame).contains(needle) } +fn agent_panel_starts_with(frame: &FrameWire, agent_label: &str) -> bool { + frame_text(frame) + .lines() + .skip_while(|line| !line.contains("agents")) + .skip(1) + .find(|line| line.contains("agent-")) + .is_some_and(|line| line.contains(agent_label)) +} + #[test] fn multi_client_allows_multiple_simultaneous_connections() { let _lock = test_lock(); @@ -827,6 +849,75 @@ fn multi_client_effective_size_shrinks_when_smaller_client_joins() { cleanup_spawned_herdr(server, base); } +#[test] +fn non_foreground_client_render_preserves_agent_panel_scroll() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + + let server = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + wait_for_file(&client_socket, Duration::from_secs(10)); + + for index in 1..=23 { + let (_, pane_id) = + create_workspace_and_root_pane(&api_socket, &format!("agent-{index:02}")); + report_idle_agent(&api_socket, &pane_id); + } + + let mut setup_client = connect_raw_client(&client_socket, 106, 40); + assert!(wait_for_frame(&mut setup_client, Duration::from_secs(2))); + drain_server_messages(&mut setup_client, Duration::from_millis(250)); + + let wheel_down = b"\x1b[<65;10;30M"; + send_client_input(&mut setup_client, &wheel_down.repeat(20)); + let (reached_bottom, setup_frames) = wait_for_frame_matching_with_snapshots( + &mut setup_client, + Duration::from_secs(3), + |frame| agent_panel_starts_with(frame, "agent-16"), + ) + .expect("setup frame decoding should succeed"); + assert!( + reached_bottom, + "40-row client should scroll the agent panel to its final page; frames:\n{}", + setup_frames.join("\n--- frame ---\n") + ); + send_client_detach(&mut setup_client); + drop(setup_client); + + let mut tall_background = connect_raw_client(&client_socket, 106, 64); + assert!(wait_for_frame(&mut tall_background, Duration::from_secs(2))); + let mut probe = connect_raw_client(&client_socket, 106, 40); + let (started_at_tall_limit, initial_frames) = + wait_for_frame_matching_with_snapshots(&mut probe, Duration::from_secs(3), |frame| { + agent_panel_starts_with(frame, "agent-10") + }) + .expect("initial probe frame decoding should succeed"); + assert!( + started_at_tall_limit, + "tall client should normalize the shared scroll before the probe attaches; frames:\n{}", + initial_frames.join("\n--- frame ---\n") + ); + drain_server_messages(&mut probe, Duration::from_millis(250)); + + send_client_input(&mut probe, wheel_down); + let (scrolled, probe_frames) = + wait_for_frame_matching_with_snapshots(&mut probe, Duration::from_secs(3), |frame| { + agent_panel_starts_with(frame, "agent-11") + }) + .expect("probe frame decoding should succeed"); + assert!( + scrolled, + "background client projection must not undo the foreground wheel event; frames:\n{}", + probe_frames.join("\n--- frame ---\n") + ); + + cleanup_spawned_herdr(server, base); +} + #[test] fn multi_client_broadcasts_frame_updates_to_all_clients() { let _lock = test_lock();