From 11cb2aba837c3c2e0430af0cdf08fbe6c3ba63be Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 30 Mar 2026 07:34:05 -0300 Subject: [PATCH] agent_ui: Refine the thinking block display design (#52608) Follow up to https://github.com/zed-industries/zed/pull/51525 Closes https://github.com/zed-industries/zed/issues/52452 This PR further refines the behavior of thinking blocks in the agent panel. In the PR linked above, I had previously made it auto-expand while running but then auto-collapse when finished. Although that reduced the sense of staleness when the model is thinking for too long, it caused layout shift that many found jarring. Therefore, this PR changes the behavior so that, by default, thinking blocks render "almost" fully expanded. They will have a max-height and will auto-scroll as content streams in. Therefore, this design fully removes layout shift and still allows you to sort of follow along the generated thinking content, even though sometimes it can be fast, in which case you can always fully expand it. Lastly, I'm also adding a "thinking display" setting that allows to choose between the "automatic" behavior (what I just described, the default), always expanded, or always collapsed. Here's a preview: https://github.com/user-attachments/assets/c96c89c7-40ed-4e9b-9ffc-f70b0659be47 Release Notes: - Agent: Refined thinking block display, removing layout shift while still allowing it to be readable while it streams in. It comes together with a "Thinking Display" setting to control the behavior --- assets/settings/default.json | 4 + crates/agent/src/tool_permissions.rs | 1 + crates/agent_settings/src/agent_settings.rs | 4 +- crates/agent_ui/src/agent_ui.rs | 1 + crates/agent_ui/src/conversation_view.rs | 2 +- .../src/conversation_view/thread_view.rs | 156 +++++++++++++----- crates/settings_content/src/agent.rs | 32 ++++ crates/settings_ui/src/page_data.rs | 22 +++ crates/settings_ui/src/settings_ui.rs | 1 + 9 files changed, 176 insertions(+), 47 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 7bfb1f2cdb..cabeeaf200 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1106,6 +1106,10 @@ // // Default: true "expand_terminal_card": true, + // How thinking blocks should be displayed by default in the agent panel. + // + // Default: automatic + "thinking_display": "automatic", // Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. // Note that this only applies to the stop button, not to ctrl+c inside the terminal. // diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index ec22fa11da..b04db5ffd8 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -597,6 +597,7 @@ mod tests { show_turn_stats: false, new_thread_location: Default::default(), sidebar_side: Default::default(), + thinking_display: Default::default(), } } diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index ec0a46af06..7d83be6ebc 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use settings::{ DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, SidebarDockPosition, - SidebarSide, ToolPermissionMode, + SidebarSide, ThinkingBlockDisplay, ToolPermissionMode, }; pub use crate::agent_profile::*; @@ -48,6 +48,7 @@ pub struct AgentSettings { pub enable_feedback: bool, pub expand_edit_card: bool, pub expand_terminal_card: bool, + pub thinking_display: ThinkingBlockDisplay, pub cancel_generation_on_terminal_stop: bool, pub use_modifier_to_send: bool, pub message_editor_min_lines: usize, @@ -448,6 +449,7 @@ impl Settings for AgentSettings { enable_feedback: agent.enable_feedback.unwrap(), expand_edit_card: agent.expand_edit_card.unwrap(), expand_terminal_card: agent.expand_terminal_card.unwrap(), + thinking_display: agent.thinking_display.unwrap(), cancel_generation_on_terminal_stop: agent.cancel_generation_on_terminal_stop.unwrap(), use_modifier_to_send: agent.use_modifier_to_send.unwrap(), message_editor_min_lines: agent.message_editor_min_lines.unwrap(), diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 531f0cfa62..f8e73a6987 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -673,6 +673,7 @@ mod tests { show_turn_stats: false, new_thread_location: Default::default(), sidebar_side: Default::default(), + thinking_display: Default::default(), }; cx.update(|cx| { diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 316650a32b..80567646d1 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -43,7 +43,7 @@ use prompt_store::{PromptId, PromptStore}; use crate::DEFAULT_THREAD_TITLE; use crate::message_editor::SessionCapabilities; use rope::Point; -use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; +use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore, ThinkingBlockDisplay}; use std::path::Path; use std::sync::Arc; use std::time::Instant; diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index a295cb562f..77b2b1f82c 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -253,6 +253,7 @@ pub struct ThreadView { pub expanded_tool_call_raw_inputs: HashSet, pub expanded_thinking_blocks: HashSet<(usize, usize)>, auto_expanded_thinking_block: Option<(usize, usize)>, + user_toggled_thinking_blocks: HashSet<(usize, usize)>, pub subagent_scroll_handles: RefCell>, pub edits_expanded: bool, pub plan_expanded: bool, @@ -495,6 +496,7 @@ impl ThreadView { expanded_tool_call_raw_inputs: HashSet::default(), expanded_thinking_blocks: HashSet::default(), auto_expanded_thinking_block: None, + user_toggled_thinking_blocks: HashSet::default(), subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, plan_expanded: false, @@ -5155,9 +5157,13 @@ impl ThreadView { .into_any_element() } - /// If the last entry's last chunk is a streaming thought block, auto-expand it. - /// Also collapses the previously auto-expanded block when a new one starts. pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context) { + // Only auto-expand thinking blocks in Automatic mode. + // AlwaysExpanded shows them open by default; AlwaysCollapsed keeps them closed. + if AgentSettings::get_global(cx).thinking_display != ThinkingBlockDisplay::Automatic { + return; + } + let key = { let thread = self.thread.read(cx); if thread.status() != ThreadStatus::Generating { @@ -5178,30 +5184,59 @@ impl ThreadView { if let Some(key) = key { if self.auto_expanded_thinking_block != Some(key) { - if let Some(old_key) = self.auto_expanded_thinking_block.replace(key) { - self.expanded_thinking_blocks.remove(&old_key); - } + self.auto_expanded_thinking_block = Some(key); self.expanded_thinking_blocks.insert(key); cx.notify(); } } else if self.auto_expanded_thinking_block.is_some() { - // The last chunk is no longer a thought (model transitioned to responding), - // so collapse the previously auto-expanded block. - self.collapse_auto_expanded_thinking_block(); + self.auto_expanded_thinking_block = None; cx.notify(); } } - fn collapse_auto_expanded_thinking_block(&mut self) { - if let Some(key) = self.auto_expanded_thinking_block.take() { - self.expanded_thinking_blocks.remove(&key); - } - } - pub(crate) fn clear_auto_expand_tracking(&mut self) { self.auto_expanded_thinking_block = None; } + fn toggle_thinking_block_expansion(&mut self, key: (usize, usize), cx: &mut Context) { + let thinking_display = AgentSettings::get_global(cx).thinking_display; + + match thinking_display { + ThinkingBlockDisplay::Automatic => { + let is_user_expanded = self.user_toggled_thinking_blocks.contains(&key); + let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key); + + if is_user_expanded { + self.user_toggled_thinking_blocks.remove(&key); + self.expanded_thinking_blocks.remove(&key); + } else if is_in_expanded_set { + self.user_toggled_thinking_blocks.insert(key); + } else { + self.expanded_thinking_blocks.insert(key); + self.user_toggled_thinking_blocks.insert(key); + } + } + ThinkingBlockDisplay::AlwaysExpanded => { + if self.user_toggled_thinking_blocks.contains(&key) { + self.user_toggled_thinking_blocks.remove(&key); + } else { + self.user_toggled_thinking_blocks.insert(key); + } + } + ThinkingBlockDisplay::AlwaysCollapsed => { + if self.user_toggled_thinking_blocks.contains(&key) { + self.user_toggled_thinking_blocks.remove(&key); + self.expanded_thinking_blocks.remove(&key); + } else { + self.expanded_thinking_blocks.insert(key); + self.user_toggled_thinking_blocks.insert(key); + } + } + } + + cx.notify(); + } + fn render_thinking_block( &self, entry_ix: usize, @@ -5215,7 +5250,21 @@ impl ThreadView { let key = (entry_ix, chunk_ix); - let is_open = self.expanded_thinking_blocks.contains(&key); + let thinking_display = AgentSettings::get_global(cx).thinking_display; + let is_user_toggled = self.user_toggled_thinking_blocks.contains(&key); + let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key); + + let (is_open, is_constrained) = match thinking_display { + ThinkingBlockDisplay::Automatic => { + let is_open = is_user_toggled || is_in_expanded_set; + let is_constrained = is_in_expanded_set && !is_user_toggled; + (is_open, is_constrained) + } + ThinkingBlockDisplay::AlwaysExpanded => (!is_user_toggled, false), + ThinkingBlockDisplay::AlwaysCollapsed => (is_user_toggled, false), + }; + + let should_auto_scroll = self.auto_expanded_thinking_block == Some(key); let scroll_handle = self .entry_view_state @@ -5223,6 +5272,14 @@ impl ThreadView { .entry(entry_ix) .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); + if should_auto_scroll { + if let Some(ref handle) = scroll_handle { + handle.scroll_to_bottom(); + } + } + + let panel_bg = cx.theme().colors().panel_background; + v_flex() .gap_1() .child( @@ -5255,42 +5312,51 @@ impl ThreadView { .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .visible_on_hover(&card_header_id) - .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( + move |this, _event: &ClickEvent, _window, cx| { + this.toggle_thinking_block_expansion(key, cx); + }, + )), ) - .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(move |this, _event: &ClickEvent, _window, cx| { + this.toggle_thinking_block_expansion(key, cx); })), ) .when(is_open, |this| { this.child( div() - .id(("thinking-content", chunk_ix)) - .ml_1p5() - .pl_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .when_some(scroll_handle, |this, scroll_handle| { - this.track_scroll(&scroll_handle) - }) - .overflow_hidden() - .child(self.render_markdown( - chunk, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - )), + .when(is_constrained, |this| this.relative()) + .child( + div() + .id(("thinking-content", chunk_ix)) + .ml_1p5() + .pl_3p5() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .when(is_constrained, |this| this.max_h_64()) + .when_some(scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .overflow_hidden() + .child(self.render_markdown( + chunk, + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + )), + ) + .when(is_constrained, |this| { + this.child( + div() + .absolute() + .inset_0() + .size_full() + .bg(linear_gradient( + 180., + linear_color_stop(panel_bg.opacity(0.8), 0.), + linear_color_stop(panel_bg.opacity(0.), 0.1), + )) + .block_mouse_except_scroll(), + ) + }), ) }) .into_any_element() diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 0c77957bc6..158cdc78d9 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -66,6 +66,34 @@ pub enum SidebarSide { Right, } +/// How thinking blocks should be displayed by default in the agent panel. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum ThinkingBlockDisplay { + /// Thinking blocks auto-expand with a height constraint during streaming, + /// then remain in their constrained state when complete. Users can click + /// to fully expand or collapse. + #[default] + Automatic, + /// Thinking blocks are always fully expanded by default (no height constraint). + AlwaysExpanded, + /// Thinking blocks are always collapsed by default. + AlwaysCollapsed, +} + #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] pub struct AgentSettingsContent { @@ -159,6 +187,10 @@ pub struct AgentSettingsContent { /// /// Default: true pub expand_terminal_card: Option, + /// How thinking blocks should be displayed by default in the agent panel. + /// + /// Default: automatic + pub thinking_display: Option, /// Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. /// Note that this only applies to the stop button, not to ctrl+c inside the terminal. /// diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 37e3e78bac..83895cf6f3 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7323,6 +7323,28 @@ fn ai_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Thinking Display", + description: "How thinking blocks should be displayed by default. 'Automatic' auto-expands with a height constraint during streaming. 'Always Expanded' shows full content. 'Always Collapsed' keeps them collapsed.", + field: Box::new(SettingField { + json_path: Some("agent.thinking_display"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .thinking_display + .as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .thinking_display = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Cancel Generation On Terminal Stop", description: "Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. Note that this only applies to the stop button, not to ctrl+c inside the terminal.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index a14d7d3c88..568d00115d 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -524,6 +524,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown)