thread view: Add UI refinements (#35754)

More notably around how we render tool calls. Nothing too drastic,
though.

Release Notes:

- N/A
This commit is contained in:
Danilo Leal 2025-08-06 20:31:11 -03:00 committed by GitHub
parent 58392b9c13
commit 8e290b446e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 188 additions and 103 deletions

View file

Before

Width:  |  Height:  |  Size: 776 B

After

Width:  |  Height:  |  Size: 776 B

Before After
Before After

View file

@ -332,7 +332,9 @@
"enter": "agent::Chat", "enter": "agent::Chat",
"up": "agent::PreviousHistoryMessage", "up": "agent::PreviousHistoryMessage",
"down": "agent::NextHistoryMessage", "down": "agent::NextHistoryMessage",
"shift-ctrl-r": "agent::OpenAgentDiff" "shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll"
} }
}, },
{ {

View file

@ -384,7 +384,9 @@
"enter": "agent::Chat", "enter": "agent::Chat",
"up": "agent::PreviousHistoryMessage", "up": "agent::PreviousHistoryMessage",
"down": "agent::NextHistoryMessage", "down": "agent::NextHistoryMessage",
"shift-ctrl-r": "agent::OpenAgentDiff" "shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll"
} }
}, },
{ {

View file

@ -858,6 +858,7 @@ impl AcpThreadView {
.into_any() .into_any()
} }
AgentThreadEntry::ToolCall(tool_call) => div() AgentThreadEntry::ToolCall(tool_call) => div()
.w_full()
.py_1p5() .py_1p5()
.px_5() .px_5()
.child(self.render_tool_call(index, tool_call, window, cx)) .child(self.render_tool_call(index, tool_call, window, cx))
@ -903,6 +904,7 @@ impl AcpThreadView {
cx: &Context<Self>, cx: &Context<Self>,
) -> AnyElement { ) -> AnyElement {
let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
let card_header_id = SharedString::from("inner-card-header");
let key = (entry_ix, chunk_ix); let key = (entry_ix, chunk_ix);
let is_open = self.expanded_thinking_blocks.contains(&key); let is_open = self.expanded_thinking_blocks.contains(&key);
@ -910,41 +912,53 @@ impl AcpThreadView {
.child( .child(
h_flex() h_flex()
.id(header_id) .id(header_id)
.group("disclosure-header") .group(&card_header_id)
.relative()
.w_full() .w_full()
.justify_between() .gap_1p5()
.opacity(0.8) .opacity(0.8)
.hover(|style| style.opacity(1.)) .hover(|style| style.opacity(1.))
.child( .child(
h_flex() h_flex()
.gap_1p5() .size_4()
.child( .justify_center()
Icon::new(IconName::ToolBulb)
.size(IconSize::Small)
.color(Color::Muted),
)
.child( .child(
div() div()
.text_size(self.tool_name_font_size()) .group_hover(&card_header_id, |s| s.invisible().w_0())
.child("Thinking"), .child(
Icon::new(IconName::ToolThink)
.size(IconSize::Small)
.color(Color::Muted),
),
)
.child(
h_flex()
.absolute()
.inset_0()
.invisible()
.justify_center()
.group_hover(&card_header_id, |s| s.visible())
.child(
Disclosure::new(("expand", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronRight)
.on_click(cx.listener({
move |this, _event, _window, cx| {
if is_open {
this.expanded_thinking_blocks.remove(&key);
} else {
this.expanded_thinking_blocks.insert(key);
}
cx.notify();
}
})),
),
), ),
) )
.child( .child(
div().visible_on_hover("disclosure-header").child( div()
Disclosure::new("thinking-disclosure", is_open) .text_size(self.tool_name_font_size())
.opened_icon(IconName::ChevronUp) .child("Thinking"),
.closed_icon(IconName::ChevronDown)
.on_click(cx.listener({
move |this, _event, _window, cx| {
if is_open {
this.expanded_thinking_blocks.remove(&key);
} else {
this.expanded_thinking_blocks.insert(key);
}
cx.notify();
}
})),
),
) )
.on_click(cx.listener({ .on_click(cx.listener({
move |this, _event, _window, cx| { move |this, _event, _window, cx| {
@ -975,6 +989,67 @@ impl AcpThreadView {
.into_any_element() .into_any_element()
} }
fn render_tool_call_icon(
&self,
group_name: SharedString,
entry_ix: usize,
is_collapsible: bool,
is_open: bool,
tool_call: &ToolCall,
cx: &Context<Self>,
) -> Div {
let tool_icon = Icon::new(match tool_call.kind {
acp::ToolKind::Read => IconName::ToolRead,
acp::ToolKind::Edit => IconName::ToolPencil,
acp::ToolKind::Delete => IconName::ToolDeleteFile,
acp::ToolKind::Move => IconName::ArrowRightLeft,
acp::ToolKind::Search => IconName::ToolSearch,
acp::ToolKind::Execute => IconName::ToolTerminal,
acp::ToolKind::Think => IconName::ToolThink,
acp::ToolKind::Fetch => IconName::ToolWeb,
acp::ToolKind::Other => IconName::ToolHammer,
})
.size(IconSize::Small)
.color(Color::Muted);
if is_collapsible {
h_flex()
.size_4()
.justify_center()
.child(
div()
.group_hover(&group_name, |s| s.invisible().w_0())
.child(tool_icon),
)
.child(
h_flex()
.absolute()
.inset_0()
.invisible()
.justify_center()
.group_hover(&group_name, |s| s.visible())
.child(
Disclosure::new(("expand", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronRight)
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
if is_open {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id.clone());
}
cx.notify();
}
})),
),
)
} else {
div().child(tool_icon)
}
}
fn render_tool_call( fn render_tool_call(
&self, &self,
entry_ix: usize, entry_ix: usize,
@ -982,7 +1057,8 @@ impl AcpThreadView {
window: &Window, window: &Window,
cx: &Context<Self>, cx: &Context<Self>,
) -> Div { ) -> Div {
let header_id = SharedString::from(format!("tool-call-header-{}", entry_ix)); let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix));
let card_header_id = SharedString::from("inner-tool-call-header");
let status_icon = match &tool_call.status { let status_icon = match &tool_call.status {
ToolCallStatus::Allowed { ToolCallStatus::Allowed {
@ -1031,6 +1107,21 @@ impl AcpThreadView {
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
let is_open = !is_collapsible || self.expanded_tool_calls.contains(&tool_call.id); let is_open = !is_collapsible || self.expanded_tool_calls.contains(&tool_call.id);
let gradient_color = cx.theme().colors().panel_background;
let gradient_overlay = {
div()
.absolute()
.top_0()
.right_0()
.w_12()
.h_full()
.bg(linear_gradient(
90.,
linear_color_stop(gradient_color, 1.),
linear_color_stop(gradient_color.opacity(0.2), 0.),
))
};
v_flex() v_flex()
.when(needs_confirmation, |this| { .when(needs_confirmation, |this| {
this.rounded_lg() this.rounded_lg()
@ -1047,43 +1138,38 @@ impl AcpThreadView {
.justify_between() .justify_between()
.map(|this| { .map(|this| {
if needs_confirmation { if needs_confirmation {
this.px_2() this.pl_2()
.pr_1()
.py_1() .py_1()
.rounded_t_md() .rounded_t_md()
.bg(self.tool_card_header_bg(cx))
.border_b_1() .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))
} else { } else {
this.opacity(0.8).hover(|style| style.opacity(1.)) this.opacity(0.8).hover(|style| style.opacity(1.))
} }
}) })
.child( .child(
h_flex() h_flex()
.id("tool-call-header") .group(&card_header_id)
.overflow_x_scroll() .relative()
.w_full()
.map(|this| { .map(|this| {
if needs_confirmation { if tool_call.locations.len() == 1 {
this.text_xs() this.gap_0()
} else { } else {
this.text_size(self.tool_name_font_size()) this.gap_1p5()
} }
}) })
.gap_1p5() .text_size(self.tool_name_font_size())
.child( .child(self.render_tool_call_icon(
Icon::new(match tool_call.kind { card_header_id,
acp::ToolKind::Read => IconName::ToolRead, entry_ix,
acp::ToolKind::Edit => IconName::ToolPencil, is_collapsible,
acp::ToolKind::Delete => IconName::ToolDeleteFile, is_open,
acp::ToolKind::Move => IconName::ArrowRightLeft, tool_call,
acp::ToolKind::Search => IconName::ToolSearch, cx,
acp::ToolKind::Execute => IconName::ToolTerminal, ))
acp::ToolKind::Think => IconName::ToolBulb,
acp::ToolKind::Fetch => IconName::ToolWeb,
acp::ToolKind::Other => IconName::ToolHammer,
})
.size(IconSize::Small)
.color(Color::Muted),
)
.child(if tool_call.locations.len() == 1 { .child(if tool_call.locations.len() == 1 {
let name = tool_call.locations[0] let name = tool_call.locations[0]
.path .path
@ -1094,13 +1180,11 @@ impl AcpThreadView {
h_flex() h_flex()
.id(("open-tool-call-location", entry_ix)) .id(("open-tool-call-location", entry_ix))
.child(name)
.w_full() .w_full()
.max_w_full() .max_w_full()
.pr_1() .px_1p5()
.gap_0p5()
.cursor_pointer()
.rounded_sm() .rounded_sm()
.overflow_x_scroll()
.opacity(0.8) .opacity(0.8)
.hover(|label| { .hover(|label| {
label.opacity(1.).bg(cx label.opacity(1.).bg(cx
@ -1109,53 +1193,49 @@ impl AcpThreadView {
.element_hover .element_hover
.opacity(0.5)) .opacity(0.5))
}) })
.child(name)
.tooltip(Tooltip::text("Jump to File")) .tooltip(Tooltip::text("Jump to File"))
.on_click(cx.listener(move |this, _, window, cx| { .on_click(cx.listener(move |this, _, window, cx| {
this.open_tool_call_location(entry_ix, 0, window, cx); this.open_tool_call_location(entry_ix, 0, window, cx);
})) }))
.into_any_element() .into_any_element()
} else { } else {
self.render_markdown( h_flex()
tool_call.label.clone(), .id("non-card-label-container")
default_markdown_style(needs_confirmation, window, cx), .w_full()
) .relative()
.into_any() .overflow_hidden()
.child(
h_flex()
.id("non-card-label")
.pr_8()
.w_full()
.overflow_x_scroll()
.child(self.render_markdown(
tool_call.label.clone(),
default_markdown_style(
needs_confirmation,
window,
cx,
),
)),
)
.child(gradient_overlay)
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
if is_open {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id.clone());
}
cx.notify();
}
}))
.into_any()
}), }),
) )
.child( .children(status_icon),
h_flex()
.gap_0p5()
.when(is_collapsible, |this| {
this.child(
Disclosure::new(("expand", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
if is_open {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id.clone());
}
cx.notify();
}
})),
)
})
.children(status_icon),
)
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
if is_open {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id.clone());
}
cx.notify();
}
})),
) )
.when(is_open, |this| { .when(is_open, |this| {
this.child( this.child(
@ -1249,8 +1329,7 @@ impl AcpThreadView {
cx: &Context<Self>, cx: &Context<Self>,
) -> Div { ) -> Div {
h_flex() h_flex()
.py_1p5() .p_1p5()
.px_1p5()
.gap_1() .gap_1()
.justify_end() .justify_end()
.when(!empty_content, |this| { .when(!empty_content, |this| {
@ -1276,6 +1355,7 @@ impl AcpThreadView {
}) })
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall) .icon_size(IconSize::XSmall)
.label_size(LabelSize::Small)
.on_click(cx.listener({ .on_click(cx.listener({
let tool_call_id = tool_call_id.clone(); let tool_call_id = tool_call_id.clone();
let option_id = option.id.clone(); let option_id = option.id.clone();
@ -1525,7 +1605,7 @@ impl AcpThreadView {
}) })
}) })
.when(!changed_buffers.is_empty(), |this| { .when(!changed_buffers.is_empty(), |this| {
this.child(Divider::horizontal()) this.child(Divider::horizontal().color(DividerColor::Border))
.child(self.render_edits_summary( .child(self.render_edits_summary(
action_log, action_log,
&changed_buffers, &changed_buffers,
@ -1555,6 +1635,7 @@ impl AcpThreadView {
{ {
h_flex() h_flex()
.w_full() .w_full()
.cursor_default()
.gap_1() .gap_1()
.text_xs() .text_xs()
.text_color(cx.theme().colors().text_muted) .text_color(cx.theme().colors().text_muted)
@ -1584,7 +1665,7 @@ impl AcpThreadView {
let status_label = if stats.pending == 0 { let status_label = if stats.pending == 0 {
"All Done".to_string() "All Done".to_string()
} else if stats.completed == 0 { } else if stats.completed == 0 {
format!("{}", plan.entries.len()) format!("{} Tasks", plan.entries.len())
} else { } else {
format!("{}/{}", stats.completed, plan.entries.len()) format!("{}/{}", stats.completed, plan.entries.len())
}; };
@ -1698,7 +1779,6 @@ impl AcpThreadView {
.child( .child(
h_flex() h_flex()
.id("edits-container") .id("edits-container")
.cursor_pointer()
.w_full() .w_full()
.gap_1() .gap_1()
.child(Disclosure::new("edits-disclosure", expanded)) .child(Disclosure::new("edits-disclosure", expanded))
@ -2473,6 +2553,7 @@ impl AcpThreadView {
})); }));
h_flex() h_flex()
.w_full()
.mr_1() .mr_1()
.pb_2() .pb_2()
.px(RESPONSE_PADDING_X) .px(RESPONSE_PADDING_X)

View file

@ -2624,7 +2624,7 @@ impl ActiveThread {
h_flex() h_flex()
.gap_1p5() .gap_1p5()
.child( .child(
Icon::new(IconName::ToolBulb) Icon::new(IconName::ToolThink)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Muted), .color(Color::Muted),
) )

View file

@ -37,7 +37,7 @@ impl Tool for ThinkingTool {
} }
fn icon(&self) -> IconName { fn icon(&self) -> IconName {
IconName::ToolBulb IconName::ToolThink
} }
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> { fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {

View file

@ -261,7 +261,6 @@ pub enum IconName {
TodoComplete, TodoComplete,
TodoPending, TodoPending,
TodoProgress, TodoProgress,
ToolBulb,
ToolCopy, ToolCopy,
ToolDeleteFile, ToolDeleteFile,
ToolDiagnostics, ToolDiagnostics,
@ -273,6 +272,7 @@ pub enum IconName {
ToolRegex, ToolRegex,
ToolSearch, ToolSearch,
ToolTerminal, ToolTerminal,
ToolThink,
ToolWeb, ToolWeb,
Trash, Trash,
Triangle, Triangle,

View file

@ -95,7 +95,7 @@ impl RenderOnce for Disclosure {
impl Component for Disclosure { impl Component for Disclosure {
fn scope() -> ComponentScope { fn scope() -> ComponentScope {
ComponentScope::Navigation ComponentScope::Input
} }
fn description() -> Option<&'static str> { fn description() -> Option<&'static str> {