thread view: Add improvements to the UI (#36680)

Release Notes:

- N/A
This commit is contained in:
Danilo Leal 2025-08-21 17:05:29 -03:00 committed by GitHub
parent 2234f91b7b
commit 555692fac6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 269 additions and 49 deletions

View file

@ -44,7 +44,7 @@ pub struct ClaudeCode;
impl AgentServer for ClaudeCode { impl AgentServer for ClaudeCode {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"Claude Code" "Welcome to Claude Code"
} }
fn empty_state_headline(&self) -> &'static str { fn empty_state_headline(&self) -> &'static str {

View file

@ -41,7 +41,7 @@ use text::Anchor;
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::{ use ui::{
Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle, Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
Scrollbar, ScrollbarState, Tooltip, prelude::*, Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
}; };
use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use workspace::{CollaboratorId, Workspace}; use workspace::{CollaboratorId, Workspace};
@ -1205,7 +1205,7 @@ impl AcpThreadView {
div() div()
.py_3() .py_3()
.px_2() .px_2()
.rounded_lg() .rounded_md()
.shadow_md() .shadow_md()
.bg(cx.theme().colors().editor_background) .bg(cx.theme().colors().editor_background)
.border_1() .border_1()
@ -1263,7 +1263,7 @@ impl AcpThreadView {
.into_any() .into_any()
} }
AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
let style = default_markdown_style(false, window, cx); let style = default_markdown_style(false, false, window, cx);
let message_body = v_flex() let message_body = v_flex()
.w_full() .w_full()
.gap_2p5() .gap_2p5()
@ -1398,8 +1398,6 @@ impl AcpThreadView {
.relative() .relative()
.w_full() .w_full()
.gap_1p5() .gap_1p5()
.opacity(0.8)
.hover(|style| style.opacity(1.))
.child( .child(
h_flex() h_flex()
.size_4() .size_4()
@ -1440,6 +1438,7 @@ impl AcpThreadView {
.child( .child(
div() div()
.text_size(self.tool_name_font_size()) .text_size(self.tool_name_font_size())
.text_color(cx.theme().colors().text_muted)
.child("Thinking"), .child("Thinking"),
) )
.on_click(cx.listener({ .on_click(cx.listener({
@ -1463,9 +1462,10 @@ impl AcpThreadView {
.border_l_1() .border_l_1()
.border_color(self.tool_card_border_color(cx)) .border_color(self.tool_card_border_color(cx))
.text_ui_sm(cx) .text_ui_sm(cx)
.child( .child(self.render_markdown(
self.render_markdown(chunk, default_markdown_style(false, window, cx)), chunk,
), default_markdown_style(false, false, window, cx),
)),
) )
}) })
.into_any_element() .into_any_element()
@ -1555,11 +1555,11 @@ impl AcpThreadView {
| ToolCallStatus::Completed => None, | ToolCallStatus::Completed => None,
ToolCallStatus::InProgress => Some( ToolCallStatus::InProgress => Some(
Icon::new(IconName::ArrowCircle) Icon::new(IconName::ArrowCircle)
.color(Color::Accent) .color(Color::Muted)
.size(IconSize::Small) .size(IconSize::Small)
.with_animation( .with_animation(
"running", "running",
Animation::new(Duration::from_secs(2)).repeat(), Animation::new(Duration::from_secs(3)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))), |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
) )
.into_any(), .into_any(),
@ -1572,6 +1572,10 @@ impl AcpThreadView {
), ),
}; };
let failed_tool_call = matches!(
tool_call.status,
ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
);
let needs_confirmation = matches!( let needs_confirmation = matches!(
tool_call.status, tool_call.status,
ToolCallStatus::WaitingForConfirmation { .. } ToolCallStatus::WaitingForConfirmation { .. }
@ -1652,7 +1656,7 @@ impl AcpThreadView {
v_flex() v_flex()
.when(use_card_layout, |this| { .when(use_card_layout, |this| {
this.rounded_lg() this.rounded_md()
.border_1() .border_1()
.border_color(self.tool_card_border_color(cx)) .border_color(self.tool_card_border_color(cx))
.bg(cx.theme().colors().editor_background) .bg(cx.theme().colors().editor_background)
@ -1664,20 +1668,16 @@ impl AcpThreadView {
.w_full() .w_full()
.gap_1() .gap_1()
.justify_between() .justify_between()
.map(|this| { .when(use_card_layout, |this| {
if use_card_layout { this.pl_2()
this.pl_2() .pr_1p5()
.pr_1p5() .py_1()
.py_1() .rounded_t_md()
.rounded_t_md() .when(is_open && !failed_tool_call, |this| {
.when(is_open, |this| { this.border_b_1()
this.border_b_1() .border_color(self.tool_card_border_color(cx))
.border_color(self.tool_card_border_color(cx)) })
}) .bg(self.tool_card_header_bg(cx))
.bg(self.tool_card_header_bg(cx))
} else {
this.opacity(0.8).hover(|style| style.opacity(1.))
}
}) })
.child( .child(
h_flex() h_flex()
@ -1709,13 +1709,15 @@ impl AcpThreadView {
.px_1p5() .px_1p5()
.rounded_sm() .rounded_sm()
.overflow_x_scroll() .overflow_x_scroll()
.opacity(0.8)
.hover(|label| { .hover(|label| {
label.opacity(1.).bg(cx label.bg(cx.theme().colors().element_hover.opacity(0.5))
.theme() })
.colors() .map(|this| {
.element_hover if use_card_layout {
.opacity(0.5)) this.text_color(cx.theme().colors().text)
} else {
this.text_color(cx.theme().colors().text_muted)
}
}) })
.child(name) .child(name)
.tooltip(Tooltip::text("Jump to File")) .tooltip(Tooltip::text("Jump to File"))
@ -1738,7 +1740,7 @@ impl AcpThreadView {
.overflow_x_scroll() .overflow_x_scroll()
.child(self.render_markdown( .child(self.render_markdown(
tool_call.label.clone(), tool_call.label.clone(),
default_markdown_style(false, window, cx), default_markdown_style(false, true, window, cx),
)), )),
) )
.child(gradient_overlay(gradient_color)) .child(gradient_overlay(gradient_color))
@ -1804,9 +1806,9 @@ impl AcpThreadView {
.border_color(self.tool_card_border_color(cx)) .border_color(self.tool_card_border_color(cx))
.text_sm() .text_sm()
.text_color(cx.theme().colors().text_muted) .text_color(cx.theme().colors().text_muted)
.child(self.render_markdown(markdown, default_markdown_style(false, window, cx))) .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx)))
.child( .child(
Button::new(button_id, "Collapse Output") Button::new(button_id, "Collapse")
.full_width() .full_width()
.style(ButtonStyle::Outlined) .style(ButtonStyle::Outlined)
.label_size(LabelSize::Small) .label_size(LabelSize::Small)
@ -2131,7 +2133,7 @@ impl AcpThreadView {
.to_string() .to_string()
} else { } else {
format!( format!(
"Output is {} longto avoid unexpected token usage, \ "Output is {} long, and to avoid unexpected token usage, \
only 16 KB was sent back to the model.", only 16 KB was sent back to the model.",
format_file_size(output.original_content_len as u64, true), format_file_size(output.original_content_len as u64, true),
) )
@ -2199,7 +2201,7 @@ impl AcpThreadView {
.border_1() .border_1()
.when(tool_failed || command_failed, |card| card.border_dashed()) .when(tool_failed || command_failed, |card| card.border_dashed())
.border_color(border_color) .border_color(border_color)
.rounded_lg() .rounded_md()
.overflow_hidden() .overflow_hidden()
.child( .child(
v_flex() v_flex()
@ -2553,9 +2555,10 @@ impl AcpThreadView {
.into_any(), .into_any(),
) )
.children(description.map(|desc| { .children(description.map(|desc| {
div().text_ui(cx).text_center().child( div().text_ui(cx).text_center().child(self.render_markdown(
self.render_markdown(desc.clone(), default_markdown_style(false, window, cx)), desc.clone(),
) default_markdown_style(false, false, window, cx),
))
})) }))
.children( .children(
configuration_view configuration_view
@ -3379,7 +3382,7 @@ impl AcpThreadView {
"used-tokens-label", "used-tokens-label",
Animation::new(Duration::from_secs(2)) Animation::new(Duration::from_secs(2))
.repeat() .repeat()
.with_easing(pulsating_between(0.6, 1.)), .with_easing(pulsating_between(0.3, 0.8)),
|label, delta| label.alpha(delta), |label, delta| label.alpha(delta),
) )
.into_any() .into_any()
@ -4636,9 +4639,9 @@ impl Render for AcpThreadView {
ThreadStatus::Idle ThreadStatus::Idle
| ThreadStatus::WaitingForToolConfirmation => None, | ThreadStatus::WaitingForToolConfirmation => None,
ThreadStatus::Generating => div() ThreadStatus::Generating => div()
.px_5()
.py_2() .py_2()
.child(LoadingLabel::new("").size(LabelSize::Small)) .px(rems_from_px(22.))
.child(SpinnerLabel::new().size(LabelSize::Small))
.into(), .into(),
}, },
) )
@ -4671,7 +4674,12 @@ impl Render for AcpThreadView {
} }
} }
fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle { fn default_markdown_style(
buffer_font: bool,
muted_text: bool,
window: &Window,
cx: &App,
) -> MarkdownStyle {
let theme_settings = ThemeSettings::get_global(cx); let theme_settings = ThemeSettings::get_global(cx);
let colors = cx.theme().colors(); let colors = cx.theme().colors();
@ -4692,20 +4700,26 @@ fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> Markd
TextSize::Default.rems(cx) TextSize::Default.rems(cx)
}; };
let text_color = if muted_text {
colors.text_muted
} else {
colors.text
};
text_style.refine(&TextStyleRefinement { text_style.refine(&TextStyleRefinement {
font_family: Some(font_family), font_family: Some(font_family),
font_fallbacks: theme_settings.ui_font.fallbacks.clone(), font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
font_features: Some(theme_settings.ui_font.features.clone()), font_features: Some(theme_settings.ui_font.features.clone()),
font_size: Some(font_size.into()), font_size: Some(font_size.into()),
line_height: Some(line_height.into()), line_height: Some(line_height.into()),
color: Some(cx.theme().colors().text), color: Some(text_color),
..Default::default() ..Default::default()
}); });
MarkdownStyle { MarkdownStyle {
base_text_style: text_style.clone(), base_text_style: text_style.clone(),
syntax: cx.theme().syntax().clone(), syntax: cx.theme().syntax().clone(),
selection_background_color: cx.theme().colors().element_selection_background, selection_background_color: colors.element_selection_background,
code_block_overflow_x_scroll: true, code_block_overflow_x_scroll: true,
table_overflow_x_scroll: true, table_overflow_x_scroll: true,
heading_level_styles: Some(HeadingLevelStyles { heading_level_styles: Some(HeadingLevelStyles {
@ -4791,7 +4805,7 @@ fn plan_label_markdown_style(
window: &Window, window: &Window,
cx: &App, cx: &App,
) -> MarkdownStyle { ) -> MarkdownStyle {
let default_md_style = default_markdown_style(false, window, cx); let default_md_style = default_markdown_style(false, false, window, cx);
MarkdownStyle { MarkdownStyle {
base_text_style: TextStyle { base_text_style: TextStyle {
@ -4811,7 +4825,7 @@ fn plan_label_markdown_style(
} }
fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
let default_md_style = default_markdown_style(true, window, cx); let default_md_style = default_markdown_style(true, false, window, cx);
MarkdownStyle { MarkdownStyle {
base_text_style: TextStyle { base_text_style: TextStyle {

View file

@ -1,6 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use gpui::{ClickEvent, CursorStyle}; use gpui::{ClickEvent, CursorStyle, SharedString};
use crate::{Color, IconButton, IconButtonShape, IconName, IconSize, prelude::*}; use crate::{Color, IconButton, IconButtonShape, IconName, IconSize, prelude::*};
@ -14,6 +14,7 @@ pub struct Disclosure {
cursor_style: CursorStyle, cursor_style: CursorStyle,
opened_icon: IconName, opened_icon: IconName,
closed_icon: IconName, closed_icon: IconName,
visible_on_hover: Option<SharedString>,
} }
impl Disclosure { impl Disclosure {
@ -27,6 +28,7 @@ impl Disclosure {
cursor_style: CursorStyle::PointingHand, cursor_style: CursorStyle::PointingHand,
opened_icon: IconName::ChevronDown, opened_icon: IconName::ChevronDown,
closed_icon: IconName::ChevronRight, closed_icon: IconName::ChevronRight,
visible_on_hover: None,
} }
} }
@ -73,6 +75,13 @@ impl Clickable for Disclosure {
} }
} }
impl VisibleOnHover for Disclosure {
fn visible_on_hover(mut self, group_name: impl Into<SharedString>) -> Self {
self.visible_on_hover = Some(group_name.into());
self
}
}
impl RenderOnce for Disclosure { impl RenderOnce for Disclosure {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
IconButton::new( IconButton::new(
@ -87,6 +96,9 @@ impl RenderOnce for Disclosure {
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.disabled(self.disabled) .disabled(self.disabled)
.toggle_state(self.selected) .toggle_state(self.selected)
.when_some(self.visible_on_hover.clone(), |this, group_name| {
this.visible_on_hover(group_name)
})
.when_some(self.on_toggle, move |this, on_toggle| { .when_some(self.on_toggle, move |this, on_toggle| {
this.on_click(move |event, window, cx| on_toggle(event, window, cx)) this.on_click(move |event, window, cx| on_toggle(event, window, cx))
}) })

View file

@ -2,8 +2,10 @@ mod highlighted_label;
mod label; mod label;
mod label_like; mod label_like;
mod loading_label; mod loading_label;
mod spinner_label;
pub use highlighted_label::*; pub use highlighted_label::*;
pub use label::*; pub use label::*;
pub use label_like::*; pub use label_like::*;
pub use loading_label::*; pub use loading_label::*;
pub use spinner_label::*;

View file

@ -0,0 +1,192 @@
use crate::prelude::*;
use gpui::{Animation, AnimationExt, FontWeight};
use std::time::Duration;
/// Different types of spinner animations
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub enum SpinnerVariant {
#[default]
Dots,
DotsVariant,
}
/// A spinner indication, based on the label component, that loops through
/// frames of the specified animation. It implements `LabelCommon` as well.
///
/// # Default Example
///
/// ```
/// use ui::{SpinnerLabel};
///
/// SpinnerLabel::new();
/// ```
///
/// # Variant Example
///
/// ```
/// use ui::{SpinnerLabel};
///
/// SpinnerLabel::dots_variant();
/// ```
#[derive(IntoElement, RegisterComponent)]
pub struct SpinnerLabel {
base: Label,
variant: SpinnerVariant,
frames: Vec<&'static str>,
duration: Duration,
}
impl SpinnerVariant {
fn frames(&self) -> Vec<&'static str> {
match self {
SpinnerVariant::Dots => vec!["", "", "", "", "", "", "", "", "", ""],
SpinnerVariant::DotsVariant => vec!["", "", "", "", "", "", "", ""],
}
}
fn duration(&self) -> Duration {
match self {
SpinnerVariant::Dots => Duration::from_millis(1000),
SpinnerVariant::DotsVariant => Duration::from_millis(1000),
}
}
fn animation_id(&self) -> &'static str {
match self {
SpinnerVariant::Dots => "spinner_label_dots",
SpinnerVariant::DotsVariant => "spinner_label_dots_variant",
}
}
}
impl SpinnerLabel {
pub fn new() -> Self {
Self::with_variant(SpinnerVariant::default())
}
pub fn with_variant(variant: SpinnerVariant) -> Self {
let frames = variant.frames();
let duration = variant.duration();
SpinnerLabel {
base: Label::new(frames[0]),
variant,
frames,
duration,
}
}
pub fn dots() -> Self {
Self::with_variant(SpinnerVariant::Dots)
}
pub fn dots_variant() -> Self {
Self::with_variant(SpinnerVariant::DotsVariant)
}
}
impl LabelCommon for SpinnerLabel {
fn size(mut self, size: LabelSize) -> Self {
self.base = self.base.size(size);
self
}
fn weight(mut self, weight: FontWeight) -> Self {
self.base = self.base.weight(weight);
self
}
fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self {
self.base = self.base.line_height_style(line_height_style);
self
}
fn color(mut self, color: Color) -> Self {
self.base = self.base.color(color);
self
}
fn strikethrough(mut self) -> Self {
self.base = self.base.strikethrough();
self
}
fn italic(mut self) -> Self {
self.base = self.base.italic();
self
}
fn alpha(mut self, alpha: f32) -> Self {
self.base = self.base.alpha(alpha);
self
}
fn underline(mut self) -> Self {
self.base = self.base.underline();
self
}
fn truncate(mut self) -> Self {
self.base = self.base.truncate();
self
}
fn single_line(mut self) -> Self {
self.base = self.base.single_line();
self
}
fn buffer_font(mut self, cx: &App) -> Self {
self.base = self.base.buffer_font(cx);
self
}
fn inline_code(mut self, cx: &App) -> Self {
self.base = self.base.inline_code(cx);
self
}
}
impl RenderOnce for SpinnerLabel {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let frames = self.frames.clone();
let duration = self.duration;
self.base.color(Color::Muted).with_animation(
self.variant.animation_id(),
Animation::new(duration).repeat(),
move |mut label, delta| {
let frame_index = (delta * frames.len() as f32) as usize % frames.len();
label.set_text(frames[frame_index]);
label
},
)
}
}
impl Component for SpinnerLabel {
fn scope() -> ComponentScope {
ComponentScope::Loading
}
fn name() -> &'static str {
"Spinner Label"
}
fn sort_name() -> &'static str {
"Spinner Label"
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let examples = vec![
single_example("Default", SpinnerLabel::new().into_any_element()),
single_example(
"Dots Variant",
SpinnerLabel::dots_variant().into_any_element(),
),
];
Some(example_group(examples).vertical().into_any_element())
}
}