diff options
| author | Eric Yue <jiacheng.yue@foxmail.com> | 2026-09-14 20:46:39 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-14 15:46:39 +0300 |
| commit | be03b7a19f198dba2cbf28a5b291b1157e8717c9 (patch) | |
| tree | 97815a5f31d46265a079e70814ad473dcdda9720 /src/client | |
| parent | c77af1892ff121736ecb103b32d504d6f1b31805 (diff) | |
fix: preserve agent panel scroll when switching machines (#4101)
refs #3937
Root cause:
Activating another endpoint applies a snapshot with a different
endpoint-qualified graphics scope. This triggers reset_endpoint_projection,
which clears agent_scroll together with endpoint-local state even though
the agent list aggregates all machines. The selected agent receives focus,
but the list jumps back to the top.
Fix:
Save the client-owned agent scroll offset and restore it only when
activation changes endpoints. Keep same-endpoint reboot resets,
workspace/tab scroll resets, input cleanup and existing list bounds
clamping unchanged. This adds constant-time work on endpoint activation,
with no protocol, persisted-state or pane-scaled rendering changes.
Validation:
The new regression fails on the original implementation with scroll 0
instead of 6. Tests cover repeated bidirectional agent clicks, stable
visible rows, shrinking lists and same-machine reboots. All 242 client
shell tests pass.
Real SSH A/B testing against the same two disposable 0.9.0 servers
reproduces the reset with both the stable and current-master clients.
The patched client preserves the visible rows through six cross-machine
switches and two same-machine control clicks.
Native just ci passes: 3535 Rust tests, six default skips, plus lint,
maintenance, architecture and integration-asset checks. All seven docs
contract tests pass. Windows cross-lint was not run because its SDK is
not installed; native macOS and Windows UI behavior was not tested.
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/shell/endpoints.rs | 8 | ||||
| -rw-r--r-- | src/client/shell/tests/endpoints.rs | 109 |
2 files changed, 116 insertions, 1 deletions
diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index 420f7131..d3823d54 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -208,12 +208,18 @@ impl ClientShellState { let Some(snapshot) = endpoint.snapshot.clone() else { return false; }; - if endpoint_id != &self.active_endpoint_id { + let switching_endpoint = endpoint_id != &self.active_endpoint_id; + let agent_scroll = self.agent_scroll; + if switching_endpoint { self.active_endpoint_id = endpoint_id.clone(); self.pane_surface = None; self.pending_pane_surface = None; } self.apply_active_snapshot(snapshot); + if switching_endpoint { + // The aggregate agent list belongs to the client, not one endpoint. + self.agent_scroll = agent_scroll; + } true } diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index 67663bea..1edba8e6 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -73,6 +73,115 @@ fn state_with_remote() -> (ClientShellState, ClientEndpointId) { (state, endpoint_id) } +fn state_with_scrollable_agents() -> (ClientShellState, ClientEndpointId) { + let (mut state, remote) = state_with_remote(); + for endpoint_id in [ClientEndpointId::Local, remote.clone()] { + let mut projection = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .unwrap() + .snapshot + .clone() + .unwrap(); + projection.agents = (0..8) + .map(|index| ClientShellAgent { + pane_id: format!("pane_{}", index + 1), + focused: index == 0, + ..agent(&format!("agent {index}"), AgentStatus::Idle, 1) + }) + .collect(); + projection.panes = projection + .agents + .iter() + .map(|agent| ClientShellPane { + pane_id: agent.pane_id.clone(), + focused: agent.focused, + ..projection.panes[0].clone() + }) + .collect(); + state.set_endpoint_snapshot(&endpoint_id, projection); + } + state.compose(100, 28).unwrap(); + state.agent_scroll = 6; + state.compose(100, 28).unwrap(); + assert_eq!(state.agent_scroll, 6); + (state, remote) +} + +#[test] +fn switching_machines_preserves_aggregate_agent_scroll_and_visible_rows() { + let (mut state, remote) = state_with_scrollable_agents(); + for endpoint_id in [remote.clone(), ClientEndpointId::Local, remote] { + let visible = state.hits.endpoint_agents.clone(); + let (rect, _, pane_id) = visible + .iter() + .find(|(_, endpoint, _)| endpoint == &endpoint_id) + .expect("destination agent remains visible"); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x + 2, + row: rect.y, + modifiers: KeyModifiers::NONE, + })]); + assert!(matches!( + click.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: target, + target: Some(ClientEndpointFocusTarget::Pane(target_pane)), + }] if target == &endpoint_id && target_pane == pane_id + )); + + state.workspace_scroll = 3; + state.tab_scroll = 2; + assert!(state.activate_endpoint_projection(&endpoint_id)); + assert_eq!(state.agent_scroll, 6); + assert_eq!(state.workspace_scroll, 0); + assert_eq!(state.tab_scroll, 0); + assert!(state.pane_surface.is_none()); + + let mut next_surface = surface(); + next_surface.boot_id = state.endpoint_boot_id(&endpoint_id).unwrap().into(); + state.set_pane_surface(next_surface); + state.compose(100, 28).unwrap(); + assert_eq!(state.agent_scroll, 6); + assert_eq!(state.hits.endpoint_agents, visible); + } +} + +#[test] +fn aggregate_agent_scroll_still_clamps_when_rows_shrink_on_activation() { + let (mut state, remote) = state_with_scrollable_agents(); + for endpoint_id in [ClientEndpointId::Local, remote.clone()] { + let mut projection = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .unwrap() + .snapshot + .clone() + .unwrap(); + projection.revision += 1; + projection.agents.truncate(1); + state.set_endpoint_snapshot(&endpoint_id, projection); + } + assert!(state.activate_endpoint_projection(&remote)); + state.compose(100, 28).unwrap(); + assert_eq!(state.agent_scroll, 0); + assert_eq!(state.hits.agent_max_scroll, 0); + assert_eq!(state.hits.endpoint_agents.len(), 2); +} + +#[test] +fn same_machine_reboot_still_resets_agent_scroll() { + let (mut state, _) = state_with_scrollable_agents(); + let mut projection = state.snapshot.clone().unwrap(); + projection.boot_id = "restarted-local".into(); + state.cache_endpoint_snapshot(&ClientEndpointId::Local, projection); + assert!(state.activate_endpoint_projection(&ClientEndpointId::Local)); + assert_eq!(state.agent_scroll, 0); +} + #[test] fn switching_machines_from_copy_mode_restores_terminal_input() { let (mut state, remote) = state_with_remote(); |
