agent: Thread switcher sticky workspace header (#51372)

Cameron Mcloughlin created

Change summary

crates/agent_ui/src/sidebar.rs | 116 ++++++++++++++++++++++++++++++++---
1 file changed, 105 insertions(+), 11 deletions(-)

Detailed changes

crates/agent_ui/src/sidebar.rs 🔗

@@ -134,6 +134,7 @@ impl From<ThreadEntry> for ListEntry {
 struct SidebarContents {
     entries: Vec<ListEntry>,
     notified_threads: HashSet<acp::SessionId>,
+    project_header_indices: Vec<usize>,
 }
 
 impl SidebarContents {
@@ -663,10 +664,17 @@ impl Sidebar {
         // the build pass (no extra scan needed).
         notified_threads.retain(|id| current_session_ids.contains(id));
 
+        let project_header_indices = entries
+            .iter()
+            .enumerate()
+            .filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i))
+            .collect();
+
         self.active_entry_index = active_entry_index;
         self.contents = SidebarContents {
             entries,
             notified_threads,
+            project_header_indices,
         };
     }
 
@@ -724,6 +732,7 @@ impl Sidebar {
                 has_threads,
             } => self.render_project_header(
                 ix,
+                false,
                 path_list,
                 label,
                 workspace,
@@ -769,6 +778,7 @@ impl Sidebar {
     fn render_project_header(
         &self,
         ix: usize,
+        is_sticky: bool,
         path_list: &PathList,
         label: &SharedString,
         workspace: &Entity<Workspace>,
@@ -778,9 +788,10 @@ impl Sidebar {
         docked_right: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        let id = SharedString::from(format!("project-header-{}", ix));
-        let group_name = SharedString::from(format!("header-group-{}", ix));
-        let ib_id = SharedString::from(format!("project-header-new-thread-{}", ix));
+        let id_prefix = if is_sticky { "sticky-" } else { "" };
+        let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
+        let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
+        let ib_id = SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}"));
 
         let is_collapsed = self.collapsed_groups.contains(path_list);
         let disclosure_icon = if is_collapsed {
@@ -842,7 +853,9 @@ impl Sidebar {
                     .when(workspace_count > 1, |this| {
                         this.child(
                             IconButton::new(
-                                SharedString::from(format!("project-header-remove-{}", ix)),
+                                SharedString::from(format!(
+                                    "{id_prefix}project-header-remove-{ix}",
+                                )),
                                 IconName::Close,
                             )
                             .icon_size(IconSize::Small)
@@ -858,7 +871,9 @@ impl Sidebar {
                     .when(view_more_expanded && !is_collapsed, |this| {
                         this.child(
                             IconButton::new(
-                                SharedString::from(format!("project-header-collapse-{}", ix)),
+                                SharedString::from(format!(
+                                    "{id_prefix}project-header-collapse-{ix}",
+                                )),
                                 IconName::ListCollapse,
                             )
                             .icon_size(IconSize::Small)
@@ -899,6 +914,84 @@ impl Sidebar {
             .into_any_element()
     }
 
+    fn render_sticky_header(
+        &self,
+        docked_right: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
+        let scroll_top = self.list_state.logical_scroll_top();
+
+        let &header_idx = self
+            .contents
+            .project_header_indices
+            .iter()
+            .rev()
+            .find(|&&idx| idx <= scroll_top.item_ix)?;
+
+        let needs_sticky = header_idx < scroll_top.item_ix
+            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
+
+        if !needs_sticky {
+            return None;
+        }
+
+        let ListEntry::ProjectHeader {
+            path_list,
+            label,
+            workspace,
+            highlight_positions,
+            has_threads,
+        } = self.contents.entries.get(header_idx)?
+        else {
+            return None;
+        };
+
+        let is_focused = self.focus_handle.is_focused(window)
+            || self.filter_editor.focus_handle(cx).is_focused(window);
+        let is_selected = is_focused && self.selection == Some(header_idx);
+
+        let header_element = self.render_project_header(
+            header_idx,
+            true,
+            &path_list,
+            &label,
+            &workspace,
+            &highlight_positions,
+            *has_threads,
+            is_selected,
+            docked_right,
+            cx,
+        );
+
+        let top_offset = self
+            .contents
+            .project_header_indices
+            .iter()
+            .find(|&&idx| idx > header_idx)
+            .and_then(|&next_idx| {
+                let bounds = self.list_state.bounds_for_item(next_idx)?;
+                let viewport = self.list_state.viewport_bounds();
+                let y_in_viewport = bounds.origin.y - viewport.origin.y;
+                let header_height = bounds.size.height;
+                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
+            })
+            .unwrap_or(px(0.));
+
+        let element = v_flex()
+            .absolute()
+            .top(top_offset)
+            .left_0()
+            .w_full()
+            .bg(cx.theme().colors().surface_background)
+            .border_b_1()
+            .border_color(cx.theme().colors().border_variant)
+            .child(header_element)
+            .into_any_element();
+
+        Some(element)
+    }
+
     fn activate_workspace(
         &mut self,
         workspace: &Entity<Workspace>,
@@ -1466,6 +1559,8 @@ impl Render for Sidebar {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let ui_font = theme::setup_ui_font(window, cx);
         let has_query = self.has_filter_query(cx);
+        let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
+        let sticky_header = self.render_sticky_header(docked_right, window, cx);
 
         v_flex()
             .id("workspace-sidebar")
@@ -1484,10 +1579,7 @@ impl Render for Sidebar {
             .font(ui_font)
             .size_full()
             .bg(cx.theme().colors().surface_background)
-            .child({
-                let docked_right =
-                    AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
-
+            .child(
                 h_flex()
                     .h(Tab::container_height(cx))
                     .flex_none()
@@ -1513,10 +1605,11 @@ impl Render for Sidebar {
                         this.pl_2()
                             .pr_0p5()
                             .child(self.render_sidebar_toggle_button(true, cx))
-                    })
-            })
+                    }),
+            )
             .child(
                 v_flex()
+                    .relative()
                     .flex_1()
                     .overflow_hidden()
                     .child(
@@ -1527,6 +1620,7 @@ impl Render for Sidebar {
                         .flex_1()
                         .size_full(),
                     )
+                    .when_some(sticky_header, |this, header| this.child(header))
                     .vertical_scrollbar_for(&self.list_state, window, cx),
             )
     }