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

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