agent_ui: Add more refinements for v2 flag (#50968)

- Fixes message editor in empty state not consuming the whole height of
the panel
- Remove duped focused method in the `ListItem`
- Remove duped "new thread" buttons when group is empty
- Add some UI adjustments like removing labels and fading out truncated
items

Release Notes:

- N/A
This commit is contained in:
Danilo Leal 2026-03-06 19:47:04 -03:00 committed by GitHub
parent 1dd09c3e76
commit 1dd80ac28f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 202 additions and 139 deletions

View file

@ -3685,7 +3685,7 @@ impl AgentPanel {
h_flex()
.gap_1()
.child(agent_icon_element)
.child(Label::new(selected_agent_label).color(label_color))
.child(Label::new(selected_agent_label).color(label_color).ml_0p5())
.child(
Icon::new(chevron_icon)
.color(icon_color)

View file

@ -2715,6 +2715,31 @@ impl ThreadView {
(IconName::Maximize, "Expand Message Editor")
};
if v2_empty_state {
self.message_editor.update(cx, |editor, cx| {
editor.set_mode(
EditorMode::Full {
scale_ui_elements_with_buffer_font_size: false,
show_active_line_background: false,
sizing_behavior: SizingBehavior::Default,
},
cx,
);
});
} else {
self.message_editor.update(cx, |editor, cx| {
editor.set_mode(
EditorMode::AutoHeight {
min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
max_lines: Some(
AgentSettings::get_global(cx).set_message_editor_max_lines(),
),
},
cx,
);
});
}
v_flex()
.on_action(cx.listener(Self::expand_message_editor))
.p_2()
@ -2731,6 +2756,7 @@ impl ThreadView {
v_flex()
.relative()
.size_full()
.when(v2_empty_state, |this| this.flex_1())
.pt_1()
.pr_2p5()
.child(self.message_editor.clone())

View file

@ -1222,8 +1222,10 @@ impl MessageEditor {
pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.set_mode(mode);
cx.notify()
if *editor.mode() != mode {
editor.set_mode(mode);
cx.notify()
}
});
}

View file

@ -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]

View file

@ -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(),
),

View file

@ -42,7 +42,6 @@ pub struct ListItem {
selectable: bool,
always_show_disclosure_icon: bool,
outlined: bool,
selection_outlined: Option<bool>,
rounded: bool,
overflow_x: bool,
focused: Option<bool>,
@ -72,7 +71,6 @@ impl ListItem {
selectable: true,
always_show_disclosure_icon: false,
outlined: false,
selection_outlined: None,
rounded: false,
overflow_x: false,
focused: None,
@ -173,11 +171,6 @@ impl ListItem {
self
}
pub fn selection_outlined(mut self, outlined: bool) -> Self {
self.selection_outlined = Some(outlined);
self
}
pub fn rounded(mut self) -> Self {
self.rounded = true;
self
@ -248,13 +241,6 @@ impl RenderOnce for ListItem {
})
})
.when(self.rounded, |this| this.rounded_sm())
.when_some(self.selection_outlined, |this, outlined| {
this.border_1()
.border_color(gpui::transparent_black())
.when(outlined, |this| {
this.border_color(cx.theme().colors().panel_focused_border)
})
})
.when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover))
.child(
h_flex()