thread view: Refine the UI a bit (#36504)

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
Danilo Leal 2025-08-20 01:47:28 -03:00 committed by GitHub
parent fbba6addfd
commit 60960409f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 262 additions and 145 deletions

View file

@ -27,16 +27,15 @@ impl AgentServer for NativeAgentServer {
}
fn empty_state_headline(&self) -> &'static str {
"Native Agent"
""
}
fn empty_state_message(&self) -> &'static str {
"How can I help you today?"
""
}
fn logo(&self) -> ui::IconName {
// Using the ZedAssistant icon as it's the native built-in agent
ui::IconName::ZedAssistant
ui::IconName::ZedAgent
}
fn connect(

View file

@ -26,7 +26,7 @@ impl AgentServer for Gemini {
}
fn empty_state_message(&self) -> &'static str {
"Ask questions, edit files, run commands.\nBe specific for the best results."
"Ask questions, edit files, run commands"
}
fn logo(&self) -> ui::IconName {

View file

@ -189,6 +189,7 @@ pub enum ViewEvent {
MessageEditorEvent(Entity<MessageEditor>, MessageEditorEvent),
}
#[derive(Debug)]
pub enum Entry {
UserMessage(Entity<MessageEditor>),
Content(HashMap<EntityId, AnyEntity>),

View file

@ -21,11 +21,11 @@ use file_icons::FileIcons;
use fs::Fs;
use gpui::{
Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
EdgesRefinement, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset, ListState,
MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription, Task,
TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window,
WindowHandle, div, linear_color_stop, linear_gradient, list, percentage, point, prelude::*,
pulsating_between,
EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset,
ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription,
Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window,
WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, point,
prelude::*, pulsating_between,
};
use language::Buffer;
@ -170,7 +170,7 @@ impl AcpThreadView {
project.clone(),
thread_store.clone(),
text_thread_store.clone(),
"Message the agent @ to include context",
"Message the agent @ to include context",
prevent_slash_commands,
editor::EditorMode::AutoHeight {
min_lines: MIN_EDITOR_LINES,
@ -928,29 +928,41 @@ impl AcpThreadView {
None
};
div()
v_flex()
.id(("user_message", entry_ix))
.py_4()
.pt_2()
.pb_4()
.px_2()
.gap_1p5()
.w_full()
.children(rules_item)
.children(message.id.clone().and_then(|message_id| {
message.checkpoint.as_ref()?.show.then(|| {
Button::new("restore-checkpoint", "Restore Checkpoint")
.icon(IconName::Undo)
.icon_size(IconSize::XSmall)
.icon_position(IconPosition::Start)
.label_size(LabelSize::XSmall)
.on_click(cx.listener(move |this, _, _window, cx| {
this.rewind(&message_id, cx);
}))
h_flex()
.gap_2()
.child(Divider::horizontal())
.child(
Button::new("restore-checkpoint", "Restore Checkpoint")
.icon(IconName::Undo)
.icon_size(IconSize::XSmall)
.icon_position(IconPosition::Start)
.label_size(LabelSize::XSmall)
.icon_color(Color::Muted)
.color(Color::Muted)
.on_click(cx.listener(move |this, _, _window, cx| {
this.rewind(&message_id, cx);
}))
)
.child(Divider::horizontal())
})
}))
.children(rules_item)
.child(
div()
.relative()
.child(
div()
.p_3()
.py_3()
.px_2()
.rounded_lg()
.shadow_md()
.bg(cx.theme().colors().editor_background)
@ -1080,12 +1092,20 @@ impl AcpThreadView {
if let Some(editing_index) = self.editing_message.as_ref()
&& *editing_index < entry_ix
{
div()
.child(primary)
.opacity(0.2)
let backdrop = div()
.id(("backdrop", entry_ix))
.size_full()
.absolute()
.inset_0()
.bg(cx.theme().colors().panel_background)
.opacity(0.8)
.block_mouse_except_scroll()
.id("overlay")
.on_click(cx.listener(Self::cancel_editing))
.on_click(cx.listener(Self::cancel_editing));
div()
.relative()
.child(primary)
.child(backdrop)
.into_any_element()
} else {
primary
@ -1100,7 +1120,7 @@ impl AcpThreadView {
}
fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
cx.theme().colors().border.opacity(0.6)
cx.theme().colors().border.opacity(0.8)
}
fn tool_name_font_size(&self) -> Rems {
@ -1299,23 +1319,14 @@ impl AcpThreadView {
tool_call.status,
ToolCallStatus::WaitingForConfirmation { .. }
);
let is_edit = matches!(tool_call.kind, acp::ToolKind::Edit);
let has_diff = tool_call
.content
.iter()
.any(|content| matches!(content, ToolCallContent::Diff { .. }));
let has_nonempty_diff = tool_call.content.iter().any(|content| match content {
ToolCallContent::Diff(diff) => diff.read(cx).has_revealed_range(cx),
_ => false,
});
let use_card_layout = needs_confirmation || is_edit || has_diff;
let is_edit =
matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
let use_card_layout = needs_confirmation || is_edit;
let is_collapsible = !tool_call.content.is_empty() && !use_card_layout;
let is_open = tool_call.content.is_empty()
|| needs_confirmation
|| has_nonempty_diff
|| self.expanded_tool_calls.contains(&tool_call.id);
let is_open =
needs_confirmation || is_edit || self.expanded_tool_calls.contains(&tool_call.id);
let gradient_overlay = |color: Hsla| {
div()
@ -1336,41 +1347,49 @@ impl AcpThreadView {
cx.theme().colors().panel_background
};
let tool_output_display = match &tool_call.status {
ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
.w_full()
.children(tool_call.content.iter().map(|content| {
div()
.child(
self.render_tool_call_content(entry_ix, content, tool_call, window, cx),
)
.into_any_element()
}))
.child(self.render_permission_buttons(
options,
entry_ix,
tool_call.id.clone(),
tool_call.content.is_empty(),
cx,
)),
ToolCallStatus::Pending
| ToolCallStatus::InProgress
| ToolCallStatus::Completed
| ToolCallStatus::Failed
| ToolCallStatus::Canceled => {
v_flex()
let tool_output_display = if is_open {
match &tool_call.status {
ToolCallStatus::WaitingForConfirmation { options, .. } => {
v_flex()
.w_full()
.children(tool_call.content.iter().map(|content| {
div()
.child(self.render_tool_call_content(
entry_ix, content, tool_call, window, cx,
))
.into_any_element()
}))
.child(self.render_permission_buttons(
options,
entry_ix,
tool_call.id.clone(),
tool_call.content.is_empty(),
cx,
))
.into_any()
}
ToolCallStatus::Pending | ToolCallStatus::InProgress
if is_edit && tool_call.content.is_empty() =>
{
self.render_diff_loading(cx).into_any()
}
ToolCallStatus::Pending
| ToolCallStatus::InProgress
| ToolCallStatus::Completed
| ToolCallStatus::Failed
| ToolCallStatus::Canceled => v_flex()
.w_full()
.children(tool_call.content.iter().map(|content| {
div()
.child(
self.render_tool_call_content(
entry_ix, content, tool_call, window, cx,
),
)
.into_any_element()
div().child(
self.render_tool_call_content(entry_ix, content, tool_call, window, cx),
)
}))
.into_any(),
ToolCallStatus::Rejected => Empty.into_any(),
}
ToolCallStatus::Rejected => v_flex().size_0(),
.into()
} else {
None
};
v_flex()
@ -1390,9 +1409,13 @@ impl AcpThreadView {
.map(|this| {
if use_card_layout {
this.pl_2()
.pr_1()
.pr_1p5()
.py_1()
.rounded_t_md()
.when(is_open, |this| {
this.border_b_1()
.border_color(self.tool_card_border_color(cx))
})
.bg(self.tool_card_header_bg(cx))
} else {
this.opacity(0.8).hover(|style| style.opacity(1.))
@ -1403,6 +1426,7 @@ impl AcpThreadView {
.group(&card_header_id)
.relative()
.w_full()
.min_h_6()
.text_size(self.tool_name_font_size())
.child(self.render_tool_call_icon(
card_header_id,
@ -1456,11 +1480,7 @@ impl AcpThreadView {
.overflow_x_scroll()
.child(self.render_markdown(
tool_call.label.clone(),
default_markdown_style(
needs_confirmation || is_edit || has_diff,
window,
cx,
),
default_markdown_style(false, window, cx),
)),
)
.child(gradient_overlay(gradient_color))
@ -1480,7 +1500,7 @@ impl AcpThreadView {
)
.children(status_icon),
)
.when(is_open, |this| this.child(tool_output_display))
.children(tool_output_display)
}
fn render_tool_call_content(
@ -1501,7 +1521,7 @@ impl AcpThreadView {
Empty.into_any_element()
}
}
ToolCallContent::Diff(diff) => self.render_diff_editor(entry_ix, diff, cx),
ToolCallContent::Diff(diff) => self.render_diff_editor(entry_ix, diff, tool_call, cx),
ToolCallContent::Terminal(terminal) => {
self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx)
}
@ -1645,21 +1665,69 @@ impl AcpThreadView {
})))
}
fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
let bar = |n: u64, width_class: &str| {
let bg_color = cx.theme().colors().element_active;
let base = h_flex().h_1().rounded_full();
let modified = match width_class {
"w_4_5" => base.w_3_4(),
"w_1_4" => base.w_1_4(),
"w_2_4" => base.w_2_4(),
"w_3_5" => base.w_3_5(),
"w_2_5" => base.w_2_5(),
_ => base.w_1_2(),
};
modified.with_animation(
ElementId::Integer(n),
Animation::new(Duration::from_secs(2)).repeat(),
move |tab, delta| {
let delta = (delta - 0.15 * n as f32) / 0.7;
let delta = 1.0 - (0.5 - delta).abs() * 2.;
let delta = ease_in_out(delta.clamp(0., 1.));
let delta = 0.1 + 0.9 * delta;
tab.bg(bg_color.opacity(delta))
},
)
};
v_flex()
.p_3()
.gap_1()
.rounded_b_md()
.bg(cx.theme().colors().editor_background)
.child(bar(0, "w_4_5"))
.child(bar(1, "w_1_4"))
.child(bar(2, "w_2_4"))
.child(bar(3, "w_3_5"))
.child(bar(4, "w_2_5"))
.into_any_element()
}
fn render_diff_editor(
&self,
entry_ix: usize,
diff: &Entity<acp_thread::Diff>,
tool_call: &ToolCall,
cx: &Context<Self>,
) -> AnyElement {
let tool_progress = matches!(
&tool_call.status,
ToolCallStatus::InProgress | ToolCallStatus::Pending
);
v_flex()
.h_full()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.child(
if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix)
&& let Some(editor) = entry.editor_for_diff(diff)
&& diff.read(cx).has_revealed_range(cx)
{
editor.clone().into_any_element()
} else if tool_progress {
self.render_diff_loading(cx)
} else {
Empty.into_any()
},
@ -1924,11 +1992,11 @@ impl AcpThreadView {
.justify_center()
.child(div().opacity(0.3).child(logo))
.child(
h_flex().absolute().right_1().bottom_0().child(
Icon::new(IconName::XCircle)
.color(Color::Error)
.size(IconSize::Small),
),
h_flex()
.absolute()
.right_1()
.bottom_0()
.child(Icon::new(IconName::XCircleFilled).color(Color::Error)),
)
.into_any_element()
}
@ -1982,12 +2050,12 @@ impl AcpThreadView {
Some(
v_flex()
.pt_2()
.px_2p5()
.gap_1()
.when_some(user_rules_text, |parent, user_rules_text| {
parent.child(
h_flex()
.group("user-rules")
.w_full()
.child(
Icon::new(IconName::Reader)
@ -2008,6 +2076,7 @@ impl AcpThreadView {
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.icon_color(Color::Ignored)
.visible_on_hover("user-rules")
// TODO: Figure out a way to pass focus handle here so we can display the `OpenRulesLibrary` keybinding
.tooltip(Tooltip::text("View User Rules"))
.on_click(move |_event, window, cx| {
@ -2024,6 +2093,7 @@ impl AcpThreadView {
.when_some(rules_file_text, |parent, rules_file_text| {
parent.child(
h_flex()
.group("project-rules")
.w_full()
.child(
Icon::new(IconName::File)
@ -2044,7 +2114,8 @@ impl AcpThreadView {
.icon_size(IconSize::XSmall)
.icon_color(Color::Ignored)
.on_click(cx.listener(Self::handle_open_rules))
.tooltip(Tooltip::text("View Rules")),
.visible_on_hover("project-rules")
.tooltip(Tooltip::text("View Project Rules")),
),
)
})
@ -2119,11 +2190,9 @@ impl AcpThreadView {
.items_center()
.justify_center()
.child(self.render_error_agent_logo())
.child(
h_flex().mt_4().mb_1().justify_center().child(
Headline::new("Authentication Required").size(HeadlineSize::Medium),
),
)
.child(h_flex().mt_4().mb_1().justify_center().child(
Headline::new(self.agent.empty_state_headline()).size(HeadlineSize::Medium),
))
.into_any(),
)
.children(description.map(|desc| {
@ -2838,10 +2907,10 @@ impl AcpThreadView {
.child(
h_flex()
.flex_none()
.flex_wrap()
.justify_between()
.child(
h_flex()
.gap_1()
.child(self.render_follow_toggle(cx))
.children(self.render_burn_mode_toggle(cx)),
)
@ -2883,7 +2952,7 @@ impl AcpThreadView {
h_flex()
.flex_shrink_0()
.gap_0p5()
.mr_1()
.mr_1p5()
.child(
Label::new(used)
.size(LabelSize::Small)
@ -2904,7 +2973,11 @@ impl AcpThreadView {
}
}),
)
.child(Label::new("/").size(LabelSize::Small).color(Color::Muted))
.child(
Label::new("/")
.size(LabelSize::Small)
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
)
.child(Label::new(max).size(LabelSize::Small).color(Color::Muted)),
)
}

View file

@ -65,8 +65,8 @@ use theme::ThemeSettings;
use time::UtcOffset;
use ui::utils::WithRemSize;
use ui::{
Banner, Callout, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, KeyBinding,
PopoverMenu, PopoverMenuHandle, ProgressBar, Tab, Tooltip, prelude::*,
Banner, Callout, ContextMenu, ContextMenuEntry, ElevationIndex, KeyBinding, PopoverMenu,
PopoverMenuHandle, ProgressBar, Tab, Tooltip, prelude::*,
};
use util::ResultExt as _;
use workspace::{
@ -245,17 +245,16 @@ impl AgentType {
match self {
Self::Zed | Self::TextThread => "Zed Agent",
Self::NativeAgent => "Agent 2",
Self::Gemini => "Google Gemini",
Self::Gemini => "Gemini CLI",
Self::ClaudeCode => "Claude Code",
}
}
fn icon(self) -> IconName {
fn icon(self) -> Option<IconName> {
match self {
Self::Zed | Self::TextThread => IconName::AiZed,
Self::NativeAgent => IconName::ZedAssistant,
Self::Gemini => IconName::AiGemini,
Self::ClaudeCode => IconName::AiClaude,
Self::Zed | Self::NativeAgent | Self::TextThread => None,
Self::Gemini => Some(IconName::AiGemini),
Self::ClaudeCode => Some(IconName::AiClaude),
}
}
}
@ -2158,12 +2157,17 @@ impl AgentPanel {
})
}
fn render_recent_entries_menu(&self, cx: &mut Context<Self>) -> impl IntoElement {
fn render_recent_entries_menu(
&self,
icon: IconName,
corner: Corner,
cx: &mut Context<Self>,
) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
PopoverMenu::new("agent-nav-menu")
.trigger_with_tooltip(
IconButton::new("agent-nav-menu", IconName::MenuAlt).icon_size(IconSize::Small),
IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small),
{
let focus_handle = focus_handle.clone();
move |window, cx| {
@ -2177,7 +2181,7 @@ impl AgentPanel {
}
},
)
.anchor(Corner::TopLeft)
.anchor(corner)
.with_handle(self.assistant_navigation_menu_handle.clone())
.menu({
let menu = self.assistant_navigation_menu.clone();
@ -2304,7 +2308,9 @@ impl AgentPanel {
.pl(DynamicSpacing::Base04.rems(cx))
.child(self.render_toolbar_back_button(cx))
.into_any_element(),
_ => self.render_recent_entries_menu(cx).into_any_element(),
_ => self
.render_recent_entries_menu(IconName::MenuAlt, Corner::TopLeft, cx)
.into_any_element(),
})
.child(self.render_title_view(window, cx)),
)
@ -2390,7 +2396,7 @@ impl AgentPanel {
.item(
ContextMenuEntry::new("New Thread")
.action(NewThread::default().boxed_clone())
.icon(IconName::ZedAssistant)
.icon(IconName::Thread)
.icon_color(Color::Muted)
.handler({
let workspace = workspace.clone();
@ -2443,7 +2449,7 @@ impl AgentPanel {
.header("External Agents")
.when(cx.has_flag::<GeminiAndNativeFeatureFlag>(), |menu| {
menu.item(
ContextMenuEntry::new("New Gemini Thread")
ContextMenuEntry::new("New Gemini CLI Thread")
.icon(IconName::AiGemini)
.icon_color(Color::Muted)
.handler({
@ -2503,16 +2509,18 @@ impl AgentPanel {
let selected_agent_label = self.selected_agent.label().into();
let selected_agent = div()
.id("selected_agent_icon")
.px(DynamicSpacing::Base02.rems(cx))
.child(Icon::new(self.selected_agent.icon()).color(Color::Muted))
.tooltip(move |window, cx| {
Tooltip::with_meta(
selected_agent_label.clone(),
None,
"Selected Agent",
window,
cx,
)
.when_some(self.selected_agent.icon(), |this, icon| {
this.px(DynamicSpacing::Base02.rems(cx))
.child(Icon::new(icon).color(Color::Muted))
.tooltip(move |window, cx| {
Tooltip::with_meta(
selected_agent_label.clone(),
None,
"Selected Agent",
window,
cx,
)
})
})
.into_any_element();
@ -2535,31 +2543,23 @@ impl AgentPanel {
ActiveView::History | ActiveView::Configuration => {
self.render_toolbar_back_button(cx).into_any_element()
}
_ => h_flex()
.gap_1()
.child(self.render_recent_entries_menu(cx))
.child(Divider::vertical())
.child(selected_agent)
.into_any_element(),
_ => selected_agent.into_any_element(),
})
.child(self.render_title_view(window, cx)),
)
.child(
h_flex()
.h_full()
.gap_2()
.children(self.render_token_count(cx))
.child(
h_flex()
.h_full()
.gap(DynamicSpacing::Base02.rems(cx))
.pl(DynamicSpacing::Base04.rems(cx))
.pr(DynamicSpacing::Base06.rems(cx))
.border_l_1()
.border_color(cx.theme().colors().border)
.child(new_thread_menu)
.child(self.render_panel_options_menu(window, cx)),
),
.flex_none()
.gap(DynamicSpacing::Base02.rems(cx))
.pl(DynamicSpacing::Base04.rems(cx))
.pr(DynamicSpacing::Base06.rems(cx))
.child(new_thread_menu)
.child(self.render_recent_entries_menu(
IconName::MenuAltTemp,
Corner::TopRight,
cx,
))
.child(self.render_panel_options_menu(window, cx)),
)
}

View file

@ -155,6 +155,7 @@ pub enum IconName {
Maximize,
Menu,
MenuAlt,
MenuAltTemp,
Mic,
MicMute,
Minimize,
@ -245,6 +246,8 @@ pub enum IconName {
Warning,
WholeWord,
XCircle,
XCircleFilled,
ZedAgent,
ZedAssistant,
ZedBurnMode,
ZedBurnModeOn,

View file

@ -1084,7 +1084,13 @@ impl Element for MarkdownElement {
cx,
);
el.child(
div().absolute().top_1().right_0p5().w_5().child(codeblock),
h_flex()
.w_5()
.absolute()
.top_1()
.right_1()
.justify_center()
.child(codeblock),
)
});
}