From 28b73a1773a3a60e52ff21a6706e0a9308719e4a 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 7bfb1f2cdb68856d66073e8629d9921602d806d8..cabeeaf2007dee5d7bd15aa295ffb265d55f183f 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 ec22fa11da00f6f41dfbdcee283ea983fbeac1af..b04db5ffd886b0b4a3704d8c1443e0ef7358f3ec 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 ec0a46af0636877210d26b2c45660baae648ffea..7d83be6ebcfe935d6650a85108966d02c7c95eec 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 531f0cfa6259e651d473e08135a68f7f44cac48d..f8e73a69870ea693270990cb1253ea777331e094 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 316650a32b2f0179d9e63ea26bb5ad22cccd8021..80567646d1abc6be566b91d705b53bad50abc416 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 a295cb562f0d46b8d979412ced39abf5f94d1ad9..77b2b1f82c21cfbe424d047f80b1a554f15e10db 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 0c77957bc6a1dab2af47164cbd1f46c5dc679d37..158cdc78d963847c3f2d814279e1417b723f7c2c 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 37e3e78baceccde480801c84cfe6462c8356c5ed..83895cf6f3493fb6bddf6963e72b5467f382dd0a 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 a14d7d3c883cad98dc8f5d462c52f20819a6c7ae..568d00115dcb760fcdacb264a9dd8d36829df9a4 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)