sidebar: Fix selection highlight flickering when clicking terminal threads (#64387)

Clear keyboard selection when clicking a terminal thread, as we already
do for agent threads.

The old test cleared selection itself instead of clicking a row. Replace
it with seven scoped tests:

| Test | Action | Expected result |
|---|---|---|
| Different terminal | Terminal A → click Terminal B | B active,
selection cleared |
| Same terminal | Terminal A → click Terminal A | A still active,
selection cleared |
| Different thread | Thread A → click Thread B | B active, selection
cleared |
| Confirm terminal | B active, A selected → Confirm | A active, A still
selected |
| Confirm thread | Thread A selected → Confirm | A active, A still
selected |
| Refocus without selection | Focus outside → sidebar | Search focused,
selection still empty |
| Refocus with selection | Focus outside → sidebar | List focused,
selection preserved |

Each click test starts with the source item active and
keyboard-selected, with the sidebar list focused so the keyboard
highlight is visible.

Before:


https://github.com/user-attachments/assets/eee8bc47-e962-402f-9268-8134dc20ba5b

After:


https://github.com/user-attachments/assets/a437f4c8-b2c0-47d8-99c6-c4600b49c359

Release Notes:

- Fixed the selection highlight flickering in the sidebar when clicking
terminal threads.
This commit is contained in:
Smit Barmase authored and GitHub committed 2026-09-18 07:17:01 +00:00
1 parent 49276a3d1d
commit d0ae2f05d1
2 files changed
+311 -60

No files matched your search

+1
View File
@@ -6641,6 +6641,7 @@ impl Sidebar {
let metadata = terminal.metadata.clone();
let workspace = terminal.workspace.clone();
move |this, _, window, cx| {
this.selection = None;
this.activate_terminal_entry(
metadata.clone(),
workspace.clone(),
+310 -60
View File
@@ -1752,35 +1752,58 @@ async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
}
#[gpui::test]
async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
async fn test_refocus_sidebar_with_no_selection_focuses_search(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
});
// Initially no selection
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
workspace.update_in(cx, |workspace, window, cx| {
workspace.focus_handle(cx).focus(window, cx);
});
cx.run_until_parked();
sidebar.update_in(cx, |sidebar, window, cx| {
assert!(!sidebar.focus_handle.is_focused(window));
assert!(!sidebar.filter_editor.read(cx).is_focused(window));
assert_eq!(sidebar.selection, None);
});
// Open the sidebar so it's rendered, then focus it to trigger focus_in.
// focus_in no longer sets a default selection.
// Refocusing with no selection sends focus to search without selecting a row.
focus_sidebar(&sidebar, cx);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
sidebar.update_in(cx, |sidebar, window, cx| {
assert!(sidebar.filter_editor.read(cx).is_focused(window));
assert_eq!(sidebar.selection, None);
});
}
// Manually set a selection, blur, then refocus — selection should be preserved
sidebar.update_in(cx, |sidebar, _window, _cx| {
#[gpui::test]
async fn test_refocus_sidebar_with_selection_preserves_it(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
});
sidebar.update(cx, |sidebar, cx| {
sidebar.selection = Some(0);
cx.notify();
});
cx.update(|window, cx| {
window.blur(cx);
workspace.update_in(cx, |workspace, window, cx| {
workspace.focus_handle(cx).focus(window, cx);
});
cx.run_until_parked();
sidebar.update_in(cx, |_, window, cx| {
cx.focus_self(window);
// Refocusing with a selection returns focus to the list and keeps that row selected.
focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, window, _cx| {
assert!(sidebar.focus_handle.is_focused(window));
assert_eq!(sidebar.selection, Some(0));
});
cx.run_until_parked();
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
}
#[gpui::test]
@@ -5236,74 +5259,301 @@ async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_threa
);
}
struct SidebarClickFixture {
sidebar: Entity<Sidebar>,
panel: Entity<AgentPanel>,
}
impl SidebarClickFixture {
fn new(multi_workspace: &Entity<MultiWorkspace>, cx: &mut gpui::VisualTestContext) -> Self {
let (sidebar, panel) = setup_sidebar_with_agent_panel(multi_workspace, cx);
Self { sidebar, panel }
}
fn insert_terminal(&self, title: &str, cx: &mut gpui::VisualTestContext) -> TerminalId {
let terminal_id = self
.panel
.update_in(cx, |panel, window, cx| {
panel.insert_test_terminal(title, true, window, cx)
})
.expect("test terminal should be inserted");
cx.run_until_parked();
terminal_id
}
fn terminal_index(&self, terminal_id: TerminalId, cx: &mut gpui::VisualTestContext) -> usize {
self.sidebar.read_with(cx, |sidebar, _cx| {
sidebar
.contents
.entries
.iter()
.position(|entry| {
matches!(
entry,
ListEntry::Terminal(terminal)
if terminal.metadata.terminal_id == terminal_id
)
})
.expect("terminal should be visible in sidebar")
})
}
fn thread_index(&self, session_id: &acp::SessionId, cx: &mut gpui::VisualTestContext) -> usize {
self.sidebar.read_with(cx, |sidebar, _cx| {
sidebar
.contents
.entries
.iter()
.position(|entry| {
matches!(
entry,
ListEntry::Thread(thread)
if thread.metadata.session_id.as_ref() == Some(session_id)
)
})
.expect("thread should be visible in sidebar")
})
}
fn select_and_focus(&self, entry_index: usize, cx: &mut gpui::VisualTestContext) {
self.sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.selection = Some(entry_index);
sidebar.focus_handle.focus(window, cx);
cx.notify();
});
cx.run_until_parked();
}
fn click(&self, entry_index: usize, cx: &mut gpui::VisualTestContext) {
cx.draw(
gpui::point(px(0.), px(0.)),
gpui::size(px(400.), px(400.)),
|_, _| self.sidebar.clone().into_any_element(),
);
let entry_bounds = self.sidebar.read_with(cx, |sidebar, _cx| {
sidebar
.list_state
.bounds_for_item(entry_index)
.expect("sidebar entry should be measured")
});
cx.simulate_click(entry_bounds.center(), gpui::Modifiers::none());
cx.run_until_parked();
}
}
#[gpui::test]
async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
async fn test_keyboard_confirm_on_terminal_preserves_selection(cx: &mut TestAppContext) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let fixture = SidebarClickFixture::new(&multi_workspace, cx);
let terminal_id = fixture.insert_terminal("Terminal A", cx);
let other_terminal_id = fixture.insert_terminal("Terminal B", cx);
let terminal_index = fixture.terminal_index(terminal_id, cx);
fixture.select_and_focus(terminal_index, cx);
fixture.sidebar.update_in(cx, |sidebar, window, _cx| {
assert!(sidebar.focus_handle.is_focused(window));
assert_eq!(sidebar.selection, Some(terminal_index));
assert!(
matches!(
&sidebar.active_entry,
Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. })
if *active_terminal_id == other_terminal_id
),
"Terminal B should be active before confirming Terminal A, got {:?}",
sidebar.active_entry,
);
});
// Confirm switches from Terminal B to Terminal A without clearing selection.
cx.dispatch_action(Confirm);
cx.run_until_parked();
fixture.sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(sidebar.selection, Some(terminal_index));
assert!(
matches!(
&sidebar.active_entry,
Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. })
if *active_terminal_id == terminal_id
),
"confirmed terminal should be active, got {:?}",
sidebar.active_entry,
);
});
}
#[gpui::test]
async fn test_keyboard_confirm_on_thread_preserves_selection(cx: &mut TestAppContext) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
let fixture = SidebarClickFixture::new(&multi_workspace, cx);
let session_id = acp::SessionId::new(Arc::from("thread-a"));
save_thread_metadata(
acp::SessionId::new(Arc::from("t-1")),
session_id.clone(),
Some("Thread A".into()),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
Utc::now(),
None,
None,
&project,
cx,
);
let thread_index = fixture.thread_index(&session_id, cx);
fixture.select_and_focus(thread_index, cx);
// Confirm must preserve selection without the test setting it again.
cx.dispatch_action(Confirm);
cx.run_until_parked();
fixture.sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(sidebar.selection, Some(thread_index));
assert_active_thread(sidebar, &session_id, "confirmed thread should be active");
});
}
#[gpui::test]
async fn test_clicking_different_terminal_clears_sidebar_selection(cx: &mut TestAppContext) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let fixture = SidebarClickFixture::new(&multi_workspace, cx);
let terminal_a_id = fixture.insert_terminal("Terminal A", cx);
let terminal_b_id = fixture.insert_terminal("Terminal B", cx);
let terminal_a_index = fixture.terminal_index(terminal_a_id, cx);
let terminal_b_index = fixture.terminal_index(terminal_b_id, cx);
fixture.panel.update_in(cx, |panel, window, cx| {
panel.activate_terminal(terminal_a_id, true, window, cx);
});
cx.run_until_parked();
fixture.select_and_focus(terminal_a_index, cx);
fixture.sidebar.update_in(cx, |sidebar, window, _cx| {
assert!(sidebar.focus_handle.is_focused(window));
assert_eq!(sidebar.selection, Some(terminal_a_index));
assert!(
matches!(
&sidebar.active_entry,
Some(ActiveEntry::Terminal { terminal_id, .. })
if *terminal_id == terminal_a_id
),
"Terminal A should be active before clicking Terminal B, got {:?}",
sidebar.active_entry,
);
});
// Clicking a different terminal clears keyboard selection and activates that terminal.
fixture.click(terminal_b_index, cx);
fixture.sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(sidebar.selection, None);
assert!(
matches!(
&sidebar.active_entry,
Some(ActiveEntry::Terminal { terminal_id, .. })
if *terminal_id == terminal_b_id
),
"Terminal B should be active after the click, got {:?}",
sidebar.active_entry,
);
});
}
#[gpui::test]
async fn test_clicking_active_terminal_clears_sidebar_selection(cx: &mut TestAppContext) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let fixture = SidebarClickFixture::new(&multi_workspace, cx);
let terminal_id = fixture.insert_terminal("Terminal A", cx);
let terminal_index = fixture.terminal_index(terminal_id, cx);
fixture.select_and_focus(terminal_index, cx);
fixture.sidebar.update_in(cx, |sidebar, window, _cx| {
assert!(sidebar.focus_handle.is_focused(window));
assert_eq!(sidebar.selection, Some(terminal_index));
assert!(
matches!(
&sidebar.active_entry,
Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. })
if *active_terminal_id == terminal_id
),
"Terminal A should be active before clicking it, got {:?}",
sidebar.active_entry,
);
});
// Clicking the active terminal clears keyboard selection without changing the active terminal.
fixture.click(terminal_index, cx);
fixture.sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(sidebar.selection, None);
assert!(
matches!(
&sidebar.active_entry,
Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. })
if *active_terminal_id == terminal_id
),
"Terminal A should remain active after the click, got {:?}",
sidebar.active_entry,
);
});
}
#[gpui::test]
async fn test_clicking_different_thread_clears_sidebar_selection(cx: &mut TestAppContext) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let fixture = SidebarClickFixture::new(&multi_workspace, cx);
let thread_a_session_id = acp::SessionId::new(Arc::from("thread-a"));
let thread_b_session_id = acp::SessionId::new(Arc::from("thread-b"));
save_thread_metadata(
acp::SessionId::new(Arc::from("t-2")),
Some("Thread B".into()),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
thread_a_session_id.clone(),
Some("Thread A".into()),
Utc::now() + chrono::Duration::days(2),
None,
None,
&project,
cx,
);
cx.run_until_parked();
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [my-project]",
" Thread A",
" Thread B",
]
save_thread_metadata(
thread_b_session_id.clone(),
Some("Thread B".into()),
Utc::now() + chrono::Duration::days(1),
None,
None,
&project,
cx,
);
let thread_a_index = fixture.thread_index(&thread_a_session_id, cx);
let thread_b_index = fixture.thread_index(&thread_b_session_id, cx);
fixture.select_and_focus(thread_a_index, cx);
cx.dispatch_action(Confirm);
cx.run_until_parked();
fixture.select_and_focus(thread_a_index, cx);
// Keyboard confirm preserves selection.
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.selection = Some(1);
sidebar.confirm(&Confirm, window, cx);
fixture.sidebar.update_in(cx, |sidebar, window, _cx| {
assert!(sidebar.focus_handle.is_focused(window));
assert_eq!(sidebar.selection, Some(thread_a_index));
assert_active_thread(
sidebar,
&thread_a_session_id,
"Thread A should be active before clicking Thread B",
);
});
assert_eq!(
sidebar.read_with(cx, |sidebar, _| sidebar.selection),
Some(1)
);
// Click handlers clear selection to None so no highlight lingers
// after a click regardless of focus state. The hover style provides
// visual feedback during mouse interaction instead.
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.selection = None;
let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
let project_group_key = ProjectGroupKey::new(None, path_list);
sidebar.toggle_collapse(&project_group_key, window, cx);
});
assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
// Clicking a different thread clears keyboard selection and activates that thread.
fixture.click(thread_b_index, cx);
// When the user tabs back into the sidebar, focus_in no longer
// restores selection — it stays None.
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.focus_in(window, cx);
fixture.sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(sidebar.selection, None);
assert_active_thread(
sidebar,
&thread_b_session_id,
"Thread B should be active after the click",
);
});
assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
}
#[gpui::test]