@@ -73,6 +73,7 @@ enum ListEntry {
label: SharedString,
workspace: Entity<Workspace>,
highlight_positions: Vec<usize>,
+ has_threads: bool,
},
Thread {
session_info: acp_thread::AgentSessionInfo,
@@ -322,10 +323,15 @@ impl Sidebar {
window,
|this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
AgentPanelEvent::ActiveViewChanged => {
- if let Some(thread) = agent_panel.read(cx).active_connection_view()
- && let Some(session_id) = thread.read(cx).parent_id(cx)
- {
- this.focused_thread = Some(session_id);
+ match agent_panel.read(cx).active_connection_view() {
+ Some(thread) => {
+ if let Some(session_id) = thread.read(cx).parent_id(cx) {
+ this.focused_thread = Some(session_id);
+ }
+ }
+ None => {
+ this.focused_thread = None;
+ }
}
this.update_entries(cx);
}
@@ -334,7 +340,7 @@ impl Sidebar {
.read(cx)
.active_connection_view()
.and_then(|thread| thread.read(cx).parent_id(cx));
- if new_focused != this.focused_thread {
+ if new_focused.is_some() && new_focused != this.focused_thread {
this.focused_thread = new_focused;
this.update_entries(cx);
}
@@ -522,6 +528,7 @@ impl Sidebar {
}
if !query.is_empty() {
+ let has_threads = !threads.is_empty();
let mut matched_threads = Vec::new();
for mut thread in threads {
if let ListEntry::Thread {
@@ -554,14 +561,17 @@ impl Sidebar {
label,
workspace: workspace.clone(),
highlight_positions: workspace_highlight_positions,
+ has_threads,
});
entries.extend(matched_threads);
} else {
+ let has_threads = !threads.is_empty();
entries.push(ListEntry::ProjectHeader {
path_list: path_list.clone(),
label,
workspace: workspace.clone(),
highlight_positions: Vec::new(),
+ has_threads,
});
if is_collapsed {
@@ -677,12 +687,14 @@ impl Sidebar {
label,
workspace,
highlight_positions,
+ has_threads,
} => self.render_project_header(
ix,
path_list,
label,
workspace,
highlight_positions,
+ *has_threads,
is_selected,
cx,
),
@@ -736,12 +748,12 @@ impl Sidebar {
label: &SharedString,
workspace: &Entity<Workspace>,
highlight_positions: &[usize],
+ has_threads: bool,
is_selected: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let id = SharedString::from(format!("project-header-{}", ix));
let ib_id = SharedString::from(format!("project-header-new-thread-{}", ix));
- let group = SharedString::from(format!("group-{}", ix));
let is_collapsed = self.collapsed_groups.contains(path_list);
let disclosure_icon = if is_collapsed {
@@ -774,20 +786,19 @@ impl Sidebar {
.into_any_element()
};
- // TODO: if is_selected, draw a blue border around the item.
-
ListItem::new(id)
- .selection_outlined(is_selected)
- .group_name(&group)
.toggle_state(is_active_workspace)
+ .focused(is_selected)
.child(
- h_flex().px_1().py_1p5().gap_0p5().child(label).child(
- div().visible_on_hover(group).child(
+ h_flex()
+ .p_1()
+ .gap_1p5()
+ .child(
Icon::new(disclosure_icon)
.size(IconSize::Small)
- .color(Color::Muted),
- ),
- ),
+ .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
+ )
+ .child(label),
)
.end_hover_slot(
h_flex()
@@ -808,18 +819,21 @@ impl Sidebar {
)),
)
})
- .child(
- IconButton::new(ib_id, IconName::NewThread)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .tooltip(Tooltip::text("New Thread"))
- .on_click(cx.listener(move |this, _, window, cx| {
- this.selection = None;
- this.create_new_thread(&workspace_for_new_thread, window, cx);
- })),
- ),
+ .when(has_threads, |this| {
+ this.child(
+ IconButton::new(ib_id, IconName::NewThread)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip(Tooltip::text("New Thread"))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.selection = None;
+ this.create_new_thread(&workspace_for_new_thread, window, cx);
+ })),
+ )
+ }),
)
.on_click(cx.listener(move |this, _, window, cx| {
+ this.selection = None;
this.toggle_collapse(&path_list_for_toggle, window, cx);
}))
// TODO: Decide if we really want the header to be activating different workspaces
@@ -887,12 +901,7 @@ impl Sidebar {
self.update_entries(cx);
}
- fn focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- if self.selection.is_none() && !self.contents.entries.is_empty() {
- self.selection = Some(0);
- cx.notify();
- }
- }
+ fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
if self.reset_filter_editor_text(window, cx) {
@@ -1122,7 +1131,7 @@ impl Sidebar {
.status(status)
.notified(has_notification)
.selected(self.focused_thread.as_ref() == Some(&session_info.session_id))
- .outlined(is_selected)
+ .focused(is_selected)
.on_click(cx.listener(move |this, _, window, cx| {
this.selection = None;
this.activate_thread(session_info.clone(), &workspace, window, cx);
@@ -1168,7 +1177,7 @@ impl Sidebar {
let count = format!("({})", remaining_count);
ListItem::new(id)
- .selection_outlined(is_selected)
+ .focused(is_selected)
.child(
h_flex()
.px_1()
@@ -1319,52 +1328,45 @@ impl Render for Sidebar {
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border)
- .child(
- h_flex()
- .gap_1()
- .child({
- let focus_handle_toggle = self.focus_handle.clone();
- let focus_handle_focus = self.focus_handle.clone();
- IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::element(move |_, cx| {
- v_flex()
- .gap_1()
- .child(
- h_flex()
- .gap_2()
- .justify_between()
- .child(Label::new("Close Sidebar"))
- .child(KeyBinding::for_action_in(
- &ToggleWorkspaceSidebar,
- &focus_handle_toggle,
- cx,
- )),
- )
- .child(
- h_flex()
- .pt_1()
- .gap_2()
- .border_t_1()
- .border_color(
- cx.theme().colors().border_variant,
- )
- .justify_between()
- .child(Label::new(focus_tooltip_label))
- .child(KeyBinding::for_action_in(
- &FocusWorkspaceSidebar,
- &focus_handle_focus,
- cx,
- )),
- )
- .into_any_element()
- }))
- .on_click(cx.listener(|_this, _, _window, cx| {
- cx.emit(SidebarEvent::Close);
- }))
- })
- .child(Label::new("Threads").size(LabelSize::Small)),
- )
+ .child({
+ let focus_handle_toggle = self.focus_handle.clone();
+ let focus_handle_focus = self.focus_handle.clone();
+ IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::element(move |_, cx| {
+ v_flex()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .justify_between()
+ .child(Label::new("Close Sidebar"))
+ .child(KeyBinding::for_action_in(
+ &ToggleWorkspaceSidebar,
+ &focus_handle_toggle,
+ cx,
+ )),
+ )
+ .child(
+ h_flex()
+ .pt_1()
+ .gap_2()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ .justify_between()
+ .child(Label::new(focus_tooltip_label))
+ .child(KeyBinding::for_action_in(
+ &FocusWorkspaceSidebar,
+ &focus_handle_focus,
+ cx,
+ )),
+ )
+ .into_any_element()
+ }))
+ .on_click(cx.listener(|_this, _, _window, cx| {
+ cx.emit(SidebarEvent::Close);
+ }))
+ })
.child(
IconButton::new("open-project", IconName::OpenFolder)
.icon_size(IconSize::Small)
@@ -1852,6 +1854,7 @@ mod tests {
label: "expanded-project".into(),
workspace: workspace.clone(),
highlight_positions: Vec::new(),
+ has_threads: true,
},
// Thread with default (Completed) status, not active
ListEntry::Thread {
@@ -1954,6 +1957,7 @@ mod tests {
label: "collapsed-project".into(),
workspace: workspace.clone(),
highlight_positions: Vec::new(),
+ has_threads: true,
},
];
// Select the Running thread (index 2)
@@ -2014,11 +2018,16 @@ mod tests {
cx.run_until_parked();
// Entries: [header, thread3, thread2, thread1]
- // Focusing the sidebar triggers focus_in, which selects the first entry
+ // Focusing the sidebar does not set a selection; select_next/select_previous
+ // handle None gracefully by starting from the first or last entry.
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
+
+ // First SelectNext from None starts at index 0
+ cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
- // Move down through all entries
+ // Move down through remaining entries
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
@@ -2072,7 +2081,7 @@ mod tests {
}
#[gpui::test]
- async fn test_keyboard_focus_in_selects_first(cx: &mut TestAppContext) {
+ async fn test_keyboard_focus_in_does_not_set_selection(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));
@@ -2081,11 +2090,16 @@ mod tests {
// Initially no selection
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
- // Open the sidebar so it's rendered, then focus it to trigger focus_in
+ // Open the sidebar so it's rendered, then focus it to trigger focus_in.
+ // focus_in no longer sets a default selection.
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+ assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
+
+ // Manually set a selection, blur, then refocus — selection should be preserved
+ sidebar.update_in(cx, |sidebar, _window, _cx| {
+ sidebar.selection = Some(0);
+ });
- // Blur the sidebar, then refocus — existing selection should be preserved
cx.update(|window, _cx| {
window.blur();
});
@@ -2135,9 +2149,11 @@ mod tests {
1
);
- // Focus the sidebar — focus_in selects the header (index 0)
+ // Focus the sidebar and manually select the header (index 0)
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+ sidebar.update_in(cx, |sidebar, _window, _cx| {
+ sidebar.selection = Some(0);
+ });
// Press confirm on project header (workspace 0) to activate it.
cx.dispatch_action(Confirm);
@@ -2176,9 +2192,9 @@ mod tests {
assert_eq!(entries.len(), 7);
assert!(entries.iter().any(|e| e.contains("View More (3)")));
- // Focus sidebar (selects index 0), then navigate down to the "View More" entry (index 6)
+ // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
- for _ in 0..6 {
+ for _ in 0..7 {
cx.dispatch_action(SelectNext);
}
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
@@ -2210,9 +2226,11 @@ mod tests {
vec!["v [my-project]", " Thread 1"]
);
- // Focus sidebar — focus_in selects the header (index 0). Press left to collapse.
+ // Focus sidebar and manually select the header (index 0). Press left to collapse.
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+ sidebar.update_in(cx, |sidebar, _window, _cx| {
+ sidebar.selection = Some(0);
+ });
cx.dispatch_action(CollapseSelectedEntry);
cx.run_until_parked();
@@ -2248,9 +2266,10 @@ mod tests {
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
- // Focus sidebar (selects header at index 0), then navigate down to the thread (child)
+ // Focus sidebar (selection starts at None), then navigate down to the thread (child)
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
cx.dispatch_action(SelectNext);
+ cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
assert_eq!(
@@ -2282,8 +2301,12 @@ mod tests {
vec!["v [empty-project]", " [+ New Thread]"]
);
- // Focus sidebar — focus_in selects the first entry (header at 0)
+ // Focus sidebar — focus_in does not set a selection
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
+
+ // First SelectNext from None starts at index 0 (header)
+ cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
// SelectNext moves to the new thread button
@@ -2311,9 +2334,10 @@ mod tests {
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
- // Focus sidebar (selects header at 0), navigate down to the thread (index 1)
+ // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
cx.dispatch_action(SelectNext);
+ cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
// Collapse the group, which removes the thread from the list
@@ -2935,9 +2959,11 @@ mod tests {
cx.run_until_parked();
// User focuses the sidebar and collapses the group using keyboard:
- // select the header, then press CollapseSelectedEntry to collapse.
+ // manually select the header, then press CollapseSelectedEntry to collapse.
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+ sidebar.update_in(cx, |sidebar, _window, _cx| {
+ sidebar.selection = Some(0);
+ });
cx.dispatch_action(CollapseSelectedEntry);
cx.run_until_parked();
@@ -3151,15 +3177,12 @@ mod tests {
});
assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
- // When the user tabs back into the sidebar, focus_in restores
- // selection to the first entry for keyboard navigation.
+ // 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);
});
- assert_eq!(
- sidebar.read_with(cx, |sidebar, _| sidebar.selection),
- Some(0)
- );
+ assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
}
#[gpui::test]
@@ -3,7 +3,7 @@ use crate::{
prelude::*,
};
-use gpui::{AnyView, ClickEvent, Hsla, SharedString};
+use gpui::{AnyView, ClickEvent, Hsla, SharedString, linear_color_stop, linear_gradient};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AgentThreadStatus {
@@ -24,7 +24,7 @@ pub struct ThreadItem {
notified: bool,
status: AgentThreadStatus,
selected: bool,
- outlined: bool,
+ focused: bool,
hovered: bool,
added: Option<usize>,
removed: Option<usize>,
@@ -48,7 +48,7 @@ impl ThreadItem {
notified: false,
status: AgentThreadStatus::default(),
selected: false,
- outlined: false,
+ focused: false,
hovered: false,
added: None,
removed: None,
@@ -92,8 +92,8 @@ impl ThreadItem {
self
}
- pub fn outlined(mut self, outlined: bool) -> Self {
- self.outlined = outlined;
+ pub fn focused(mut self, focused: bool) -> Self {
+ self.focused = focused;
self
}
@@ -153,7 +153,7 @@ impl ThreadItem {
impl RenderOnce for ThreadItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
- let clr = cx.theme().colors();
+ let color = cx.theme().colors();
// let dot_separator = || {
// Label::new("•")
// .size(LabelSize::Small)
@@ -161,7 +161,7 @@ impl RenderOnce for ThreadItem {
// .alpha(0.5)
// };
- let icon_container = || h_flex().size_4().justify_center();
+ let icon_container = || h_flex().size_4().flex_none().justify_center();
let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
Icon::from_external_svg(custom_svg)
.color(Color::Muted)
@@ -189,7 +189,7 @@ impl RenderOnce for ThreadItem {
} else if self.status == AgentThreadStatus::Error {
Some(decoration(IconDecorationKind::X, cx.theme().status().error))
} else if self.notified {
- Some(decoration(IconDecorationKind::Dot, clr.text_accent))
+ Some(decoration(IconDecorationKind::Dot, color.text_accent))
} else {
None
};
@@ -209,15 +209,41 @@ impl RenderOnce for ThreadItem {
let title = self.title;
let highlight_positions = self.highlight_positions;
let title_label = if highlight_positions.is_empty() {
- Label::new(title).truncate().into_any_element()
+ Label::new(title).into_any_element()
} else {
- HighlightedLabel::new(title, highlight_positions)
- .truncate()
- .into_any_element()
+ HighlightedLabel::new(title, highlight_positions).into_any_element()
};
+ let base_bg = if self.selected {
+ color.element_active
+ } else {
+ color.panel_background
+ };
+
+ let gradient_overlay = div()
+ .absolute()
+ .top_0()
+ .right(px(-10.0))
+ .w_12()
+ .h_full()
+ .bg(linear_gradient(
+ 90.,
+ linear_color_stop(base_bg, 0.6),
+ linear_color_stop(base_bg.opacity(0.0), 0.),
+ ))
+ .group_hover("thread-item", |s| {
+ s.bg(linear_gradient(
+ 90.,
+ linear_color_stop(color.element_hover, 0.6),
+ linear_color_stop(color.element_hover.opacity(0.0), 0.),
+ ))
+ });
+
v_flex()
.id(self.id.clone())
+ .group("thread-item")
+ .relative()
+ .overflow_hidden()
.cursor_pointer()
.w_full()
.map(|this| {
@@ -227,11 +253,11 @@ impl RenderOnce for ThreadItem {
this.px_2().py_1()
}
})
- .when(self.selected, |s| s.bg(clr.element_active))
+ .when(self.selected, |s| s.bg(color.element_active))
.border_1()
.border_color(gpui::transparent_black())
- .when(self.outlined, |s| s.border_color(clr.panel_focused_border))
- .hover(|s| s.bg(clr.element_hover))
+ .when(self.focused, |s| s.border_color(color.panel_focused_border))
+ .hover(|s| s.bg(color.element_hover))
.on_hover(self.on_hover)
.child(
h_flex()
@@ -249,6 +275,7 @@ impl RenderOnce for ThreadItem {
.child(title_label)
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
)
+ .child(gradient_overlay)
.when(running_or_action, |this| {
this.child(
h_flex()
@@ -271,7 +298,6 @@ impl RenderOnce for ThreadItem {
Label::new(worktree)
.size(LabelSize::Small)
.color(Color::Muted)
- .truncate_start()
.into_any_element()
} else {
HighlightedLabel::new(worktree, worktree_highlight_positions)
@@ -420,25 +446,25 @@ impl Component for ThreadItem {
.into_any_element(),
),
single_example(
- "Outlined Item (Keyboard Selection)",
+ "Focused Item (Keyboard Selection)",
container()
.child(
ThreadItem::new("ti-7", "Implement keyboard navigation")
.icon(IconName::AiClaude)
.timestamp("4:00 PM")
- .outlined(true),
+ .focused(true),
)
.into_any_element(),
),
single_example(
- "Selected + Outlined",
+ "Selected + Focused",
container()
.child(
ThreadItem::new("ti-8", "Active and keyboard-focused thread")
.icon(IconName::AiGemini)
.timestamp("5:00 PM")
.selected(true)
- .outlined(true),
+ .focused(true),
)
.into_any_element(),
),