agent_ui: Refactor in preparation for subagent permission work (#47874)

Cameron Mcloughlin , MrSubidubi , and Ben Brandt created

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: MrSubidubi <dev@bahn.sh>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Change summary

crates/agent_ui/src/acp/message_editor.rs            |   14 
crates/agent_ui/src/acp/thread_view.rs               |  772 ++------
crates/agent_ui/src/acp/thread_view/active_thread.rs | 1224 ++++++++++++++
crates/agent_ui/src/agent_panel.rs                   |   20 
crates/agent_ui/src/profile_selector.rs              |    2 
crates/agent_ui_v2/src/agent_thread_pane.rs          |    4 
crates/gpui/src/elements/div.rs                      |    9 
crates/zed/src/visual_test_runner.rs                 |   14 
8 files changed, 1,490 insertions(+), 569 deletions(-)

Detailed changes

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -302,6 +302,20 @@ impl MessageEditor {
         }
     }
 
+    pub fn set_command_state(
+        &mut self,
+        prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
+        available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+        cached_user_commands: Rc<RefCell<collections::HashMap<String, UserSlashCommand>>>,
+        cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
+        _cx: &mut Context<Self>,
+    ) {
+        self.prompt_capabilities = prompt_capabilities;
+        self.available_commands = available_commands;
+        self.cached_user_commands = cached_user_commands;
+        self.cached_user_command_errors = cached_user_command_errors;
+    }
+
     fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
         let available_commands = self.available_commands.borrow();
         if available_commands.is_empty() {

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -87,6 +87,9 @@ use crate::{
 const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
 const TOKEN_THRESHOLD: u64 = 250;
 
+mod active_thread;
+pub use active_thread::*;
+
 pub struct QueuedMessage {
     pub content: Vec<acp::ContentBlock>,
     pub tracked_buffers: Vec<Entity<Buffer>>,
@@ -326,86 +329,45 @@ pub struct AcpThreadView {
     agent_server_store: Entity<AgentServerStore>,
     workspace: WeakEntity<Workspace>,
     project: Entity<Project>,
+    thread_store: Option<Entity<ThreadStore>>,
+    prompt_store: Option<Entity<PromptStore>>,
     thread_state: ThreadState,
-    permission_dropdown_handle: PopoverMenuHandle<ContextMenu>,
-    /// Tracks the selected granularity index for each tool call's permission dropdown.
-    /// The index corresponds to the position in the allow_options list.
-    /// Default is the last option (index pointing to "Only this time").
-    selected_permission_granularity: HashMap<acp::ToolCallId, usize>,
-    login: Option<task::SpawnInTerminal>,
+    login: Option<task::SpawnInTerminal>, // is some <=> Active | Unauthenticated
     recent_history_entries: Vec<AgentSessionInfo>,
     history: Entity<AcpThreadHistory>,
     _history_subscription: Subscription,
     hovered_recent_history_item: Option<usize>,
-    entry_view_state: Entity<EntryViewState>,
     message_editor: Entity<MessageEditor>,
     focus_handle: FocusHandle,
-    model_selector: Option<Entity<AcpModelSelectorPopover>>,
-    config_options_view: Option<Entity<ConfigOptionsView>>,
-    profile_selector: Option<Entity<ProfileSelector>>,
     notifications: Vec<WindowHandle<AgentNotification>>,
     notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
-    thread_retry_status: Option<RetryStatus>,
-    thread_error: Option<ThreadError>,
-    thread_error_markdown: Option<Entity<Markdown>>,
-    command_load_errors: Vec<CommandLoadError>,
-    command_load_errors_dismissed: bool,
     slash_command_registry: Option<Entity<SlashCommandRegistry>>,
-    token_limit_callout_dismissed: bool,
-    thread_feedback: ThreadFeedbackState,
-    list_state: ListState,
     auth_task: Option<Task<()>>,
-    /// Tracks which tool calls have their content/output expanded.
-    /// Used for showing/hiding tool call results, terminal output, etc.
-    expanded_tool_calls: HashSet<acp::ToolCallId>,
-    expanded_tool_call_raw_inputs: HashSet<acp::ToolCallId>,
-    expanded_thinking_blocks: HashSet<(usize, usize)>,
-    expanded_subagents: HashSet<acp::SessionId>,
-    subagent_scroll_handles: RefCell<HashMap<acp::SessionId, ScrollHandle>>,
-    edits_expanded: bool,
-    plan_expanded: bool,
-    queue_expanded: bool,
-    editor_expanded: bool,
-    should_be_following: bool,
-    editing_message: Option<usize>,
-    local_queued_messages: Vec<QueuedMessage>,
-    queued_message_editors: Vec<Entity<MessageEditor>>,
-    queued_message_editor_subscriptions: Vec<Subscription>,
-    last_synced_queue_length: usize,
-    discarded_partial_edits: HashSet<acp::ToolCallId>,
-    prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
-    available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
-    cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
-    cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
-    is_loading_contents: bool,
-    new_server_version_available: Option<SharedString>,
-    resume_thread_metadata: Option<AgentSessionInfo>,
-    resumed_without_history: bool,
-    _cancel_task: Option<Task<()>>,
-    _subscriptions: [Subscription; 5],
+    _subscriptions: [Subscription; 4],
     show_codex_windows_warning: bool,
     in_flight_prompt: Option<Vec<acp::ContentBlock>>,
-    skip_queue_processing_count: usize,
-    user_interrupted_generation: bool,
-    can_fast_track_queue: bool,
-    turn_tokens: Option<u64>,
-    last_turn_tokens: Option<u64>,
-    turn_started_at: Option<Instant>,
-    last_turn_duration: Option<Duration>,
-    turn_generation: usize,
-    _turn_timer_task: Option<Task<()>>,
-    hovered_edited_file_buttons: Option<usize>,
     add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
 }
 
+impl AcpThreadView {
+    pub fn as_active_thread(&self) -> Option<&ActiveThreadState> {
+        match &self.thread_state {
+            ThreadState::Active(active) => Some(active),
+            _ => None,
+        }
+    }
+
+    pub fn as_active_thread_mut(&mut self) -> Option<&mut ActiveThreadState> {
+        match &mut self.thread_state {
+            ThreadState::Active(active) => Some(active),
+            _ => None,
+        }
+    }
+}
+
 enum ThreadState {
     Loading(Entity<LoadingView>),
-    Ready {
-        thread: Entity<AcpThread>,
-        title_editor: Option<Entity<Editor>>,
-        mode_selector: Option<Entity<ModeSelector>>,
-        _subscriptions: Vec<Subscription>,
-    },
+    Active(ActiveThreadState),
     LoadError(LoadError),
     Unauthenticated {
         connection: Rc<dyn AgentConnection>,
@@ -439,7 +401,6 @@ impl AcpThreadView {
         let available_commands = Rc::new(RefCell::new(vec![]));
         let cached_user_commands = Rc::new(RefCell::new(collections::HashMap::default()));
         let cached_user_command_errors = Rc::new(RefCell::new(Vec::new()));
-        let mut command_load_errors = Vec::new();
 
         let agent_server_store = project.read(cx).agent_server_store().clone();
         let agent_display_name = agent_server_store
@@ -475,28 +436,10 @@ impl AcpThreadView {
             editor
         });
 
-        let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
-
-        let entry_view_state = cx.new(|_| {
-            EntryViewState::new(
-                workspace.clone(),
-                project.downgrade(),
-                thread_store.clone(),
-                history.downgrade(),
-                prompt_store.clone(),
-                prompt_capabilities.clone(),
-                available_commands.clone(),
-                cached_user_commands.clone(),
-                cached_user_command_errors.clone(),
-                agent.name(),
-            )
-        });
-
         let subscriptions = [
             cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
             cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
             cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event),
-            cx.subscribe_in(&entry_view_state, window, Self::handle_entry_view_event),
             cx.subscribe_in(
                 &agent_server_store,
                 window,
@@ -550,7 +493,6 @@ impl AcpThreadView {
                 &mut errors,
                 &server_command_names,
             );
-            command_load_errors = errors.clone();
             *cached_user_commands.borrow_mut() = commands;
             *cached_user_command_errors.borrow_mut() = errors;
 
@@ -569,100 +511,78 @@ impl AcpThreadView {
             agent_server_store,
             workspace: workspace.clone(),
             project: project.clone(),
-            entry_view_state,
-            permission_dropdown_handle: PopoverMenuHandle::default(),
-            selected_permission_granularity: HashMap::default(),
+            thread_store,
+            prompt_store,
             thread_state: Self::initial_state(
                 agent.clone(),
-                resume_thread.clone(),
+                resume_thread,
                 workspace.clone(),
                 project.clone(),
+                prompt_capabilities,
+                available_commands,
+                cached_user_commands,
+                cached_user_command_errors,
                 window,
                 cx,
             ),
             login: None,
             message_editor,
-            model_selector: None,
-            config_options_view: None,
-            profile_selector: None,
             notifications: Vec::new(),
             notification_subscriptions: HashMap::default(),
-            list_state,
-            thread_retry_status: None,
-            thread_error: None,
-            thread_error_markdown: None,
-            command_load_errors,
-            command_load_errors_dismissed: false,
             slash_command_registry,
-            token_limit_callout_dismissed: false,
-            thread_feedback: Default::default(),
             auth_task: None,
-            expanded_tool_calls: HashSet::default(),
-            expanded_tool_call_raw_inputs: HashSet::default(),
-            expanded_thinking_blocks: HashSet::default(),
-            expanded_subagents: HashSet::default(),
-            subagent_scroll_handles: RefCell::new(HashMap::default()),
-            editing_message: None,
-            local_queued_messages: Vec::new(),
-            queued_message_editors: Vec::new(),
-            queued_message_editor_subscriptions: Vec::new(),
-            last_synced_queue_length: 0,
-            edits_expanded: false,
-            plan_expanded: false,
-            queue_expanded: true,
-            discarded_partial_edits: HashSet::default(),
-            prompt_capabilities,
-            available_commands,
-            cached_user_commands,
-            cached_user_command_errors,
-            editor_expanded: false,
-            should_be_following: false,
             recent_history_entries,
             history,
             _history_subscription: history_subscription,
             hovered_recent_history_item: None,
-            is_loading_contents: false,
             _subscriptions: subscriptions,
-            _cancel_task: None,
             focus_handle: cx.focus_handle(),
-            new_server_version_available: None,
-            resume_thread_metadata: resume_thread,
-            resumed_without_history: false,
             show_codex_windows_warning,
             in_flight_prompt: None,
-            skip_queue_processing_count: 0,
-            user_interrupted_generation: false,
-            can_fast_track_queue: false,
-            turn_tokens: None,
-            last_turn_tokens: None,
-            turn_started_at: None,
-            last_turn_duration: None,
-            turn_generation: 0,
-            _turn_timer_task: None,
-            hovered_edited_file_buttons: None,
             add_context_menu_handle: PopoverMenuHandle::default(),
         }
     }
 
     fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
+        let available_commands = Rc::new(RefCell::new(vec![]));
+        let cached_user_commands = Rc::new(RefCell::new(collections::HashMap::default()));
+        let cached_user_command_errors = Rc::new(RefCell::new(Vec::new()));
+
+        let resume_thread_metadata = if let ThreadState::Active(ActiveThreadState {
+            resume_thread_metadata,
+            ..
+        }) = &self.thread_state
+        {
+            resume_thread_metadata.clone()
+        } else {
+            None
+        };
+
+        self.message_editor.update(cx, |editor, cx| {
+            editor.set_command_state(
+                prompt_capabilities.clone(),
+                available_commands.clone(),
+                cached_user_commands.clone(),
+                cached_user_command_errors.clone(),
+                cx,
+            );
+        });
+
         self.thread_state = Self::initial_state(
             self.agent.clone(),
-            self.resume_thread_metadata.clone(),
+            resume_thread_metadata,
             self.workspace.clone(),
             self.project.clone(),
+            prompt_capabilities,
+            available_commands,
+            cached_user_commands,
+            cached_user_command_errors,
             window,
             cx,
         );
-        self.available_commands.replace(vec![]);
         self.refresh_cached_user_commands(cx);
-        self.new_server_version_available.take();
         self.recent_history_entries.clear();
-        self.turn_tokens = None;
-        self.last_turn_tokens = None;
-        self.turn_started_at = None;
-        self.last_turn_duration = None;
-        self._turn_timer_task = None;
-        self.resumed_without_history = false;
         cx.notify();
     }
 
@@ -671,6 +591,10 @@ impl AcpThreadView {
         resume_thread: Option<AgentSessionInfo>,
         workspace: WeakEntity<Workspace>,
         project: Entity<Project>,
+        prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
+        available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+        cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
+        cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> ThreadState {
@@ -795,16 +719,30 @@ impl AcpThreadView {
                     Ok(thread) => {
                         let action_log = thread.read(cx).action_log().clone();
 
-                        this.resumed_without_history = resumed_without_history;
-                        this.prompt_capabilities
-                            .replace(thread.read(cx).prompt_capabilities());
+                        prompt_capabilities.replace(thread.read(cx).prompt_capabilities());
+
+                        let entry_view_state = cx.new(|_| {
+                            EntryViewState::new(
+                                this.workspace.clone(),
+                                this.project.downgrade(),
+                                this.thread_store.clone(),
+                                this.history.downgrade(),
+                                this.prompt_store.clone(),
+                                prompt_capabilities.clone(),
+                                available_commands.clone(),
+                                cached_user_commands.clone(),
+                                cached_user_command_errors.clone(),
+                                this.agent.name(),
+                            )
+                        });
 
                         let count = thread.read(cx).entries().len();
-                        this.entry_view_state.update(cx, |view_state, cx| {
+                        let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
+                        entry_view_state.update(cx, |view_state, cx| {
                             for ix in 0..count {
                                 view_state.sync_entry(ix, &thread, window, cx);
                             }
-                            this.list_state.splice_focusable(
+                            list_state.splice_focusable(
                                 0..0,
                                 (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
                             );
@@ -829,20 +767,22 @@ impl AcpThreadView {
                         let config_options_provider =
                             connection.session_config_options(&session_id, cx);
 
+                        let config_options_view;
                         let mode_selector;
+                        let model_selector;
                         if let Some(config_options) = config_options_provider {
                             // Use config options - don't create mode_selector or model_selector
                             let agent_server = this.agent.clone();
                             let fs = this.project.read(cx).fs().clone();
-                            this.config_options_view = Some(cx.new(|cx| {
+                            config_options_view = Some(cx.new(|cx| {
                                 ConfigOptionsView::new(config_options, agent_server, fs, window, cx)
                             }));
-                            this.model_selector = None;
+                            model_selector = None;
                             mode_selector = None;
                         } else {
                             // Fall back to legacy mode/model selectors
-                            this.config_options_view = None;
-                            this.model_selector =
+                            config_options_view = None;
+                            model_selector =
                                 connection.model_selector(&session_id).map(|selector| {
                                     let agent_server = this.agent.clone();
                                     let fs = this.project.read(cx).fs().clone();
@@ -879,6 +819,11 @@ impl AcpThreadView {
                         let mut subscriptions = vec![
                             cx.subscribe_in(&thread, window, Self::handle_thread_event),
                             cx.observe(&action_log, |_, _, cx| cx.notify()),
+                            cx.subscribe_in(
+                                &entry_view_state,
+                                window,
+                                Self::handle_entry_view_event,
+                            ),
                         ];
 
                         let title_editor =
@@ -898,23 +843,39 @@ impl AcpThreadView {
                                 None
                             };
 
-                        this.thread_state = ThreadState::Ready {
+                        let profile_selector: Option<Rc<agent::NativeAgentConnection>> =
+                            connection.clone().downcast();
+                        let profile_selector = profile_selector
+                            .and_then(|native_connection| native_connection.thread(&session_id, cx))
+                            .map(|native_thread| {
+                                cx.new(|cx| {
+                                    ProfileSelector::new(
+                                        <dyn Fs>::global(cx),
+                                        Arc::new(native_thread),
+                                        this.focus_handle(cx),
+                                        cx,
+                                    )
+                                })
+                            });
+
+                        this.thread_state = ThreadState::Active(ActiveThreadState::new(
                             thread,
+                            workspace.clone(),
+                            entry_view_state,
                             title_editor,
+                            config_options_view,
                             mode_selector,
-                            _subscriptions: subscriptions,
-                        };
-
-                        this.profile_selector = this.as_native_thread(cx).map(|thread| {
-                            cx.new(|cx| {
-                                ProfileSelector::new(
-                                    <dyn Fs>::global(cx),
-                                    Arc::new(thread.clone()),
-                                    this.focus_handle(cx),
-                                    cx,
-                                )
-                            })
-                        });
+                            model_selector,
+                            profile_selector,
+                            list_state,
+                            prompt_capabilities,
+                            available_commands,
+                            cached_user_commands,
+                            cached_user_command_errors,
+                            resumed_without_history,
+                            resume_thread.clone(),
+                            subscriptions,
+                        ));
 
                         if this.focus_handle.contains_focused(window, cx) {
                             this.message_editor.focus_handle(cx).focus(window, cx);
@@ -934,7 +895,13 @@ impl AcpThreadView {
             while let Ok(new_version) = new_version_available_rx.recv().await {
                 if let Some(new_version) = new_version {
                     this.update(cx, |this, cx| {
-                        this.new_server_version_available = Some(new_version.into());
+                        if let ThreadState::Active(ActiveThreadState {
+                            new_server_version_available,
+                            ..
+                        }) = &mut this.thread_state
+                        {
+                            *new_server_version_available = Some(new_version.into());
+                        }
                         cx.notify();
                     })
                     .ok();
@@ -1054,12 +1021,20 @@ impl AcpThreadView {
         // If we're in a LoadError state OR have a thread_error set (which can happen
         // when agent.connect() fails during loading), retry loading the thread.
         // This handles the case where a thread is restored before authentication completes.
-        let should_retry =
-            matches!(&self.thread_state, ThreadState::LoadError(_)) || self.thread_error.is_some();
+        let should_retry = match &self.thread_state {
+            ThreadState::LoadError(_)
+            | ThreadState::Active(ActiveThreadState {
+                thread_error: Some(_),
+                ..
+            }) => true,
+            _ => false,
+        };
 
         if should_retry {
-            self.thread_error = None;
-            self.thread_error_markdown = None;
+            if let Some(active) = self.as_active_thread_mut() {
+                active.thread_error = None;
+                active.thread_error_markdown = None;
+            }
             self.reset(window, cx);
         }
     }
@@ -1068,27 +1043,11 @@ impl AcpThreadView {
         &self.workspace
     }
 
-    pub fn thread(&self) -> Option<&Entity<AcpThread>> {
-        match &self.thread_state {
-            ThreadState::Ready { thread, .. } => Some(thread),
-            ThreadState::Unauthenticated { .. }
-            | ThreadState::Loading { .. }
-            | ThreadState::LoadError { .. } => None,
-        }
-    }
-
-    pub fn mode_selector(&self) -> Option<&Entity<ModeSelector>> {
-        match &self.thread_state {
-            ThreadState::Ready { mode_selector, .. } => mode_selector.as_ref(),
-            ThreadState::Unauthenticated { .. }
-            | ThreadState::Loading { .. }
-            | ThreadState::LoadError { .. } => None,
-        }
-    }
-
     pub fn title(&self, cx: &App) -> SharedString {
         match &self.thread_state {
-            ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(),
+            ThreadState::Active(ActiveThreadState { .. }) | ThreadState::Unauthenticated { .. } => {
+                "New Thread".into()
+            }
             ThreadState::Loading(loading_view) => loading_view.read(cx).title.clone(),
             ThreadState::LoadError(error) => match error {
                 LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
@@ -1101,21 +1060,9 @@ impl AcpThreadView {
         }
     }
 
-    pub fn title_editor(&self) -> Option<Entity<Editor>> {
-        if let ThreadState::Ready { title_editor, .. } = &self.thread_state {
-            title_editor.clone()
-        } else {
-            None
-        }
-    }
-
     pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
-        self.thread_error.take();
-        self.thread_retry_status.take();
-        self.user_interrupted_generation = true;
-
-        if let Some(thread) = self.thread() {
-            self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx)));
+        if let Some(active) = self.as_active_thread_mut() {
+            active.cancel_generation(cx);
         }
     }
 
@@ -1180,7 +1127,7 @@ impl AcpThreadView {
             return;
         }
 
-        let Some(thread) = self.thread() else {
+        let Some(active) = self.as_active_thread() else {
             return;
         };
 
@@ -1194,7 +1141,7 @@ impl AcpThreadView {
         let thread_store = session_list.thread_store().clone();
 
         let client = self.project.read(cx).client();
-        let session_id = thread.read(cx).session_id().clone();
+        let session_id = active.thread.read(cx).session_id().clone();
 
         cx.spawn_in(window, async move |this, cx| {
             let response = client
@@ -1222,7 +1169,9 @@ impl AcpThreadView {
             };
 
             this.update_in(cx, |this, window, cx| {
-                this.resume_thread_metadata = Some(thread_metadata);
+                if let Some(active) = this.as_active_thread_mut() {
+                    active.resume_thread_metadata = Some(thread_metadata);
+                }
                 this.reset(window, cx);
             })?;
 
@@ -1253,35 +1202,17 @@ impl AcpThreadView {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.set_editor_is_expanded(!self.editor_expanded, cx);
-        cx.stop_propagation();
-        cx.notify();
+        let editor = self.message_editor.clone();
+        if let Some(active) = self.as_active_thread_mut() {
+            active.expand_message_editor(editor, cx);
+        }
     }
 
     fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
-        self.editor_expanded = is_expanded;
-        self.message_editor.update(cx, |editor, cx| {
-            if is_expanded {
-                editor.set_mode(
-                    EditorMode::Full {
-                        scale_ui_elements_with_buffer_font_size: false,
-                        show_active_line_background: false,
-                        sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
-                    },
-                    cx,
-                )
-            } else {
-                let agent_settings = AgentSettings::get_global(cx);
-                editor.set_mode(
-                    EditorMode::AutoHeight {
-                        min_lines: agent_settings.message_editor_min_lines,
-                        max_lines: Some(agent_settings.set_message_editor_max_lines()),
-                    },
-                    cx,
-                )
-            }
-        });
-        cx.notify();
+        let editor = self.message_editor.clone();
+        if let Some(active) = self.as_active_thread_mut() {
+            active.set_editor_is_expanded(is_expanded, editor, cx);
+        }
     }
 
     pub fn handle_title_editor_event(
@@ -1291,25 +1222,8 @@ impl AcpThreadView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(thread) = self.thread() else { return };
-
-        match event {
-            EditorEvent::BufferEdited => {
-                let new_title = title_editor.read(cx).text(cx);
-                thread.update(cx, |thread, cx| {
-                    thread
-                        .set_title(new_title.into(), cx)
-                        .detach_and_log_err(cx);
-                })
-            }
-            EditorEvent::Blurred => {
-                if title_editor.read(cx).text(cx).is_empty() {
-                    title_editor.update(cx, |editor, cx| {
-                        editor.set_text("New Thread", window, cx);
-                    });
-                }
-            }
-            _ => {}
+        if let Some(active) = self.as_active_thread_mut() {
+            active.handle_title_editor_event(title_editor, event, window, cx);
         }
     }
 
@@ -1341,35 +1255,45 @@ impl AcpThreadView {
         match &event.view_event {
             ViewEvent::NewDiff(tool_call_id) => {
                 if AgentSettings::get_global(cx).expand_edit_card {
-                    self.expanded_tool_calls.insert(tool_call_id.clone());
+                    if let Some(active) = self.as_active_thread_mut() {
+                        active.expanded_tool_calls.insert(tool_call_id.clone());
+                    }
                 }
             }
             ViewEvent::NewTerminal(tool_call_id) => {
                 if AgentSettings::get_global(cx).expand_terminal_card {
-                    self.expanded_tool_calls.insert(tool_call_id.clone());
+                    if let Some(active) = self.as_active_thread_mut() {
+                        active.expanded_tool_calls.insert(tool_call_id.clone());
+                    }
                 }
             }
             ViewEvent::TerminalMovedToBackground(tool_call_id) => {
-                self.expanded_tool_calls.remove(tool_call_id);
+                if let Some(active) = self.as_active_thread_mut() {
+                    active.expanded_tool_calls.remove(tool_call_id);
+                }
             }
             ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
-                if let Some(thread) = self.thread()
+                if let Some(active) = self.as_active_thread()
                     && let Some(AgentThreadEntry::UserMessage(user_message)) =
-                        thread.read(cx).entries().get(event.entry_index)
+                        active.thread.read(cx).entries().get(event.entry_index)
                     && user_message.id.is_some()
                 {
-                    self.editing_message = Some(event.entry_index);
+                    if let Some(active) = self.as_active_thread_mut() {
+                        active.editing_message = Some(event.entry_index);
+                    }
                     cx.notify();
                 }
             }
             ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => {
-                if let Some(thread) = self.thread()
+                if let Some(active) = self.as_active_thread()
                     && let Some(AgentThreadEntry::UserMessage(user_message)) =
-                        thread.read(cx).entries().get(event.entry_index)
+                        active.thread.read(cx).entries().get(event.entry_index)
                     && user_message.id.is_some()
                 {
                     if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) {
-                        self.editing_message = None;
+                        if let Some(active) = self.as_active_thread_mut() {
+                            active.editing_message = None;
+                        }
                         cx.notify();
                     }
                 }
@@ -1389,165 +1313,43 @@ impl AcpThreadView {
     }
 
     fn retry_generation(&mut self, cx: &mut Context<Self>) {
-        self.thread_error.take();
-        let Some(thread) = self.thread() else {
-            return;
+        if let Some(active) = self.as_active_thread_mut() {
+            active.retry_generation(cx);
         };
-        if !thread.read(cx).can_retry(cx) {
-            return;
-        }
-
-        let task = thread.update(cx, |thread, cx| thread.retry(cx));
-        cx.spawn(async move |this, cx| {
-            let result = task.await;
-
-            this.update(cx, |this, cx| {
-                if let Err(err) = result {
-                    this.handle_thread_error(err, cx);
-                }
-            })
-        })
-        .detach();
     }
 
     fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let Some(thread) = self.thread() else { return };
-
-        if self.is_loading_contents {
-            return;
-        }
-
-        let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
-        let is_generating = thread.read(cx).status() != ThreadStatus::Idle;
-
-        let has_queued = self.has_queued_messages();
-        if is_editor_empty && self.can_fast_track_queue && has_queued {
-            self.can_fast_track_queue = false;
-            self.send_queued_message_at_index(0, true, window, cx);
-            return;
-        }
-
-        if is_editor_empty {
-            return;
-        }
-
-        if is_generating {
-            self.queue_message(window, cx);
-            return;
-        }
-
-        let text = self.message_editor.read(cx).text(cx);
-        let text = text.trim();
-        if text == "/login" || text == "/logout" {
-            let ThreadState::Ready { thread, .. } = &self.thread_state else {
-                return;
-            };
-
-            let connection = thread.read(cx).connection().clone();
-            let can_login = !connection.auth_methods().is_empty() || self.login.is_some();
-            // Does the agent have a specific logout command? Prefer that in case they need to reset internal state.
-            let logout_supported = text == "/logout"
-                && self
-                    .available_commands
-                    .borrow()
-                    .iter()
-                    .any(|command| command.name == "logout");
-            if can_login && !logout_supported {
-                self.message_editor
-                    .update(cx, |editor, cx| editor.clear(window, cx));
+        let message_editor = self.message_editor.clone();
+        let agent = self.agent.clone();
+        let login = self.login.clone();
 
-                let this = cx.weak_entity();
-                let agent = self.agent.clone();
-                window.defer(cx, |window, cx| {
-                    Self::handle_auth_required(
-                        this,
-                        AuthRequired::new(),
-                        agent,
-                        connection,
-                        window,
-                        cx,
-                    );
-                });
-                cx.notify();
-                return;
-            }
+        if let Some(active) = self.as_active_thread_mut() {
+            active.send(message_editor, agent, login, window, cx);
         }
-
-        self.send_impl(self.message_editor.clone(), window, cx)
     }
 
     fn interrupt_and_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let Some(thread) = self.thread() else {
-            return;
-        };
-
-        if self.is_loading_contents {
-            return;
-        }
-
-        if thread.read(cx).status() == ThreadStatus::Idle {
-            self.send_impl(self.message_editor.clone(), window, cx);
-            return;
-        }
-
-        self.stop_current_and_send_new_message(window, cx);
-    }
-
-    fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let Some(thread) = self.thread().cloned() else {
-            return;
+        let message_editor = self.message_editor.clone();
+        if let Some(active) = self.as_active_thread_mut() {
+            active.interrupt_and_send(message_editor, window, cx);
         };
-
-        self.skip_queue_processing_count = 0;
-        self.user_interrupted_generation = true;
-
-        let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
-
-        cx.spawn_in(window, async move |this, cx| {
-            cancelled.await;
-
-            this.update_in(cx, |this, window, cx| {
-                this.send_impl(this.message_editor.clone(), window, cx);
-            })
-            .ok();
-        })
-        .detach();
     }
 
     fn start_turn(&mut self, cx: &mut Context<Self>) -> usize {
-        self.turn_generation += 1;
-        let generation = self.turn_generation;
-        self.turn_started_at = Some(Instant::now());
-        self.last_turn_duration = None;
-        self.last_turn_tokens = None;
-        self.turn_tokens = Some(0);
-        self._turn_timer_task = Some(cx.spawn(async move |this, cx| {
-            loop {
-                cx.background_executor().timer(Duration::from_secs(1)).await;
-                if this.update(cx, |_, cx| cx.notify()).is_err() {
-                    break;
-                }
-            }
-        }));
-        generation
+        self.as_active_thread_mut()
+            .map(|active| active.start_turn(cx))
+            .unwrap_or(0)
     }
 
     fn stop_turn(&mut self, generation: usize) {
-        if self.turn_generation != generation {
-            return;
+        if let Some(active) = self.as_active_thread_mut() {
+            active.stop_turn(generation);
         }
-        self.last_turn_duration = self.turn_started_at.take().map(|started| started.elapsed());
-        self.last_turn_tokens = self.turn_tokens.take();
-        self._turn_timer_task = None;
     }
 
     fn update_turn_tokens(&mut self, cx: &App) {
-        if let Some(thread) = self.thread() {
-            if let Some(usage) = thread.read(cx).token_usage() {
-                if let Some(ref mut tokens) = self.turn_tokens {
-                    *tokens += usage.output_tokens;
-                }
-            }
+        if let Some(active) = self.as_active_thread_mut() {
+            active.update_turn_tokens(cx);
         }
     }
 
@@ -1577,11 +1379,23 @@ impl AcpThreadView {
             )
         });
 
-        self.thread_error.take();
-        self.editing_message.take();
-        self.thread_feedback.clear();
+        if let ThreadState::Active(ActiveThreadState {
+            thread_error,
+            thread_feedback,
+            editing_message,
+            ..
+        }) = &mut self.thread_state
+        {
+            thread_error.take();
+            thread_feedback.clear();
+            editing_message.take();
+        }
 
-        if self.should_be_following {
+        if let ThreadState::Active(ActiveThreadState {
+            should_be_following: true,
+            ..
+        }) = &self.thread_state
+        {
             self.workspace
                 .update(cx, |workspace, cx| {
                     workspace.follow(CollaboratorId::Agent, window, cx);
@@ -1614,159 +1428,9 @@ impl AcpThreadView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(thread) = self.thread() else {
-            return;
+        if let Some(active) = self.as_active_thread_mut() {
+            active.send_content(contents_task, window, cx);
         };
-        let session_id = thread.read(cx).session_id().clone();
-        let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
-        let thread = thread.downgrade();
-
-        self.is_loading_contents = true;
-        let model_id = self.current_model_id(cx);
-        let mode_id = self.current_mode_id(cx);
-        let guard = cx.new(|_| ());
-        cx.observe_release(&guard, |this, _guard, cx| {
-            this.is_loading_contents = false;
-            cx.notify();
-        })
-        .detach();
-
-        let task = cx.spawn_in(window, async move |this, cx| {
-            let Some((contents, tracked_buffers)) = contents_task.await? else {
-                return Ok(());
-            };
-
-            let generation = this.update_in(cx, |this, _window, cx| {
-                this.in_flight_prompt = Some(contents.clone());
-                let generation = this.start_turn(cx);
-                this.set_editor_is_expanded(false, cx);
-                this.scroll_to_bottom(cx);
-                generation
-            })?;
-
-            let _stop_turn = defer({
-                let this = this.clone();
-                let mut cx = cx.clone();
-                move || {
-                    this.update(&mut cx, |this, cx| {
-                        this.stop_turn(generation);
-                        cx.notify();
-                    })
-                    .ok();
-                }
-            });
-            let turn_start_time = Instant::now();
-            let send = thread.update(cx, |thread, cx| {
-                thread.action_log().update(cx, |action_log, cx| {
-                    for buffer in tracked_buffers {
-                        action_log.buffer_read(buffer, cx)
-                    }
-                });
-                drop(guard);
-
-                telemetry::event!(
-                    "Agent Message Sent",
-                    agent = agent_telemetry_id,
-                    session = session_id,
-                    model = model_id,
-                    mode = mode_id
-                );
-
-                thread.send(contents, cx)
-            })?;
-            let res = send.await;
-            let turn_time_ms = turn_start_time.elapsed().as_millis();
-            drop(_stop_turn);
-            let status = if res.is_ok() {
-                this.update(cx, |this, _| this.in_flight_prompt.take()).ok();
-                "success"
-            } else {
-                "failure"
-            };
-            telemetry::event!(
-                "Agent Turn Completed",
-                agent = agent_telemetry_id,
-                session = session_id,
-                model = model_id,
-                mode = mode_id,
-                status,
-                turn_time_ms,
-            );
-            res
-        });
-
-        cx.spawn(async move |this, cx| {
-            if let Err(err) = task.await {
-                this.update(cx, |this, cx| {
-                    this.handle_thread_error(err, cx);
-                })
-                .ok();
-            } else {
-                this.update(cx, |this, cx| {
-                    this.should_be_following = this
-                        .workspace
-                        .update(cx, |workspace, _| {
-                            workspace.is_being_followed(CollaboratorId::Agent)
-                        })
-                        .unwrap_or_default();
-                })
-                .ok();
-            }
-        })
-        .detach();
-    }
-
-    fn queue_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let is_idle = self
-            .thread()
-            .map(|t| t.read(cx).status() == acp_thread::ThreadStatus::Idle)
-            .unwrap_or(true);
-
-        if is_idle {
-            self.send_impl(self.message_editor.clone(), window, cx);
-            return;
-        }
-
-        let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
-            let thread = thread.read(cx);
-            AgentSettings::get_global(cx)
-                .profiles
-                .get(thread.profile())
-                .is_some_and(|profile| profile.tools.is_empty())
-        });
-
-        let cached_commands = self.cached_slash_commands(cx);
-        let cached_errors = self.cached_slash_command_errors(cx);
-        let contents = self.message_editor.update(cx, |message_editor, cx| {
-            message_editor.contents_with_cache(
-                full_mention_content,
-                Some(cached_commands),
-                Some(cached_errors),
-                cx,
-            )
-        });
-
-        let message_editor = self.message_editor.clone();
-
-        cx.spawn_in(window, async move |this, cx| {
-            let (content, tracked_buffers) = contents.await?;
-
-            if content.is_empty() {
-                return Ok::<(), anyhow::Error>(());
-            }
-
-            this.update_in(cx, |this, window, cx| {
-                this.add_to_queue(content, tracked_buffers, cx);
-                // Enable fast-track: user can press Enter again to send this queued message immediately
-                this.can_fast_track_queue = true;
-                message_editor.update(cx, |message_editor, cx| {
-                    message_editor.clear(window, cx);
-                });
-                cx.notify();
-            })?;
-            Ok(())
-        })
-        .detach_and_log_err(cx);
     }
 
     fn send_queued_message_at_index(

crates/agent_ui/src/acp/thread_view/active_thread.rs 🔗

@@ -0,0 +1,1224 @@
+use super::*;
+
+pub struct ActiveThreadState {
+    pub thread: Entity<AcpThread>,
+    pub workspace: WeakEntity<Workspace>,
+    pub entry_view_state: Entity<EntryViewState>,
+    pub title_editor: Option<Entity<Editor>>,
+    pub config_options_view: Option<Entity<ConfigOptionsView>>,
+    pub mode_selector: Option<Entity<ModeSelector>>,
+    pub model_selector: Option<Entity<AcpModelSelectorPopover>>,
+    pub profile_selector: Option<Entity<ProfileSelector>>,
+    pub permission_dropdown_handle: PopoverMenuHandle<ContextMenu>,
+    pub thread_retry_status: Option<RetryStatus>,
+    pub(super) thread_error: Option<ThreadError>,
+    pub thread_error_markdown: Option<Entity<Markdown>>,
+    pub token_limit_callout_dismissed: bool,
+    pub(super) thread_feedback: ThreadFeedbackState,
+    pub list_state: ListState,
+    pub prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
+    pub available_commands: Rc<RefCell<Vec<agent_client_protocol::AvailableCommand>>>,
+    pub cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
+    pub cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
+    /// Tracks which tool calls have their content/output expanded.
+    /// Used for showing/hiding tool call results, terminal output, etc.
+    pub expanded_tool_calls: HashSet<agent_client_protocol::ToolCallId>,
+    pub expanded_tool_call_raw_inputs: HashSet<agent_client_protocol::ToolCallId>,
+    pub expanded_thinking_blocks: HashSet<(usize, usize)>,
+    pub expanded_subagents: HashSet<agent_client_protocol::SessionId>,
+    pub subagent_scroll_handles: RefCell<HashMap<agent_client_protocol::SessionId, ScrollHandle>>,
+    pub edits_expanded: bool,
+    pub plan_expanded: bool,
+    pub queue_expanded: bool,
+    pub editor_expanded: bool,
+    pub should_be_following: bool,
+    pub editing_message: Option<usize>,
+    pub local_queued_messages: Vec<QueuedMessage>,
+    pub queued_message_editors: Vec<Entity<MessageEditor>>,
+    pub queued_message_editor_subscriptions: Vec<Subscription>,
+    pub last_synced_queue_length: usize,
+    pub turn_fields: TurnFields,
+    pub command_load_errors_dismissed: bool,
+    pub discarded_partial_edits: HashSet<agent_client_protocol::ToolCallId>,
+    pub is_loading_contents: bool,
+    pub new_server_version_available: Option<SharedString>,
+    pub resumed_without_history: bool,
+    /// Tracks the selected granularity index for each tool call's permission dropdown.
+    /// The index corresponds to the position in the allow_options list.
+    /// Default is the last option (index pointing to "Only this time").
+    pub selected_permission_granularity: HashMap<agent_client_protocol::ToolCallId, usize>,
+    pub resume_thread_metadata: Option<AgentSessionInfo>,
+    pub _cancel_task: Option<Task<()>>,
+    pub skip_queue_processing_count: usize,
+    pub user_interrupted_generation: bool,
+    pub can_fast_track_queue: bool,
+    pub hovered_edited_file_buttons: Option<usize>,
+    pub _subscriptions: Vec<Subscription>,
+}
+
+#[derive(Default)]
+pub struct TurnFields {
+    pub _turn_timer_task: Option<Task<()>>,
+    pub last_turn_duration: Option<Duration>,
+    pub last_turn_tokens: Option<u64>,
+    pub turn_generation: usize,
+    pub turn_started_at: Option<Instant>,
+    pub turn_tokens: Option<u64>,
+}
+
+impl ActiveThreadState {
+    pub fn new(
+        thread: Entity<AcpThread>,
+        workspace: WeakEntity<Workspace>,
+        entry_view_state: Entity<EntryViewState>,
+        title_editor: Option<Entity<Editor>>,
+        config_options_view: Option<Entity<ConfigOptionsView>>,
+        mode_selector: Option<Entity<ModeSelector>>,
+        model_selector: Option<Entity<AcpModelSelectorPopover>>,
+        profile_selector: Option<Entity<ProfileSelector>>,
+        list_state: ListState,
+        prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
+        available_commands: Rc<RefCell<Vec<agent_client_protocol::AvailableCommand>>>,
+        cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
+        cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
+        resumed_without_history: bool,
+        resume_thread_metadata: Option<AgentSessionInfo>,
+        subscriptions: Vec<Subscription>,
+    ) -> Self {
+        Self {
+            thread,
+            workspace,
+            entry_view_state,
+            title_editor,
+            config_options_view,
+            mode_selector,
+            model_selector,
+            profile_selector,
+            list_state,
+            prompt_capabilities,
+            available_commands,
+            cached_user_commands,
+            cached_user_command_errors,
+            resumed_without_history,
+            resume_thread_metadata,
+            command_load_errors_dismissed: false,
+            _subscriptions: subscriptions,
+            permission_dropdown_handle: PopoverMenuHandle::default(),
+            thread_retry_status: None,
+            thread_error: None,
+            thread_error_markdown: None,
+            token_limit_callout_dismissed: false,
+            thread_feedback: Default::default(),
+            expanded_tool_calls: HashSet::default(),
+            expanded_tool_call_raw_inputs: HashSet::default(),
+            expanded_thinking_blocks: HashSet::default(),
+            expanded_subagents: HashSet::default(),
+            subagent_scroll_handles: RefCell::new(HashMap::default()),
+            edits_expanded: false,
+            plan_expanded: false,
+            queue_expanded: true,
+            editor_expanded: false,
+            should_be_following: false,
+            editing_message: None,
+            local_queued_messages: Vec::new(),
+            queued_message_editors: Vec::new(),
+            queued_message_editor_subscriptions: Vec::new(),
+            last_synced_queue_length: 0,
+            turn_fields: TurnFields::default(),
+            discarded_partial_edits: HashSet::default(),
+            is_loading_contents: false,
+            new_server_version_available: None,
+            selected_permission_granularity: HashMap::default(),
+            _cancel_task: None,
+            skip_queue_processing_count: 0,
+            user_interrupted_generation: false,
+            can_fast_track_queue: false,
+            hovered_edited_file_buttons: None,
+        }
+    }
+
+    pub(crate) fn as_native_connection(
+        &self,
+        cx: &App,
+    ) -> Option<Rc<agent::NativeAgentConnection>> {
+        let acp_thread = self.thread.read(cx);
+        acp_thread.connection().clone().downcast()
+    }
+
+    pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
+        let acp_thread = self.thread.read(cx);
+        self.as_native_connection(cx)?
+            .thread(acp_thread.session_id(), cx)
+    }
+
+    pub fn current_model_id(&self, cx: &App) -> Option<String> {
+        let selector = self.model_selector.as_ref()?;
+        let model = selector.read(cx).active_model(cx)?;
+        Some(model.id.to_string())
+    }
+
+    pub fn current_mode_id(&self, cx: &App) -> Option<Arc<str>> {
+        if let Some(thread) = self.as_native_thread(cx) {
+            Some(thread.read(cx).profile().0.clone())
+        } else {
+            let mode_selector = self.mode_selector.as_ref()?;
+            Some(mode_selector.read(cx).mode().0)
+        }
+    }
+
+    pub fn has_queued_messages(&self) -> bool {
+        !self.local_queued_messages.is_empty()
+    }
+
+    pub fn is_imported_thread(&self, cx: &App) -> bool {
+        let Some(thread) = self.as_native_thread(cx) else {
+            return false;
+        };
+        thread.read(cx).is_imported()
+    }
+
+    // turns
+
+    pub fn start_turn(&mut self, cx: &mut Context<AcpThreadView>) -> usize {
+        self.turn_fields.turn_generation += 1;
+        let generation = self.turn_fields.turn_generation;
+        self.turn_fields.turn_started_at = Some(Instant::now());
+        self.turn_fields.last_turn_duration = None;
+        self.turn_fields.last_turn_tokens = None;
+        self.turn_fields.turn_tokens = Some(0);
+        self.turn_fields._turn_timer_task = Some(cx.spawn(async move |this, cx| {
+            loop {
+                cx.background_executor().timer(Duration::from_secs(1)).await;
+                if this.update(cx, |_, cx| cx.notify()).is_err() {
+                    break;
+                }
+            }
+        }));
+        generation
+    }
+
+    pub fn stop_turn(&mut self, generation: usize) {
+        if self.turn_fields.turn_generation != generation {
+            return;
+        }
+        self.turn_fields.last_turn_duration = self
+            .turn_fields
+            .turn_started_at
+            .take()
+            .map(|started| started.elapsed());
+        self.turn_fields.last_turn_tokens = self.turn_fields.turn_tokens.take();
+        self.turn_fields._turn_timer_task = None;
+    }
+
+    pub fn update_turn_tokens(&mut self, cx: &App) {
+        if let Some(usage) = self.thread.read(cx).token_usage() {
+            if let Some(tokens) = &mut self.turn_fields.turn_tokens {
+                *tokens += usage.output_tokens;
+            }
+        }
+    }
+
+    // sending
+
+    pub fn send(
+        &mut self,
+        message_editor: Entity<MessageEditor>,
+        agent: Rc<dyn AgentServer>,
+        login: Option<task::SpawnInTerminal>,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let thread = &self.thread;
+
+        if self.is_loading_contents {
+            return;
+        }
+
+        let is_editor_empty = message_editor.read(cx).is_empty(cx);
+        let is_generating = thread.read(cx).status() != ThreadStatus::Idle;
+
+        let has_queued = self.has_queued_messages();
+        if is_editor_empty && self.can_fast_track_queue && has_queued {
+            self.can_fast_track_queue = false;
+            self.send_queued_message_at_index(0, true, window, cx);
+            return;
+        }
+
+        if is_editor_empty {
+            return;
+        }
+
+        if is_generating {
+            self.queue_message(message_editor, window, cx);
+            return;
+        }
+
+        let text = message_editor.read(cx).text(cx);
+        let text = text.trim();
+        if text == "/login" || text == "/logout" {
+            let connection = thread.read(cx).connection().clone();
+            let can_login = !connection.auth_methods().is_empty() || login.is_some();
+            // Does the agent have a specific logout command? Prefer that in case they need to reset internal state.
+            let logout_supported = text == "/logout"
+                && self
+                    .available_commands
+                    .borrow()
+                    .iter()
+                    .any(|command| command.name == "logout");
+            if can_login && !logout_supported {
+                message_editor.update(cx, |editor, cx| editor.clear(window, cx));
+
+                let this = cx.weak_entity();
+                let agent = agent.clone();
+                window.defer(cx, |window, cx| {
+                    AcpThreadView::handle_auth_required(
+                        this,
+                        AuthRequired::new(),
+                        agent,
+                        connection,
+                        window,
+                        cx,
+                    );
+                });
+                cx.notify();
+                return;
+            }
+        }
+
+        self.send_impl(message_editor, window, cx)
+    }
+
+    pub fn send_impl(
+        &mut self,
+        message_editor: Entity<MessageEditor>,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
+            // Include full contents when using minimal profile
+            let thread = thread.read(cx);
+            AgentSettings::get_global(cx)
+                .profiles
+                .get(thread.profile())
+                .is_some_and(|profile| profile.tools.is_empty())
+        });
+
+        let cached_commands = &self.cached_user_commands;
+        let cached_errors = &self.cached_user_command_errors;
+        let contents = message_editor.update(cx, |message_editor, cx| {
+            message_editor.contents_with_cache(
+                full_mention_content,
+                Some(cached_commands.borrow().clone()),
+                Some(cached_errors.borrow().clone()),
+                cx,
+            )
+        });
+
+        self.thread_error.take();
+        self.thread_feedback.clear();
+        self.editing_message.take();
+
+        if self.should_be_following {
+            self.workspace
+                .update(cx, |workspace, cx| {
+                    workspace.follow(CollaboratorId::Agent, window, cx);
+                })
+                .ok();
+        }
+
+        let contents_task = cx.spawn_in(window, async move |_this, cx| {
+            let (contents, tracked_buffers) = contents.await?;
+
+            if contents.is_empty() {
+                return Ok(None);
+            }
+
+            let _ = cx.update(|window, cx| {
+                message_editor.update(cx, |message_editor, cx| {
+                    message_editor.clear(window, cx);
+                });
+            });
+
+            Ok(Some((contents, tracked_buffers)))
+        });
+
+        self.send_content(contents_task, window, cx);
+    }
+
+    pub fn send_content(
+        &mut self,
+        contents_task: Task<anyhow::Result<Option<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>>,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let session_id = self.thread.read(cx).session_id().clone();
+        let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id();
+        let thread = self.thread.downgrade();
+
+        self.is_loading_contents = true;
+
+        let model_id = self.current_model_id(cx);
+        let mode_id = self.current_mode_id(cx);
+        let guard = cx.new(|_| ());
+        cx.observe_release(&guard, |this, _guard, cx| {
+            if let ThreadState::Active(ActiveThreadState {
+                is_loading_contents,
+                ..
+            }) = &mut this.thread_state
+            {
+                *is_loading_contents = false;
+            }
+            cx.notify();
+        })
+        .detach();
+
+        let task = cx.spawn_in(window, async move |this, cx| {
+            let Some((contents, tracked_buffers)) = contents_task.await? else {
+                return Ok(());
+            };
+
+            let generation = this.update_in(cx, |this, _window, cx| {
+                this.in_flight_prompt = Some(contents.clone());
+                let generation = this.start_turn(cx);
+                this.set_editor_is_expanded(false, cx);
+                this.scroll_to_bottom(cx);
+                generation
+            })?;
+
+            let _stop_turn = defer({
+                let this = this.clone();
+                let mut cx = cx.clone();
+                move || {
+                    this.update(&mut cx, |this, cx| {
+                        this.stop_turn(generation);
+                        cx.notify();
+                    })
+                    .ok();
+                }
+            });
+            let turn_start_time = Instant::now();
+            let send = thread.update(cx, |thread, cx| {
+                thread.action_log().update(cx, |action_log, cx| {
+                    for buffer in tracked_buffers {
+                        action_log.buffer_read(buffer, cx)
+                    }
+                });
+                drop(guard);
+
+                telemetry::event!(
+                    "Agent Message Sent",
+                    agent = agent_telemetry_id,
+                    session = session_id,
+                    model = model_id,
+                    mode = mode_id
+                );
+
+                thread.send(contents, cx)
+            })?;
+            let res = send.await;
+            let turn_time_ms = turn_start_time.elapsed().as_millis();
+            drop(_stop_turn);
+            let status = if res.is_ok() {
+                this.update(cx, |this, _| this.in_flight_prompt.take()).ok();
+                "success"
+            } else {
+                "failure"
+            };
+            telemetry::event!(
+                "Agent Turn Completed",
+                agent = agent_telemetry_id,
+                session = session_id,
+                model = model_id,
+                mode = mode_id,
+                status,
+                turn_time_ms,
+            );
+            res
+        });
+
+        cx.spawn(async move |this, cx| {
+            if let Err(err) = task.await {
+                this.update(cx, |this, cx| {
+                    this.handle_thread_error(err, cx);
+                })
+                .ok();
+            } else {
+                this.update(cx, |this, cx| {
+                    if let ThreadState::Active(ActiveThreadState {
+                        should_be_following,
+                        ..
+                    }) = &mut this.thread_state
+                    {
+                        *should_be_following = this
+                            .workspace
+                            .update(cx, |workspace, _| {
+                                workspace.is_being_followed(CollaboratorId::Agent)
+                            })
+                            .unwrap_or_default();
+                    }
+                })
+                .ok();
+            }
+        })
+        .detach();
+    }
+
+    pub fn interrupt_and_send(
+        &mut self,
+        message_editor: Entity<MessageEditor>,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let thread = &self.thread;
+
+        if self.is_loading_contents {
+            return;
+        }
+
+        if thread.read(cx).status() == ThreadStatus::Idle {
+            self.send_impl(message_editor, window, cx);
+            return;
+        }
+
+        self.stop_current_and_send_new_message(window, cx);
+    }
+
+    pub fn stop_current_and_send_new_message(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let thread = self.thread.clone();
+        self.skip_queue_processing_count = 0;
+        self.user_interrupted_generation = true;
+
+        let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
+
+        cx.spawn_in(window, async move |this, cx| {
+            cancelled.await;
+
+            this.update_in(cx, |this, window, cx| {
+                this.send_impl(this.message_editor.clone(), window, cx);
+            })
+            .ok();
+        })
+        .detach();
+    }
+
+    // generation
+
+    pub fn cancel_generation(&mut self, cx: &mut Context<AcpThreadView>) {
+        self.thread_retry_status.take();
+        self.thread_error.take();
+        self.user_interrupted_generation = true;
+        self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx)));
+    }
+
+    pub fn retry_generation(&mut self, cx: &mut Context<AcpThreadView>) {
+        self.thread_error.take();
+
+        let thread = &self.thread;
+        if !thread.read(cx).can_retry(cx) {
+            return;
+        }
+
+        let task = thread.update(cx, |thread, cx| thread.retry(cx));
+        cx.spawn(async move |this, cx| {
+            let result = task.await;
+
+            this.update(cx, |this, cx| {
+                if let Err(err) = result {
+                    this.handle_thread_error(err, cx);
+                }
+            })
+        })
+        .detach();
+    }
+
+    pub fn regenerate(
+        &mut self,
+        entry_ix: usize,
+        message_editor: Entity<MessageEditor>,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        if self.is_loading_contents {
+            return;
+        }
+        let thread = self.thread.clone();
+
+        let Some(user_message_id) = thread.update(cx, |thread, _| {
+            thread.entries().get(entry_ix)?.user_message()?.id.clone()
+        }) else {
+            return;
+        };
+
+        cx.spawn_in(window, async move |this, cx| {
+            // Check if there are any edits from prompts before the one being regenerated.
+            //
+            // If there are, we keep/accept them since we're not regenerating the prompt that created them.
+            //
+            // If editing the prompt that generated the edits, they are auto-rejected
+            // through the `rewind` function in the `acp_thread`.
+            let has_earlier_edits = thread.read_with(cx, |thread, _| {
+                thread
+                    .entries()
+                    .iter()
+                    .take(entry_ix)
+                    .any(|entry| entry.diffs().next().is_some())
+            });
+
+            if has_earlier_edits {
+                thread.update(cx, |thread, cx| {
+                    thread.action_log().update(cx, |action_log, cx| {
+                        action_log.keep_all_edits(None, cx);
+                    });
+                });
+            }
+
+            thread
+                .update(cx, |thread, cx| thread.rewind(user_message_id, cx))
+                .await?;
+            this.update_in(cx, |this, window, cx| {
+                this.send_impl(message_editor, window, cx);
+                this.focus_handle(cx).focus(window, cx);
+            })?;
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
+
+    // message queueing
+
+    pub fn queue_message(
+        &mut self,
+        message_editor: Entity<MessageEditor>,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let is_idle = self.thread.read(cx).status() == acp_thread::ThreadStatus::Idle;
+
+        if is_idle {
+            self.send_impl(message_editor.clone(), window, cx);
+            return;
+        }
+
+        let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| {
+            let thread = thread.read(cx);
+            AgentSettings::get_global(cx)
+                .profiles
+                .get(thread.profile())
+                .is_some_and(|profile| profile.tools.is_empty())
+        });
+
+        let cached_commands = self.cached_user_commands.borrow().clone();
+        let cached_errors = self.cached_user_command_errors.borrow().clone();
+        let contents = message_editor.update(cx, |message_editor, cx| {
+            message_editor.contents_with_cache(
+                full_mention_content,
+                Some(cached_commands),
+                Some(cached_errors),
+                cx,
+            )
+        });
+
+        cx.spawn_in(window, async move |this, cx| {
+            let (content, tracked_buffers) = contents.await?;
+
+            if content.is_empty() {
+                return Ok::<(), anyhow::Error>(());
+            }
+
+            this.update_in(cx, |this, window, cx| {
+                this.add_to_queue(content, tracked_buffers, cx);
+                // Enable fast-track: user can press Enter again to send this queued message immediately
+                this.set_can_fast_track_queue(true);
+                message_editor.update(cx, |message_editor, cx| {
+                    message_editor.clear(window, cx);
+                });
+                cx.notify();
+            })?;
+            Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
+
+    pub fn remove_from_queue(
+        &mut self,
+        index: usize,
+        cx: &mut Context<AcpThreadView>,
+    ) -> Option<QueuedMessage> {
+        if index < self.local_queued_messages.len() {
+            let removed = self.local_queued_messages.remove(index);
+            self.sync_queue_flag_to_native_thread(cx);
+            Some(removed)
+        } else {
+            None
+        }
+    }
+
+    pub fn sync_queue_flag_to_native_thread(&self, cx: &mut Context<AcpThreadView>) {
+        if let Some(native_thread) = self.as_native_thread(cx) {
+            let has_queued = self.has_queued_messages();
+            native_thread.update(cx, |thread, _| {
+                thread.set_has_queued_message(has_queued);
+            });
+        }
+    }
+
+    pub fn send_queued_message_at_index(
+        &mut self,
+        index: usize,
+        is_send_now: bool,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let Some(queued) = self.remove_from_queue(index, cx) else {
+            return;
+        };
+        let content = queued.content;
+        let tracked_buffers = queued.tracked_buffers;
+
+        // Only increment skip count for "Send Now" operations (out-of-order sends)
+        // Normal auto-processing from the Stopped handler doesn't need to skip.
+        // We only skip the Stopped event from the cancelled generation, NOT the
+        // Stopped event from the newly sent message (which should trigger queue processing).
+        if is_send_now {
+            let is_generating =
+                self.thread.read(cx).status() == acp_thread::ThreadStatus::Generating;
+            self.skip_queue_processing_count += if is_generating { 1 } else { 0 };
+        }
+
+        let cancelled = self.thread.update(cx, |thread, cx| thread.cancel(cx));
+
+        let workspace = self.workspace.clone();
+
+        let should_be_following = self.should_be_following;
+        let contents_task = cx.spawn_in(window, async move |_this, cx| {
+            cancelled.await;
+            if should_be_following {
+                workspace
+                    .update_in(cx, |workspace, window, cx| {
+                        workspace.follow(CollaboratorId::Agent, window, cx);
+                    })
+                    .ok();
+            }
+
+            Ok(Some((content, tracked_buffers)))
+        });
+
+        self.send_content(contents_task, window, cx);
+    }
+
+    // editor methods
+
+    pub fn expand_message_editor(
+        &mut self,
+        message_editor: Entity<MessageEditor>,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        self.set_editor_is_expanded(!self.editor_expanded, message_editor, cx);
+        cx.stop_propagation();
+        cx.notify();
+    }
+
+    pub fn set_editor_is_expanded(
+        &mut self,
+        is_expanded: bool,
+        message_editor: Entity<MessageEditor>,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        self.editor_expanded = is_expanded;
+        message_editor.update(cx, |editor, cx| {
+            if is_expanded {
+                editor.set_mode(
+                    EditorMode::Full {
+                        scale_ui_elements_with_buffer_font_size: false,
+                        show_active_line_background: false,
+                        sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
+                    },
+                    cx,
+                )
+            } else {
+                let agent_settings = AgentSettings::get_global(cx);
+                editor.set_mode(
+                    EditorMode::AutoHeight {
+                        min_lines: agent_settings.message_editor_min_lines,
+                        max_lines: Some(agent_settings.set_message_editor_max_lines()),
+                    },
+                    cx,
+                )
+            }
+        });
+        cx.notify();
+    }
+
+    pub fn handle_title_editor_event(
+        &mut self,
+        title_editor: &Entity<Editor>,
+        event: &EditorEvent,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let thread = &self.thread;
+
+        match event {
+            EditorEvent::BufferEdited => {
+                let new_title = title_editor.read(cx).text(cx);
+                thread.update(cx, |thread, cx| {
+                    thread
+                        .set_title(new_title.into(), cx)
+                        .detach_and_log_err(cx);
+                })
+            }
+            EditorEvent::Blurred => {
+                if title_editor.read(cx).text(cx).is_empty() {
+                    title_editor.update(cx, |editor, cx| {
+                        editor.set_text("New Thread", window, cx);
+                    });
+                }
+            }
+            _ => {}
+        }
+    }
+
+    pub fn cancel_editing(
+        &mut self,
+        focus_handle: FocusHandle,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        if let Some(index) = self.editing_message.take()
+            && let Some(editor) = &self
+                .entry_view_state
+                .read(cx)
+                .entry(index)
+                .and_then(|e| e.message_editor())
+                .cloned()
+        {
+            editor.update(cx, |editor, cx| {
+                if let Some(user_message) = self
+                    .thread
+                    .read(cx)
+                    .entries()
+                    .get(index)
+                    .and_then(|e| e.user_message())
+                {
+                    editor.set_message(user_message.chunks.clone(), window, cx);
+                }
+            })
+        };
+        focus_handle.focus(window, cx);
+        cx.notify();
+    }
+
+    // tool permissions
+
+    pub fn authorize_tool_call(
+        &mut self,
+        tool_call_id: acp::ToolCallId,
+        option_id: acp::PermissionOptionId,
+        option_kind: acp::PermissionOptionKind,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let thread = &self.thread;
+        let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
+
+        telemetry::event!(
+            "Agent Tool Call Authorized",
+            agent = agent_telemetry_id,
+            session = thread.read(cx).session_id(),
+            option = option_kind
+        );
+
+        thread.update(cx, |thread, cx| {
+            thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
+        });
+        if self.should_be_following {
+            self.workspace
+                .update(cx, |workspace, cx| {
+                    workspace.follow(CollaboratorId::Agent, window, cx);
+                })
+                .ok();
+        }
+        cx.notify();
+    }
+
+    pub fn authorize_pending_tool_call(
+        &mut self,
+        kind: acp::PermissionOptionKind,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) -> Option<()> {
+        let thread = self.thread.read(cx);
+        let tool_call = thread.first_tool_awaiting_confirmation()?;
+        let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
+            return None;
+        };
+        let option = options.first_option_of_kind(kind)?;
+
+        self.authorize_tool_call(
+            tool_call.id.clone(),
+            option.option_id.clone(),
+            option.kind,
+            window,
+            cx,
+        );
+
+        Some(())
+    }
+
+    pub fn handle_select_permission_granularity(
+        &mut self,
+        action: &SelectPermissionGranularity,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
+        self.selected_permission_granularity
+            .insert(tool_call_id, action.index);
+
+        cx.notify();
+    }
+
+    // edits
+
+    pub fn keep_all(&mut self, cx: &mut Context<AcpThreadView>) {
+        let thread = &self.thread;
+        let telemetry = ActionLogTelemetry::from(thread.read(cx));
+        let action_log = thread.read(cx).action_log().clone();
+        action_log.update(cx, |action_log, cx| {
+            action_log.keep_all_edits(Some(telemetry), cx)
+        });
+    }
+
+    pub fn reject_all(&mut self, cx: &mut Context<AcpThreadView>) {
+        let thread = &self.thread;
+        let telemetry = ActionLogTelemetry::from(thread.read(cx));
+        let action_log = thread.read(cx).action_log().clone();
+        action_log
+            .update(cx, |action_log, cx| {
+                action_log.reject_all_edits(Some(telemetry), cx)
+            })
+            .detach();
+    }
+
+    pub fn open_edited_buffer(
+        &mut self,
+        buffer: &Entity<Buffer>,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        let thread = &self.thread;
+
+        let Some(diff) =
+            AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
+        else {
+            return;
+        };
+
+        diff.update(cx, |diff, cx| {
+            diff.move_to_path(PathKey::for_buffer(buffer, cx), window, cx)
+        })
+    }
+
+    // thread stuff
+
+    pub fn sync_thread(
+        &mut self,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        if !self.is_imported_thread(cx) {
+            return;
+        }
+
+        let Some(session_list) = self
+            .as_native_connection(cx)
+            .and_then(|connection| connection.session_list(cx))
+            .and_then(|list| list.downcast::<NativeAgentSessionList>())
+        else {
+            return;
+        };
+        let thread_store = session_list.thread_store().clone();
+
+        let client = project.read(cx).client();
+        let session_id = self.thread.read(cx).session_id().clone();
+
+        cx.spawn_in(window, async move |this, cx| {
+            let response = client
+                .request(proto::GetSharedAgentThread {
+                    session_id: session_id.to_string(),
+                })
+                .await?;
+
+            let shared_thread = SharedThread::from_bytes(&response.thread_data)?;
+
+            let db_thread = shared_thread.to_db_thread();
+
+            thread_store
+                .update(&mut cx.clone(), |store, cx| {
+                    store.save_thread(session_id.clone(), db_thread, cx)
+                })
+                .await?;
+
+            let thread_metadata = AgentSessionInfo {
+                session_id,
+                cwd: None,
+                title: Some(format!("🔗 {}", response.title).into()),
+                updated_at: Some(chrono::Utc::now()),
+                meta: None,
+            };
+
+            this.update_in(cx, |this, window, cx| {
+                if let ThreadState::Active(ActiveThreadState {
+                    resume_thread_metadata,
+                    ..
+                }) = &mut this.thread_state
+                {
+                    *resume_thread_metadata = Some(thread_metadata);
+                }
+                this.reset(window, cx);
+            })?;
+
+            this.update_in(cx, |this, _window, cx| {
+                if let Some(workspace) = this.workspace.upgrade() {
+                    workspace.update(cx, |workspace, cx| {
+                        struct ThreadSyncedToast;
+                        workspace.show_toast(
+                            Toast::new(
+                                NotificationId::unique::<ThreadSyncedToast>(),
+                                "Thread synced with latest version",
+                            )
+                            .autohide(),
+                            cx,
+                        );
+                    });
+                }
+            })?;
+
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
+
+    pub fn restore_checkpoint(
+        &mut self,
+        message_id: &UserMessageId,
+        cx: &mut Context<AcpThreadView>,
+    ) {
+        self.thread
+            .update(cx, |thread, cx| {
+                thread.restore_checkpoint(message_id.clone(), cx)
+            })
+            .detach_and_log_err(cx);
+    }
+
+    pub fn clear_thread_error(&mut self, cx: &mut Context<AcpThreadView>) {
+        self.thread_error = None;
+        self.thread_error_markdown = None;
+        self.token_limit_callout_dismissed = true;
+        cx.notify();
+    }
+
+    // other
+
+    pub fn refresh_cached_user_commands_from_registry(
+        &mut self,
+        registry: &Entity<SlashCommandRegistry>,
+        cx: &App,
+    ) {
+        let (mut commands, mut errors) = registry.read_with(cx, |registry, _| {
+            (registry.commands().clone(), registry.errors().to_vec())
+        });
+        let server_command_names = self
+            .available_commands
+            .borrow()
+            .iter()
+            .map(|command| command.name.clone())
+            .collect::<HashSet<_>>();
+        user_slash_command::apply_server_command_conflicts_to_map(
+            &mut commands,
+            &mut errors,
+            &server_command_names,
+        );
+
+        self.command_load_errors_dismissed = false;
+        *self.cached_user_commands.borrow_mut() = commands;
+        *self.cached_user_command_errors.borrow_mut() = errors;
+    }
+
+    pub fn render_command_load_errors(
+        &self,
+        cx: &mut Context<AcpThreadView>,
+    ) -> Option<impl IntoElement> {
+        let errors = self.cached_user_command_errors.borrow();
+
+        if self.command_load_errors_dismissed || errors.is_empty() {
+            return None;
+        }
+
+        let workspace = self.workspace.clone();
+
+        let error_count = errors.len();
+        let title = if error_count == 1 {
+            "Failed to load slash command"
+        } else {
+            "Failed to load slash commands"
+        };
+
+        Some(
+            v_flex()
+                .w_full()
+                .p_2()
+                .gap_1()
+                .border_t_1()
+                .border_color(cx.theme().colors().border)
+                .bg(cx.theme().colors().surface_background)
+                .child(
+                    h_flex()
+                        .justify_between()
+                        .child(
+                            h_flex()
+                                .gap_1()
+                                .child(
+                                    Icon::new(IconName::Warning)
+                                        .size(IconSize::Small)
+                                        .color(Color::Warning),
+                                )
+                                .child(
+                                    Label::new(title)
+                                        .size(LabelSize::Small)
+                                        .color(Color::Warning),
+                                ),
+                        )
+                        .child(
+                            IconButton::new("dismiss-command-errors", IconName::Close)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted)
+                                .tooltip(Tooltip::text("Dismiss"))
+                                .on_click(cx.listener(|this, _, _, cx| {
+                                    this.clear_command_load_errors(cx);
+                                })),
+                        ),
+                )
+                .children(errors.iter().enumerate().map({
+                    move |(i, error)| {
+                        let path = error.path.clone();
+                        let workspace = workspace.clone();
+                        let file_name = error
+                            .path
+                            .file_name()
+                            .map(|n| n.to_string_lossy().to_string())
+                            .unwrap_or_else(|| error.path.display().to_string());
+
+                        h_flex()
+                            .id(ElementId::Name(format!("command-error-{i}").into()))
+                            .gap_1()
+                            .px_1()
+                            .py_0p5()
+                            .rounded_sm()
+                            .cursor_pointer()
+                            .hover(|style| style.bg(cx.theme().colors().element_hover))
+                            .tooltip(Tooltip::text(format!(
+                                "Click to open {}\n\n{}",
+                                error.path.display(),
+                                error.message
+                            )))
+                            .on_click({
+                                move |_, window, cx| {
+                                    if let Some(workspace) = workspace.upgrade() {
+                                        workspace.update(cx, |workspace, cx| {
+                                            workspace
+                                                .open_abs_path(
+                                                    path.clone(),
+                                                    OpenOptions::default(),
+                                                    window,
+                                                    cx,
+                                                )
+                                                .detach_and_log_err(cx);
+                                        });
+                                    }
+                                }
+                            })
+                            .child(
+                                Label::new(format!("• {}: {}", file_name, error.message))
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                    }
+                })),
+        )
+    }
+
+    pub fn render_thread_retry_status_callout(&self) -> Option<Callout> {
+        let state = self.thread_retry_status.as_ref()?;
+
+        let next_attempt_in = state
+            .duration
+            .saturating_sub(Instant::now().saturating_duration_since(state.started_at));
+        if next_attempt_in.is_zero() {
+            return None;
+        }
+
+        let next_attempt_in_secs = next_attempt_in.as_secs() + 1;
+
+        let retry_message = if state.max_attempts == 1 {
+            if next_attempt_in_secs == 1 {
+                "Retrying. Next attempt in 1 second.".to_string()
+            } else {
+                format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.")
+            }
+        } else if next_attempt_in_secs == 1 {
+            format!(
+                "Retrying. Next attempt in 1 second (Attempt {} of {}).",
+                state.attempt, state.max_attempts,
+            )
+        } else {
+            format!(
+                "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).",
+                state.attempt, state.max_attempts,
+            )
+        };
+
+        Some(
+            Callout::new()
+                .severity(Severity::Warning)
+                .title(state.last_error.clone())
+                .description(retry_message),
+        )
+    }
+
+    pub fn handle_open_rules(&mut self, window: &mut Window, cx: &mut Context<AcpThreadView>) {
+        let Some(thread) = self.as_native_thread(cx) else {
+            return;
+        };
+        let project_context = thread.read(cx).project_context().read(cx);
+
+        let project_entry_ids = project_context
+            .worktrees
+            .iter()
+            .flat_map(|worktree| worktree.rules_file.as_ref())
+            .map(|rules_file| ProjectEntryId::from_usize(rules_file.project_entry_id))
+            .collect::<Vec<_>>();
+
+        self.workspace
+            .update(cx, move |workspace, cx| {
+                // TODO: Open a multibuffer instead? In some cases this doesn't make the set of rules
+                // files clear. For example, if rules file 1 is already open but rules file 2 is not,
+                // this would open and focus rules file 2 in a tab that is not next to rules file 1.
+                let project = workspace.project().read(cx);
+                let project_paths = project_entry_ids
+                    .into_iter()
+                    .flat_map(|entry_id| project.path_for_entry(entry_id, cx))
+                    .collect::<Vec<_>>();
+                for project_path in project_paths {
+                    workspace
+                        .open_path(project_path, None, true, window, cx)
+                        .detach_and_log_err(cx);
+                }
+            })
+            .ok();
+    }
+}

crates/agent_ui/src/agent_panel.rs 🔗

@@ -149,7 +149,12 @@ pub fn init(cx: &mut App) {
                     let thread = workspace
                         .panel::<AgentPanel>(cx)
                         .and_then(|panel| panel.read(cx).active_thread_view().cloned())
-                        .and_then(|thread_view| thread_view.read(cx).thread().cloned());
+                        .and_then(|thread_view| {
+                            thread_view
+                                .read(cx)
+                                .as_active_thread()
+                                .map(|r| r.thread.clone())
+                        });
 
                     if let Some(thread) = thread {
                         AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
@@ -1221,9 +1226,10 @@ impl AgentPanel {
 
     pub(crate) fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
         match &self.active_view {
-            ActiveView::ExternalAgentThread { thread_view, .. } => {
-                thread_view.read(cx).thread().cloned()
-            }
+            ActiveView::ExternalAgentThread { thread_view, .. } => thread_view
+                .read(cx)
+                .as_active_thread()
+                .map(|r| r.thread.clone()),
             _ => None,
         }
     }
@@ -1638,7 +1644,11 @@ impl AgentPanel {
                     .as_native_thread(cx)
                     .map_or(false, |t| t.read(cx).is_generating_title());
 
-                if let Some(title_editor) = thread_view.read(cx).title_editor() {
+                if let Some(title_editor) = thread_view
+                    .read(cx)
+                    .as_active_thread()
+                    .and_then(|ready| ready.title_editor.clone())
+                {
                     let container = div()
                         .w_full()
                         .on_action({

crates/agent_ui/src/profile_selector.rs 🔗

@@ -237,7 +237,7 @@ enum ProfilePickerEntry {
     Profile(ProfileMatchEntry),
 }
 
-pub(crate) struct ProfilePickerDelegate {
+pub struct ProfilePickerDelegate {
     fs: Arc<dyn Fs>,
     provider: Arc<dyn ProfileProvider>,
     foreground: ForegroundExecutor,

crates/agent_ui_v2/src/agent_thread_pane.rs 🔗

@@ -142,8 +142,8 @@ impl AgentThreadPane {
     fn title(&self, cx: &App) -> SharedString {
         if let Some(active_thread_view) = &self.thread_view {
             let thread_view = active_thread_view.view.read(cx);
-            if let Some(thread) = thread_view.thread() {
-                let title = thread.read(cx).title();
+            if let Some(ready) = thread_view.as_active_thread() {
+                let title = ready.thread.read(cx).title();
                 if !title.is_empty() {
                     return title;
                 }

crates/gpui/src/elements/div.rs 🔗

@@ -2011,7 +2011,7 @@ impl Interactivity {
                         origin: hitbox.origin,
                         size: text.size(FONT_SIZE),
                     };
-                    if self.source_location.is_some()
+                    if let Some(source_location) = self.source_location
                         && text_bounds.contains(&window.mouse_position())
                         && window.modifiers().secondary()
                     {
@@ -2042,7 +2042,6 @@ impl Interactivity {
 
                         window.on_mouse_event({
                             let hitbox = hitbox.clone();
-                            let location = self.source_location.unwrap();
                             move |e: &crate::MouseDownEvent, phase, window, cx| {
                                 if text_bounds.contains(&e.position)
                                     && phase.capture()
@@ -2055,9 +2054,9 @@ impl Interactivity {
 
                                     eprintln!(
                                         "This element was created at:\n{}:{}:{}",
-                                        dir.join(location.file()).to_string_lossy(),
-                                        location.line(),
-                                        location.column()
+                                        dir.join(source_location.file()).to_string_lossy(),
+                                        source_location.line(),
+                                        source_location.column()
                                     );
                                 }
                             }

crates/zed/src/visual_test_runner.rs 🔗

@@ -1806,7 +1806,12 @@ fn run_subagent_visual_tests(
         .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
 
     let thread = cx
-        .read(|cx| thread_view.read(cx).thread().cloned())
+        .read(|cx| {
+            thread_view
+                .read(cx)
+                .as_active_thread()
+                .map(|active| active.thread.clone())
+        })
         .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
 
     // Send the message to trigger the subagent response
@@ -2211,7 +2216,12 @@ fn run_agent_thread_view_test(
         .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
 
     let thread = cx
-        .read(|cx| thread_view.read(cx).thread().cloned())
+        .read(|cx| {
+            thread_view
+                .read(cx)
+                .as_active_thread()
+                .map(|active| active.thread.clone())
+        })
         .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
 
     // Send the message to trigger the image response