From 310e3674489589eec10ae946f2222d8deccfac9f Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:16:56 -0300 Subject: [PATCH] sidebar: Add scrollbar and max-height to the thread switcher (#54471) Just a little refinement as I experienced the thread switcher modal overflowing beyond the viewport if there were enough threads on it. Additionally, took the opportunity to render a scrollbar inside of it, too. Release Notes: - N/A --- crates/sidebar/src/thread_switcher.rs | 100 ++++++++++++--------- crates/ui/src/components/ai/thread_item.rs | 1 + 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs index 97c291e0dc928dfb94a530234002bd4e99e2b3be..218b32792a89578fc96a01866c3cb5c6361eaf8e 100644 --- a/crates/sidebar/src/thread_switcher.rs +++ b/crates/sidebar/src/thread_switcher.rs @@ -3,9 +3,9 @@ use agent_client_protocol as acp; use agent_ui::thread_metadata_store::ThreadMetadata; use gpui::{ Action as _, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers, - ModifiersChangedEvent, Render, SharedString, prelude::*, + ModifiersChangedEvent, Render, ScrollHandle, SharedString, prelude::*, }; -use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, prelude::*}; +use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, WithScrollbar, prelude::*}; use workspace::{ModalView, Workspace}; use zed_actions::agents_sidebar::ToggleThreadSwitcher; @@ -42,6 +42,7 @@ pub(crate) struct ThreadSwitcher { entries: Vec, selected_index: usize, init_modifiers: Option, + scroll_handle: ScrollHandle, } impl ThreadSwitcher { @@ -74,11 +75,15 @@ impl ThreadSwitcher { }) .detach(); + let scroll_handle = ScrollHandle::new(); + scroll_handle.scroll_to_item(selected_index); + Self { focus_handle, entries, selected_index, init_modifiers, + scroll_handle, } } @@ -117,6 +122,7 @@ impl ThreadSwitcher { } fn emit_preview(&mut self, cx: &mut Context) { + self.scroll_handle.scroll_to_item(self.selected_index); if let Some(entry) = self.entries.get(self.selected_index) { cx.emit(ThreadSwitcherEvent::Preview { metadata: entry.metadata.clone(), @@ -151,6 +157,7 @@ impl ThreadSwitcher { return; } self.selected_index = index; + self.scroll_handle.scroll_to_item(index); self.emit_preview(cx); cx.notify(); } @@ -205,57 +212,66 @@ impl Focusable for ThreadSwitcher { } impl Render for ThreadSwitcher { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { let selected_index = self.selected_index; v_flex() .key_context("ThreadSwitcher") .track_focus(&self.focus_handle) - .w(rems_from_px(440.)) .p_1p5() - .gap_0p5() + .w(rems_from_px(440.)) .elevation_3(cx) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::toggle)) - .children(self.entries.iter().enumerate().map(|(ix, entry)| { - let id = SharedString::from(format!("thread-switcher-{}", entry.session_id)); + .child( + v_flex() + .id("thread-switcher-list") + .gap_0p5() + .max_h_128() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .children(self.entries.iter().enumerate().map(|(ix, entry)| { + let id = + SharedString::from(format!("thread-switcher-{}", entry.session_id)); - ThreadItem::new(id, entry.title.clone()) - .rounded(true) - .icon(entry.icon) - .status(entry.status) - .when_some(entry.icon_from_external_svg.clone(), |this, svg| { - this.custom_icon_from_external_svg(svg) - }) - .when_some(entry.project_name.clone(), |this, name| { - this.project_name(name) - }) - .worktrees(entry.worktrees.clone()) - .timestamp(entry.timestamp.clone()) - .title_generating(entry.is_title_generating) - .notified(entry.notified) - .when(entry.diff_stats.lines_added > 0, |this| { - this.added(entry.diff_stats.lines_added as usize) - }) - .when(entry.diff_stats.lines_removed > 0, |this| { - this.removed(entry.diff_stats.lines_removed as usize) - }) - .selected(ix == selected_index) - .base_bg(cx.theme().colors().elevated_surface_background) - .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| { - if *hovered { - this.select_index(ix, cx); - } - })) - // TODO: This is not properly propagating to the tread item. - .on_click( - cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| { - this.select_and_confirm(ix, cx); - }), - ) - .into_any_element() - })) + ThreadItem::new(id, entry.title.clone()) + .rounded(true) + .icon(entry.icon) + .status(entry.status) + .when_some(entry.icon_from_external_svg.clone(), |this, svg| { + this.custom_icon_from_external_svg(svg) + }) + .when_some(entry.project_name.clone(), |this, name| { + this.project_name(name) + }) + .worktrees(entry.worktrees.clone()) + .timestamp(entry.timestamp.clone()) + .title_generating(entry.is_title_generating) + .notified(entry.notified) + .when(entry.diff_stats.lines_added > 0, |this| { + this.added(entry.diff_stats.lines_added as usize) + }) + .when(entry.diff_stats.lines_removed > 0, |this| { + this.removed(entry.diff_stats.lines_removed as usize) + }) + .selected(ix == selected_index) + .base_bg(cx.theme().colors().elevated_surface_background) + .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| { + if *hovered { + this.select_index(ix, cx); + } + })) + // TODO: This is not properly propagating to the tread item. + .on_click(cx.listener( + move |this, _event: &gpui::ClickEvent, _window, cx| { + this.select_and_confirm(ix, cx); + }, + )) + .into_any_element() + })), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) } } diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 11ac0ef336cfebf81a5ff00eb60f6be9795ea116..c962aa4f9f24e5be95546999e40a49fb2cfa9494 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -387,6 +387,7 @@ impl RenderOnce for ThreadItem { .cursor_pointer() .group("thread-item") .relative() + .flex_shrink_0() .overflow_hidden() .w_full() .py_1()