aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorEric Yue <jiacheng.yue@foxmail.com>2026-09-14 20:46:39 +0800
committerGitHub <noreply@github.com>2026-09-14 15:46:39 +0300
commitbe03b7a19f198dba2cbf28a5b291b1157e8717c9 (patch)
tree97815a5f31d46265a079e70814ad473dcdda9720 /src/client
parentc77af1892ff121736ecb103b32d504d6f1b31805 (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.rs8
-rw-r--r--src/client/shell/tests/endpoints.rs109
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();