conversation_view.rs

   1use acp_thread::{
   2    AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk,
   3    AuthRequired, LoadError, MaxOutputTokensError, MentionUri, PermissionOptionChoice,
   4    PermissionOptions, PermissionPattern, RetryStatus, SelectedPermissionOutcome, ThreadStatus,
   5    ToolCall, ToolCallContent, ToolCallStatus, UserMessageId,
   6};
   7use acp_thread::{AgentConnection, Plan};
   8use action_log::{ActionLog, ActionLogTelemetry, DiffStats};
   9use agent::{
  10    NativeAgentServer, NativeAgentSessionList, NoModelConfiguredError, SharedThread, ThreadStore,
  11};
  12use agent_client_protocol::schema as acp;
  13#[cfg(test)]
  14use agent_servers::AgentServerDelegate;
  15use agent_servers::{AgentServer, GEMINI_TERMINAL_AUTH_METHOD_ID};
  16use agent_settings::{AgentProfileId, AgentSettings};
  17use anyhow::{Result, anyhow};
  18#[cfg(feature = "audio")]
  19use audio::{Audio, Sound};
  20use buffer_diff::BufferDiff;
  21use client::zed_urls;
  22use collections::{HashMap, HashSet, IndexMap};
  23use editor::scroll::Autoscroll;
  24use editor::{
  25    Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
  26};
  27use feature_flags::{AgentSharingFeatureFlag, FeatureFlagAppExt as _};
  28use file_icons::FileIcons;
  29use fs::Fs;
  30use futures::FutureExt as _;
  31use gpui::{
  32    Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle,
  33    ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState,
  34    ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle,
  35    WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient,
  36    list, point, pulsating_between,
  37};
  38use language::Buffer;
  39use language_model::{LanguageModelCompletionError, LanguageModelRegistry};
  40use markdown::{
  41    CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle,
  42};
  43use parking_lot::RwLock;
  44use project::{AgentId, AgentServerStore, Project, ProjectEntryId};
  45use prompt_store::{PromptId, PromptStore};
  46
  47use crate::DEFAULT_THREAD_TITLE;
  48use crate::message_editor::SessionCapabilities;
  49use rope::Point;
  50use settings::{
  51    NotifyWhenAgentWaiting, Settings as _, SettingsStore, SidebarSide, ThinkingBlockDisplay,
  52};
  53use std::path::Path;
  54use std::sync::Arc;
  55use std::time::Instant;
  56use std::{collections::BTreeMap, rc::Rc, time::Duration};
  57use terminal_view::terminal_panel::TerminalPanel;
  58use text::Anchor;
  59use theme_settings::{AgentBufferFontSize, AgentUiFontSize};
  60use ui::{
  61    Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton,
  62    DecoratedIcon, DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind,
  63    KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
  64    right_click_menu,
  65};
  66use util::{ResultExt, size::format_file_size, time::duration_alt_display};
  67use util::{debug_panic, defer};
  68use workspace::PathList;
  69use workspace::{
  70    CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
  71};
  72use zed_actions::agent::{Chat, ToggleModelSelector};
  73use zed_actions::assistant::OpenRulesLibrary;
  74
  75use super::config_options::ConfigOptionsView;
  76use super::entry_view_state::EntryViewState;
  77use crate::ModeSelector;
  78use crate::ModelSelectorPopover;
  79use crate::agent_connection_store::{
  80    AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore,
  81};
  82use crate::agent_diff::AgentDiff;
  83use crate::entry_view_state::{EntryViewEvent, ViewEvent};
  84use crate::message_editor::{MessageEditor, MessageEditorEvent};
  85use crate::profile_selector::{ProfileProvider, ProfileSelector};
  86
  87use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
  88use crate::ui::{AgentNotification, AgentNotificationEvent};
  89use crate::{
  90    Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AgentPanelEvent, AllowAlways, AllowOnce,
  91    AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector,
  92    CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread,
  93    OpenAddContextMenu, OpenAgentDiff, RejectAll, RejectOnce, RemoveFirstQueuedMessage,
  94    ScrollOutputLineDown, ScrollOutputLineUp, ScrollOutputPageDown, ScrollOutputPageUp,
  95    ScrollOutputToBottom, ScrollOutputToNextMessage, ScrollOutputToPreviousMessage,
  96    ScrollOutputToTop, SendImmediately, SendNextQueuedMessage, ToggleFastMode,
  97    ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject,
  98};
  99
 100const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
 101const TOKEN_THRESHOLD: u64 = 250;
 102
 103mod thread_view;
 104pub use thread_view::*;
 105
 106pub struct QueuedMessage {
 107    pub content: Vec<acp::ContentBlock>,
 108    pub tracked_buffers: Vec<Entity<Buffer>>,
 109}
 110
 111#[derive(Copy, Clone, Debug, PartialEq, Eq)]
 112enum ThreadFeedback {
 113    Positive,
 114    Negative,
 115}
 116
 117#[derive(Debug)]
 118pub(crate) enum ThreadError {
 119    PaymentRequired,
 120    Refusal,
 121    AuthenticationRequired(SharedString),
 122    RateLimitExceeded {
 123        provider: SharedString,
 124    },
 125    ServerOverloaded {
 126        provider: SharedString,
 127    },
 128    PromptTooLarge,
 129    NoApiKey {
 130        provider: SharedString,
 131    },
 132    StreamError {
 133        provider: SharedString,
 134    },
 135    InvalidApiKey {
 136        provider: SharedString,
 137    },
 138    PermissionDenied {
 139        provider: SharedString,
 140    },
 141    RequestFailed,
 142    MaxOutputTokens,
 143    NoModelSelected,
 144    ApiError {
 145        provider: SharedString,
 146    },
 147    Other {
 148        message: SharedString,
 149        acp_error_code: Option<SharedString>,
 150    },
 151}
 152
 153impl From<anyhow::Error> for ThreadError {
 154    fn from(error: anyhow::Error) -> Self {
 155        if error.is::<MaxOutputTokensError>() {
 156            Self::MaxOutputTokens
 157        } else if error.is::<NoModelConfiguredError>() {
 158            Self::NoModelSelected
 159        } else if error.is::<language_model::PaymentRequiredError>() {
 160            Self::PaymentRequired
 161        } else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
 162            && acp_error.code == acp::ErrorCode::AuthRequired
 163        {
 164            Self::AuthenticationRequired(acp_error.message.clone().into())
 165        } else if let Some(lm_error) = error.downcast_ref::<LanguageModelCompletionError>() {
 166            use LanguageModelCompletionError::*;
 167            match lm_error {
 168                RateLimitExceeded { provider, .. } => Self::RateLimitExceeded {
 169                    provider: provider.to_string().into(),
 170                },
 171                ServerOverloaded { provider, .. } | ApiInternalServerError { provider, .. } => {
 172                    Self::ServerOverloaded {
 173                        provider: provider.to_string().into(),
 174                    }
 175                }
 176                PromptTooLarge { .. } => Self::PromptTooLarge,
 177                NoApiKey { provider } => Self::NoApiKey {
 178                    provider: provider.to_string().into(),
 179                },
 180                StreamEndedUnexpectedly { provider }
 181                | ApiReadResponseError { provider, .. }
 182                | DeserializeResponse { provider, .. }
 183                | HttpSend { provider, .. } => Self::StreamError {
 184                    provider: provider.to_string().into(),
 185                },
 186                AuthenticationError { provider, .. } => Self::InvalidApiKey {
 187                    provider: provider.to_string().into(),
 188                },
 189                PermissionError { provider, .. } => Self::PermissionDenied {
 190                    provider: provider.to_string().into(),
 191                },
 192                UpstreamProviderError { .. } => Self::RequestFailed,
 193                BadRequestFormat { provider, .. }
 194                | HttpResponseError { provider, .. }
 195                | ApiEndpointNotFound { provider } => Self::ApiError {
 196                    provider: provider.to_string().into(),
 197                },
 198                _ => {
 199                    let message: SharedString = format!("{:#}", error).into();
 200                    Self::Other {
 201                        message,
 202                        acp_error_code: None,
 203                    }
 204                }
 205            }
 206        } else {
 207            let message: SharedString = format!("{:#}", error).into();
 208
 209            // Extract ACP error code if available
 210            let acp_error_code = error
 211                .downcast_ref::<acp::Error>()
 212                .map(|acp_error| SharedString::from(acp_error.code.to_string()));
 213
 214            Self::Other {
 215                message,
 216                acp_error_code,
 217            }
 218        }
 219    }
 220}
 221
 222impl ProfileProvider for Entity<agent::Thread> {
 223    fn profile_id(&self, cx: &App) -> AgentProfileId {
 224        self.read(cx).profile().clone()
 225    }
 226
 227    fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
 228        self.update(cx, |thread, cx| {
 229            // Apply the profile and let the thread swap to its default model.
 230            thread.set_profile(profile_id, cx);
 231        });
 232    }
 233
 234    fn profiles_supported(&self, cx: &App) -> bool {
 235        self.read(cx)
 236            .model()
 237            .is_some_and(|model| model.supports_tools())
 238    }
 239
 240    fn model_selected(&self, cx: &App) -> bool {
 241        self.read(cx).model().is_some()
 242    }
 243}
 244
 245#[derive(Default)]
 246pub(crate) struct Conversation {
 247    threads: HashMap<acp::SessionId, Entity<AcpThread>>,
 248    permission_requests: IndexMap<acp::SessionId, Vec<acp::ToolCallId>>,
 249    subscriptions: Vec<Subscription>,
 250    updated_at: Option<Instant>,
 251}
 252
 253impl Conversation {
 254    pub fn register_thread(&mut self, thread: Entity<AcpThread>, cx: &mut Context<Self>) {
 255        let session_id = thread.read(cx).session_id().clone();
 256        let subscription = cx.subscribe(&thread, {
 257            let session_id = session_id.clone();
 258            move |this, _thread, event, _cx| {
 259                this.updated_at = Some(Instant::now());
 260                match event {
 261                    AcpThreadEvent::ToolAuthorizationRequested(id) => {
 262                        this.permission_requests
 263                            .entry(session_id.clone())
 264                            .or_default()
 265                            .push(id.clone());
 266                    }
 267                    AcpThreadEvent::ToolAuthorizationReceived(id) => {
 268                        if let Some(tool_calls) = this.permission_requests.get_mut(&session_id) {
 269                            tool_calls.retain(|tool_call_id| tool_call_id != id);
 270                            if tool_calls.is_empty() {
 271                                this.permission_requests.shift_remove(&session_id);
 272                            }
 273                        }
 274                    }
 275                    AcpThreadEvent::NewEntry
 276                    | AcpThreadEvent::TitleUpdated
 277                    | AcpThreadEvent::TokenUsageUpdated
 278                    | AcpThreadEvent::EntryUpdated(_)
 279                    | AcpThreadEvent::EntriesRemoved(_)
 280                    | AcpThreadEvent::Retry(_)
 281                    | AcpThreadEvent::SubagentSpawned(_)
 282                    | AcpThreadEvent::Stopped(_)
 283                    | AcpThreadEvent::Error
 284                    | AcpThreadEvent::LoadError(_)
 285                    | AcpThreadEvent::PromptCapabilitiesUpdated
 286                    | AcpThreadEvent::Refusal
 287                    | AcpThreadEvent::AvailableCommandsUpdated(_)
 288                    | AcpThreadEvent::ModeUpdated(_)
 289                    | AcpThreadEvent::ConfigOptionsUpdated(_)
 290                    | AcpThreadEvent::WorkingDirectoriesUpdated
 291                    | AcpThreadEvent::PromptUpdated => {}
 292                }
 293            }
 294        });
 295        self.subscriptions.push(subscription);
 296        self.threads.insert(session_id, thread);
 297    }
 298
 299    pub fn permission_options_for_tool_call<'a>(
 300        &'a self,
 301        session_id: &acp::SessionId,
 302        tool_call_id: acp::ToolCallId,
 303        cx: &'a App,
 304    ) -> Option<&'a PermissionOptions> {
 305        let thread = self.threads.get(session_id)?;
 306        let (_, tool_call) = thread.read(cx).tool_call(&tool_call_id)?;
 307        let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
 308            return None;
 309        };
 310        Some(options)
 311    }
 312
 313    pub fn pending_tool_call<'a>(
 314        &'a self,
 315        session_id: &acp::SessionId,
 316        cx: &'a App,
 317    ) -> Option<(acp::SessionId, acp::ToolCallId, &'a PermissionOptions)> {
 318        let thread = self.threads.get(session_id)?;
 319        let is_subagent = thread.read(cx).parent_session_id().is_some();
 320        let (result_session_id, thread, tool_id) = if is_subagent {
 321            let id = self.permission_requests.get(session_id)?.iter().next()?;
 322            (session_id.clone(), thread, id)
 323        } else {
 324            let (id, tool_calls) = self.permission_requests.first()?;
 325            let thread = self.threads.get(id)?;
 326            let tool_id = tool_calls.iter().next()?;
 327            (id.clone(), thread, tool_id)
 328        };
 329        let (_, tool_call) = thread.read(cx).tool_call(tool_id)?;
 330
 331        let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
 332            return None;
 333        };
 334        Some((result_session_id, tool_id.clone(), options))
 335    }
 336
 337    pub fn subagents_awaiting_permission(&self, cx: &App) -> Vec<(acp::SessionId, usize)> {
 338        self.permission_requests
 339            .iter()
 340            .filter_map(|(session_id, tool_call_ids)| {
 341                let thread = self.threads.get(session_id)?;
 342                if thread.read(cx).parent_session_id().is_some() && !tool_call_ids.is_empty() {
 343                    Some((session_id.clone(), tool_call_ids.len()))
 344                } else {
 345                    None
 346                }
 347            })
 348            .collect()
 349    }
 350
 351    pub fn authorize_pending_tool_call(
 352        &mut self,
 353        session_id: &acp::SessionId,
 354        kind: acp::PermissionOptionKind,
 355        cx: &mut Context<Self>,
 356    ) -> Option<()> {
 357        let (authorize_session_id, tool_call_id, options) =
 358            self.pending_tool_call(session_id, cx)?;
 359        let option = options.first_option_of_kind(kind)?;
 360        self.authorize_tool_call(
 361            authorize_session_id,
 362            tool_call_id,
 363            SelectedPermissionOutcome::new(option.option_id.clone(), option.kind),
 364            cx,
 365        );
 366        Some(())
 367    }
 368
 369    pub fn authorize_with_granularity(
 370        &mut self,
 371        session_id: acp::SessionId,
 372        tool_call_id: acp::ToolCallId,
 373        selection: Option<&thread_view::PermissionSelection>,
 374        is_allow: bool,
 375        cx: &mut Context<Self>,
 376    ) -> Option<()> {
 377        let options =
 378            self.permission_options_for_tool_call(&session_id, tool_call_id.clone(), cx)?;
 379        let outcome = resolve_outcome_from_selection(options, selection, is_allow)?;
 380        self.authorize_tool_call(session_id, tool_call_id, outcome, cx);
 381        Some(())
 382    }
 383
 384    pub fn authorize_tool_call(
 385        &mut self,
 386        session_id: acp::SessionId,
 387        tool_call_id: acp::ToolCallId,
 388        outcome: SelectedPermissionOutcome,
 389        cx: &mut Context<Self>,
 390    ) {
 391        let Some(thread) = self.threads.get(&session_id) else {
 392            return;
 393        };
 394        let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
 395        let session_id = thread.read(cx).session_id().clone();
 396
 397        telemetry::event!(
 398            "Agent Tool Call Authorized",
 399            agent = agent_telemetry_id,
 400            session = session_id,
 401            option = outcome.option_kind
 402        );
 403
 404        thread.update(cx, |thread, cx| {
 405            thread.authorize_tool_call(tool_call_id, outcome, cx);
 406        });
 407        cx.notify();
 408    }
 409
 410    fn set_work_dirs(&mut self, work_dirs: PathList, cx: &mut Context<Self>) {
 411        for thread in self.threads.values() {
 412            thread.update(cx, |thread, cx| {
 413                thread.set_work_dirs(work_dirs.clone(), cx);
 414            });
 415        }
 416    }
 417}
 418
 419pub(crate) struct RootThreadUpdated;
 420
 421impl EventEmitter<RootThreadUpdated> for ConversationView {}
 422
 423fn resolve_outcome_from_selection(
 424    options: &PermissionOptions,
 425    selection: Option<&thread_view::PermissionSelection>,
 426    is_allow: bool,
 427) -> Option<SelectedPermissionOutcome> {
 428    let choices = match options {
 429        PermissionOptions::Dropdown(choices) => choices.as_slice(),
 430        PermissionOptions::DropdownWithPatterns { choices, .. } => choices.as_slice(),
 431        PermissionOptions::Flat(_) => {
 432            let kind = if is_allow {
 433                acp::PermissionOptionKind::AllowOnce
 434            } else {
 435                acp::PermissionOptionKind::RejectOnce
 436            };
 437            let option = options.first_option_of_kind(kind)?;
 438            return Some(SelectedPermissionOutcome::new(
 439                option.option_id.clone(),
 440                option.kind,
 441            ));
 442        }
 443    };
 444
 445    // When in per-command pattern mode, use the checked patterns.
 446    if let Some(thread_view::PermissionSelection::SelectedPatterns(checked)) = selection {
 447        if let Some(outcome) = options.build_outcome_for_checked_patterns(checked, is_allow) {
 448            return Some(outcome);
 449        }
 450    }
 451
 452    // Use the selected granularity choice ("Always for terminal" or "Only this time").
 453    let selected_index = selection
 454        .and_then(|s| s.choice_index())
 455        .unwrap_or_else(|| choices.len().saturating_sub(1));
 456    let selected_choice = choices.get(selected_index).or(choices.last())?;
 457    Some(selected_choice.build_outcome(is_allow))
 458}
 459
 460fn affects_thread_metadata(event: &AcpThreadEvent) -> bool {
 461    match event {
 462        AcpThreadEvent::NewEntry
 463        | AcpThreadEvent::TitleUpdated
 464        | AcpThreadEvent::ToolAuthorizationRequested(_)
 465        | AcpThreadEvent::ToolAuthorizationReceived(_)
 466        | AcpThreadEvent::Stopped(_)
 467        | AcpThreadEvent::Error
 468        | AcpThreadEvent::LoadError(_)
 469        | AcpThreadEvent::Refusal
 470        | AcpThreadEvent::WorkingDirectoriesUpdated => true,
 471        // --
 472        AcpThreadEvent::EntryUpdated(_)
 473        | AcpThreadEvent::EntriesRemoved(_)
 474        | AcpThreadEvent::Retry(_)
 475        | AcpThreadEvent::TokenUsageUpdated
 476        | AcpThreadEvent::PromptCapabilitiesUpdated
 477        | AcpThreadEvent::AvailableCommandsUpdated(_)
 478        | AcpThreadEvent::ModeUpdated(_)
 479        | AcpThreadEvent::ConfigOptionsUpdated(_)
 480        | AcpThreadEvent::SubagentSpawned(_)
 481        | AcpThreadEvent::PromptUpdated => false,
 482    }
 483}
 484
 485pub enum AcpServerViewEvent {
 486    ActiveThreadChanged,
 487}
 488
 489impl EventEmitter<AcpServerViewEvent> for ConversationView {}
 490
 491pub struct ConversationView {
 492    agent: Rc<dyn AgentServer>,
 493    connection_store: Entity<AgentConnectionStore>,
 494    connection_key: Agent,
 495    agent_server_store: Entity<AgentServerStore>,
 496    workspace: WeakEntity<Workspace>,
 497    project: Entity<Project>,
 498    thread_store: Option<Entity<ThreadStore>>,
 499    prompt_store: Option<Entity<PromptStore>>,
 500    pub(crate) thread_id: ThreadId,
 501    pub(crate) root_session_id: Option<acp::SessionId>,
 502    server_state: ServerState,
 503    focus_handle: FocusHandle,
 504    notifications: Vec<WindowHandle<AgentNotification>>,
 505    notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
 506    auth_task: Option<Task<()>>,
 507    _subscriptions: Vec<Subscription>,
 508}
 509
 510impl ConversationView {
 511    pub fn has_auth_methods(&self) -> bool {
 512        self.as_connected().map_or(false, |connected| {
 513            !connected.connection.auth_methods().is_empty()
 514        })
 515    }
 516
 517    pub fn active_thread(&self) -> Option<&Entity<ThreadView>> {
 518        match &self.server_state {
 519            ServerState::Connected(connected) => connected.active_view(),
 520            _ => None,
 521        }
 522    }
 523
 524    pub fn pending_tool_call<'a>(
 525        &'a self,
 526        cx: &'a App,
 527    ) -> Option<(acp::SessionId, acp::ToolCallId, &'a PermissionOptions)> {
 528        let session_id = self.active_thread()?.read(cx).session_id.clone();
 529        self.as_connected()?
 530            .conversation
 531            .read(cx)
 532            .pending_tool_call(&session_id, cx)
 533    }
 534
 535    pub fn root_thread_has_pending_tool_call(&self, cx: &App) -> bool {
 536        let Some(root_thread) = self.root_thread_view() else {
 537            return false;
 538        };
 539        let root_session_id = root_thread.read(cx).thread.read(cx).session_id().clone();
 540        self.as_connected().is_some_and(|connected| {
 541            connected
 542                .conversation
 543                .read(cx)
 544                .pending_tool_call(&root_session_id, cx)
 545                .is_some()
 546        })
 547    }
 548
 549    pub(crate) fn root_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
 550        self.root_thread_view()
 551            .map(|view| view.read(cx).thread.clone())
 552    }
 553
 554    pub fn root_thread_view(&self) -> Option<Entity<ThreadView>> {
 555        self.root_session_id
 556            .as_ref()
 557            .and_then(|id| self.thread_view(id))
 558    }
 559
 560    pub fn thread_view(&self, session_id: &acp::SessionId) -> Option<Entity<ThreadView>> {
 561        let connected = self.as_connected()?;
 562        connected.threads.get(session_id).cloned()
 563    }
 564
 565    pub fn as_connected(&self) -> Option<&ConnectedServerState> {
 566        match &self.server_state {
 567            ServerState::Connected(connected) => Some(connected),
 568            _ => None,
 569        }
 570    }
 571
 572    pub fn as_connected_mut(&mut self) -> Option<&mut ConnectedServerState> {
 573        match &mut self.server_state {
 574            ServerState::Connected(connected) => Some(connected),
 575            _ => None,
 576        }
 577    }
 578
 579    pub fn updated_at(&self, cx: &App) -> Option<Instant> {
 580        self.as_connected()
 581            .and_then(|connected| connected.conversation.read(cx).updated_at)
 582    }
 583
 584    pub fn navigate_to_thread(
 585        &mut self,
 586        session_id: acp::SessionId,
 587        window: &mut Window,
 588        cx: &mut Context<Self>,
 589    ) {
 590        let Some(connected) = self.as_connected_mut() else {
 591            return;
 592        };
 593
 594        connected.navigate_to_thread(session_id);
 595        if let Some(view) = self.active_thread() {
 596            view.focus_handle(cx).focus(window, cx);
 597        }
 598        cx.emit(AcpServerViewEvent::ActiveThreadChanged);
 599        cx.notify();
 600    }
 601
 602    pub fn set_work_dirs(&mut self, work_dirs: PathList, cx: &mut Context<Self>) {
 603        if let Some(connected) = self.as_connected() {
 604            connected.conversation.update(cx, |conversation, cx| {
 605                conversation.set_work_dirs(work_dirs.clone(), cx);
 606            });
 607        }
 608    }
 609}
 610
 611enum ServerState {
 612    Loading { _loading: Entity<LoadingView> },
 613    LoadError { error: LoadError },
 614    Connected(ConnectedServerState),
 615}
 616
 617// current -> Entity
 618// hashmap of threads, current becomes session_id
 619pub struct ConnectedServerState {
 620    auth_state: AuthState,
 621    active_id: Option<acp::SessionId>,
 622    pub(crate) threads: HashMap<acp::SessionId, Entity<ThreadView>>,
 623    connection: Rc<dyn AgentConnection>,
 624    conversation: Entity<Conversation>,
 625    _connection_entry_subscription: Subscription,
 626}
 627
 628enum AuthState {
 629    Ok,
 630    Unauthenticated {
 631        description: Option<Entity<Markdown>>,
 632        configuration_view: Option<AnyView>,
 633        pending_auth_method: Option<acp::AuthMethodId>,
 634        _subscription: Option<Subscription>,
 635    },
 636}
 637
 638impl AuthState {
 639    pub fn is_ok(&self) -> bool {
 640        matches!(self, Self::Ok)
 641    }
 642}
 643
 644struct LoadingView {
 645    _load_task: Task<()>,
 646}
 647
 648impl ConnectedServerState {
 649    pub fn active_view(&self) -> Option<&Entity<ThreadView>> {
 650        self.active_id.as_ref().and_then(|id| self.threads.get(id))
 651    }
 652
 653    pub fn has_thread_error(&self, cx: &App) -> bool {
 654        self.active_view()
 655            .map_or(false, |view| view.read(cx).thread_error.is_some())
 656    }
 657
 658    pub fn navigate_to_thread(&mut self, session_id: acp::SessionId) {
 659        if self.threads.contains_key(&session_id) {
 660            self.active_id = Some(session_id);
 661        }
 662    }
 663
 664    pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> {
 665        let tasks = self.threads.values().filter_map(|view| {
 666            if self.connection.supports_close_session() {
 667                let session_id = view.read(cx).thread.read(cx).session_id().clone();
 668                Some(self.connection.clone().close_session(&session_id, cx))
 669            } else {
 670                None
 671            }
 672        });
 673        let task = futures::future::join_all(tasks);
 674        cx.background_spawn(async move {
 675            task.await;
 676        })
 677    }
 678}
 679
 680impl ConversationView {
 681    pub fn new(
 682        agent: Rc<dyn AgentServer>,
 683        connection_store: Entity<AgentConnectionStore>,
 684        connection_key: Agent,
 685        resume_session_id: Option<acp::SessionId>,
 686        thread_id: Option<ThreadId>,
 687        work_dirs: Option<PathList>,
 688        title: Option<SharedString>,
 689        initial_content: Option<AgentInitialContent>,
 690        workspace: WeakEntity<Workspace>,
 691        project: Entity<Project>,
 692        thread_store: Option<Entity<ThreadStore>>,
 693        prompt_store: Option<Entity<PromptStore>>,
 694        source: &'static str,
 695        window: &mut Window,
 696        cx: &mut Context<Self>,
 697    ) -> Self {
 698        let agent_server_store = project.read(cx).agent_server_store().clone();
 699        let subscriptions = vec![
 700            cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
 701            cx.observe_global_in::<AgentUiFontSize>(window, Self::agent_ui_font_size_changed),
 702            cx.observe_global_in::<AgentBufferFontSize>(window, Self::agent_ui_font_size_changed),
 703            cx.subscribe_in(
 704                &agent_server_store,
 705                window,
 706                Self::handle_agent_servers_updated,
 707            ),
 708        ];
 709
 710        cx.on_release(|this, cx| {
 711            if let Some(connected) = this.as_connected() {
 712                connected.close_all_sessions(cx).detach();
 713            }
 714            for window in this.notifications.drain(..) {
 715                window
 716                    .update(cx, |_, window, _| {
 717                        window.remove_window();
 718                    })
 719                    .ok();
 720            }
 721        })
 722        .detach();
 723
 724        let thread_id = thread_id.unwrap_or_else(ThreadId::new);
 725
 726        Self {
 727            agent: agent.clone(),
 728            connection_store: connection_store.clone(),
 729            connection_key: connection_key.clone(),
 730            agent_server_store,
 731            workspace,
 732            project: project.clone(),
 733            thread_store,
 734            prompt_store,
 735            thread_id,
 736            root_session_id: resume_session_id.clone(),
 737            server_state: Self::initial_state(
 738                agent.clone(),
 739                connection_store,
 740                connection_key,
 741                resume_session_id,
 742                work_dirs,
 743                title,
 744                project,
 745                initial_content,
 746                source,
 747                window,
 748                cx,
 749            ),
 750            notifications: Vec::new(),
 751            notification_subscriptions: HashMap::default(),
 752            auth_task: None,
 753            _subscriptions: subscriptions,
 754            focus_handle: cx.focus_handle(),
 755        }
 756    }
 757
 758    fn set_server_state(&mut self, state: ServerState, cx: &mut Context<Self>) {
 759        if let Some(connected) = self.as_connected() {
 760            connected.close_all_sessions(cx).detach();
 761        }
 762
 763        self.server_state = state;
 764        cx.emit(AcpServerViewEvent::ActiveThreadChanged);
 765        cx.notify();
 766    }
 767
 768    fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 769        let (resume_session_id, work_dirs, title) = self
 770            .root_thread_view()
 771            .map(|thread_view| {
 772                let tv = thread_view.read(cx);
 773                let thread = tv.thread.read(cx);
 774                (
 775                    Some(thread.session_id().clone()),
 776                    thread.work_dirs().cloned(),
 777                    thread.title(),
 778                )
 779            })
 780            .unwrap_or_else(|| {
 781                let session_id = self.root_session_id.clone();
 782                let (work_dirs, title) = session_id
 783                    .as_ref()
 784                    .and_then(|id| {
 785                        let store = ThreadMetadataStore::try_global(cx)?;
 786                        let entry = store.read(cx).entry_by_session(id)?;
 787                        Some((Some(entry.folder_paths().clone()), entry.title.clone()))
 788                    })
 789                    .unwrap_or((None, None));
 790                (session_id, work_dirs, title)
 791            });
 792
 793        let state = Self::initial_state(
 794            self.agent.clone(),
 795            self.connection_store.clone(),
 796            self.connection_key.clone(),
 797            resume_session_id,
 798            work_dirs,
 799            title,
 800            self.project.clone(),
 801            None,
 802            "agent_panel",
 803            window,
 804            cx,
 805        );
 806        self.set_server_state(state, cx);
 807
 808        if let Some(view) = self.root_thread_view() {
 809            view.update(cx, |this, cx| {
 810                this.message_editor.update(cx, |editor, cx| {
 811                    editor.set_session_capabilities(this.session_capabilities.clone(), cx);
 812                });
 813            });
 814        }
 815        cx.notify();
 816    }
 817
 818    fn initial_state(
 819        agent: Rc<dyn AgentServer>,
 820        connection_store: Entity<AgentConnectionStore>,
 821        connection_key: Agent,
 822        resume_session_id: Option<acp::SessionId>,
 823        work_dirs: Option<PathList>,
 824        title: Option<SharedString>,
 825        project: Entity<Project>,
 826        initial_content: Option<AgentInitialContent>,
 827        source: &'static str,
 828        window: &mut Window,
 829        cx: &mut Context<Self>,
 830    ) -> ServerState {
 831        if project.read(cx).is_via_collab()
 832            && agent.clone().downcast::<NativeAgentServer>().is_none()
 833        {
 834            return ServerState::LoadError {
 835                error: LoadError::Other(
 836                    "External agents are not yet supported in shared projects.".into(),
 837                ),
 838            };
 839        }
 840        let session_work_dirs = work_dirs.unwrap_or_else(|| project.read(cx).default_path_list(cx));
 841
 842        let connection_entry = connection_store.update(cx, |store, cx| {
 843            store.request_connection(connection_key, agent.clone(), cx)
 844        });
 845
 846        let connection_entry_subscription =
 847            cx.subscribe(&connection_entry, |this, _entry, event, cx| match event {
 848                AgentConnectionEntryEvent::NewVersionAvailable(version) => {
 849                    if let Some(thread) = this.root_thread_view() {
 850                        thread.update(cx, |thread, cx| {
 851                            thread.new_server_version_available = Some(version.clone());
 852                            cx.notify();
 853                        });
 854                    }
 855                }
 856            });
 857
 858        let connect_result = connection_entry.read(cx).wait_for_connection();
 859
 860        let side = match AgentSettings::get_global(cx).sidebar_side() {
 861            SidebarSide::Left => "left",
 862            SidebarSide::Right => "right",
 863        };
 864        let thread_location = "current_worktree";
 865
 866        let load_task = cx.spawn_in(window, async move |this, cx| {
 867            let connection = match connect_result.await {
 868                Ok(AgentConnectedState { connection, .. }) => connection,
 869                Err(err) => {
 870                    this.update_in(cx, |this, window, cx| {
 871                        this.handle_load_error(err, window, cx);
 872                        cx.notify();
 873                    })
 874                    .log_err();
 875                    return;
 876                }
 877            };
 878
 879            telemetry::event!(
 880                "Agent Thread Started",
 881                agent = connection.telemetry_id(),
 882                source = source,
 883                side = side,
 884                thread_location = thread_location
 885            );
 886
 887            let mut resumed_without_history = false;
 888            let result = if let Some(session_id) = resume_session_id.clone() {
 889                cx.update(|_, cx| {
 890                    if connection.supports_load_session() {
 891                        connection.clone().load_session(
 892                            session_id,
 893                            project.clone(),
 894                            session_work_dirs,
 895                            title,
 896                            cx,
 897                        )
 898                    } else if connection.supports_resume_session() {
 899                        resumed_without_history = true;
 900                        connection.clone().resume_session(
 901                            session_id,
 902                            project.clone(),
 903                            session_work_dirs,
 904                            title,
 905                            cx,
 906                        )
 907                    } else {
 908                        Task::ready(Err(anyhow!(LoadError::Other(
 909                            "Loading or resuming sessions is not supported by this agent.".into()
 910                        ))))
 911                    }
 912                })
 913                .log_err()
 914            } else {
 915                cx.update(|_, cx| {
 916                    connection
 917                        .clone()
 918                        .new_session(project.clone(), session_work_dirs, cx)
 919                })
 920                .log_err()
 921            };
 922
 923            let Some(result) = result else {
 924                return;
 925            };
 926
 927            let result = match result.await {
 928                Err(e) => match e.downcast::<acp_thread::AuthRequired>() {
 929                    Ok(err) => {
 930                        cx.update(|window, cx| {
 931                            Self::handle_auth_required(
 932                                this,
 933                                err,
 934                                agent.agent_id(),
 935                                connection,
 936                                window,
 937                                cx,
 938                            )
 939                        })
 940                        .log_err();
 941                        return;
 942                    }
 943                    Err(err) => Err(err),
 944                },
 945                Ok(thread) => Ok(thread),
 946            };
 947
 948            this.update_in(cx, |this, window, cx| {
 949                match result {
 950                    Ok(thread) => {
 951                        let root_session_id = thread.read(cx).session_id().clone();
 952
 953                        let conversation = cx.new(|cx| {
 954                            let mut conversation = Conversation::default();
 955                            conversation.register_thread(thread.clone(), cx);
 956                            conversation
 957                        });
 958
 959                        let current = this.new_thread_view(
 960                            thread,
 961                            conversation.clone(),
 962                            resumed_without_history,
 963                            initial_content,
 964                            window,
 965                            cx,
 966                        );
 967
 968                        if this.focus_handle.contains_focused(window, cx) {
 969                            current
 970                                .read(cx)
 971                                .message_editor
 972                                .focus_handle(cx)
 973                                .focus(window, cx);
 974                        }
 975
 976                        this.root_session_id = Some(root_session_id.clone());
 977                        this.set_server_state(
 978                            ServerState::Connected(ConnectedServerState {
 979                                connection,
 980                                auth_state: AuthState::Ok,
 981                                active_id: Some(root_session_id.clone()),
 982                                threads: HashMap::from_iter([(root_session_id, current)]),
 983                                conversation,
 984                                _connection_entry_subscription: connection_entry_subscription,
 985                            }),
 986                            cx,
 987                        );
 988                    }
 989                    Err(err) => {
 990                        this.handle_load_error(
 991                            LoadError::Other(err.to_string().into()),
 992                            window,
 993                            cx,
 994                        );
 995                    }
 996                };
 997            })
 998            .log_err();
 999        });
1000
1001        let loading_view = cx.new(|_cx| LoadingView {
1002            _load_task: load_task,
1003        });
1004
1005        ServerState::Loading {
1006            _loading: loading_view,
1007        }
1008    }
1009
1010    fn new_thread_view(
1011        &self,
1012        thread: Entity<AcpThread>,
1013        conversation: Entity<Conversation>,
1014        resumed_without_history: bool,
1015        initial_content: Option<AgentInitialContent>,
1016        window: &mut Window,
1017        cx: &mut Context<Self>,
1018    ) -> Entity<ThreadView> {
1019        let agent_id = self.agent.agent_id();
1020        let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
1021            thread.read(cx).prompt_capabilities(),
1022            thread.read(cx).available_commands().to_vec(),
1023        )));
1024
1025        let action_log = thread.read(cx).action_log().clone();
1026
1027        let entry_view_state = cx.new(|_| {
1028            EntryViewState::new(
1029                self.workspace.clone(),
1030                self.project.downgrade(),
1031                self.thread_store.clone(),
1032                self.prompt_store.clone(),
1033                session_capabilities.clone(),
1034                self.agent.agent_id(),
1035            )
1036        });
1037
1038        let count = thread.read(cx).entries().len();
1039        let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0));
1040        list_state.set_follow_mode(gpui::FollowMode::Tail);
1041
1042        entry_view_state.update(cx, |view_state, cx| {
1043            for ix in 0..count {
1044                view_state.sync_entry(ix, &thread, window, cx);
1045            }
1046            list_state.splice_focusable(
1047                0..0,
1048                (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
1049            );
1050        });
1051
1052        if let Some(scroll_position) = thread.read(cx).ui_scroll_position() {
1053            list_state.scroll_to(scroll_position);
1054        } else {
1055            list_state.scroll_to_end();
1056        }
1057
1058        AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx);
1059
1060        let connection = thread.read(cx).connection().clone();
1061        let session_id = thread.read(cx).session_id().clone();
1062
1063        // Check for config options first
1064        // Config options take precedence over legacy mode/model selectors
1065        // (feature flag gating happens at the data layer)
1066        let config_options_provider = connection.session_config_options(&session_id, cx);
1067
1068        let config_options_view;
1069        let mode_selector;
1070        let model_selector;
1071        if let Some(config_options) = config_options_provider {
1072            // Use config options - don't create mode_selector or model_selector
1073            let agent_server = self.agent.clone();
1074            let fs = self.project.read(cx).fs().clone();
1075            config_options_view =
1076                Some(cx.new(|cx| {
1077                    ConfigOptionsView::new(config_options, agent_server, fs, window, cx)
1078                }));
1079            model_selector = None;
1080            mode_selector = None;
1081        } else {
1082            // Fall back to legacy mode/model selectors
1083            config_options_view = None;
1084            model_selector = connection.model_selector(&session_id).map(|selector| {
1085                let agent_server = self.agent.clone();
1086                let fs = self.project.read(cx).fs().clone();
1087                cx.new(|cx| {
1088                    ModelSelectorPopover::new(
1089                        selector,
1090                        agent_server,
1091                        fs,
1092                        PopoverMenuHandle::default(),
1093                        self.focus_handle(cx),
1094                        window,
1095                        cx,
1096                    )
1097                })
1098            });
1099
1100            mode_selector = connection
1101                .session_modes(&session_id, cx)
1102                .map(|session_modes| {
1103                    let fs = self.project.read(cx).fs().clone();
1104                    cx.new(|_cx| ModeSelector::new(session_modes, self.agent.clone(), fs))
1105                });
1106        }
1107
1108        let subscriptions = vec![
1109            cx.subscribe_in(&thread, window, Self::handle_thread_event),
1110            cx.observe(&action_log, |_, _, cx| cx.notify()),
1111        ];
1112
1113        let subagent_sessions = thread
1114            .read(cx)
1115            .entries()
1116            .iter()
1117            .filter_map(|entry| match entry {
1118                AgentThreadEntry::ToolCall(call) => call
1119                    .subagent_session_info
1120                    .as_ref()
1121                    .map(|i| i.session_id.clone()),
1122                _ => None,
1123            })
1124            .collect::<Vec<_>>();
1125
1126        if !subagent_sessions.is_empty() {
1127            let parent_session_id = thread.read(cx).session_id().clone();
1128            cx.spawn_in(window, async move |this, cx| {
1129                this.update_in(cx, |this, window, cx| {
1130                    for subagent_id in subagent_sessions {
1131                        this.load_subagent_session(
1132                            subagent_id,
1133                            parent_session_id.clone(),
1134                            window,
1135                            cx,
1136                        );
1137                    }
1138                })
1139            })
1140            .detach();
1141        }
1142
1143        let profile_selector: Option<Rc<agent::NativeAgentConnection>> =
1144            connection.clone().downcast();
1145        let profile_selector = profile_selector
1146            .and_then(|native_connection| native_connection.thread(&session_id, cx))
1147            .map(|native_thread| {
1148                cx.new(|cx| {
1149                    ProfileSelector::new(
1150                        <dyn Fs>::global(cx),
1151                        Arc::new(native_thread),
1152                        self.focus_handle(cx),
1153                        cx,
1154                    )
1155                })
1156            });
1157
1158        let agent_display_name = self
1159            .agent_server_store
1160            .read(cx)
1161            .agent_display_name(&agent_id.clone())
1162            .unwrap_or_else(|| agent_id.0.clone());
1163
1164        let agent_icon = self.agent.logo();
1165        let agent_icon_from_external_svg = self
1166            .agent_server_store
1167            .read(cx)
1168            .agent_icon(&self.agent.agent_id())
1169            .or_else(|| {
1170                project::AgentRegistryStore::try_global(cx).and_then(|store| {
1171                    store
1172                        .read(cx)
1173                        .agent(&self.agent.agent_id())
1174                        .and_then(|a| a.icon_path().cloned())
1175                })
1176            });
1177
1178        let weak = cx.weak_entity();
1179        cx.new(|cx| {
1180            ThreadView::new(
1181                thread,
1182                conversation,
1183                weak,
1184                agent_icon,
1185                agent_icon_from_external_svg,
1186                agent_id,
1187                agent_display_name,
1188                self.workspace.clone(),
1189                entry_view_state,
1190                config_options_view,
1191                mode_selector,
1192                model_selector,
1193                profile_selector,
1194                list_state,
1195                session_capabilities,
1196                resumed_without_history,
1197                self.project.downgrade(),
1198                self.thread_store.clone(),
1199                self.prompt_store.clone(),
1200                initial_content,
1201                subscriptions,
1202                window,
1203                cx,
1204            )
1205        })
1206    }
1207
1208    fn handle_auth_required(
1209        this: WeakEntity<Self>,
1210        err: AuthRequired,
1211        agent_id: AgentId,
1212        connection: Rc<dyn AgentConnection>,
1213        window: &mut Window,
1214        cx: &mut App,
1215    ) {
1216        let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id {
1217            let registry = LanguageModelRegistry::global(cx);
1218
1219            let sub = window.subscribe(&registry, cx, {
1220                let provider_id = provider_id.clone();
1221                let this = this.clone();
1222                move |_, ev, window, cx| {
1223                    if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
1224                        && &provider_id == updated_provider_id
1225                        && LanguageModelRegistry::global(cx)
1226                            .read(cx)
1227                            .provider(&provider_id)
1228                            .map_or(false, |provider| provider.is_authenticated(cx))
1229                    {
1230                        this.update(cx, |this, cx| {
1231                            this.reset(window, cx);
1232                        })
1233                        .ok();
1234                    }
1235                }
1236            });
1237
1238            let view = registry.read(cx).provider(&provider_id).map(|provider| {
1239                provider.configuration_view(
1240                    language_model::ConfigurationViewTargetAgent::Other(agent_id.0),
1241                    window,
1242                    cx,
1243                )
1244            });
1245
1246            (view, Some(sub))
1247        } else {
1248            (None, None)
1249        };
1250
1251        this.update(cx, |this, cx| {
1252            let description = err
1253                .description
1254                .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx)));
1255            let auth_state = AuthState::Unauthenticated {
1256                pending_auth_method: None,
1257                configuration_view,
1258                description,
1259                _subscription: subscription,
1260            };
1261            if let Some(connected) = this.as_connected_mut() {
1262                connected.auth_state = auth_state;
1263                if let Some(view) = connected.active_view()
1264                    && view
1265                        .read(cx)
1266                        .message_editor
1267                        .focus_handle(cx)
1268                        .is_focused(window)
1269                {
1270                    this.focus_handle.focus(window, cx)
1271                }
1272            } else {
1273                this.set_server_state(
1274                    ServerState::Connected(ConnectedServerState {
1275                        auth_state,
1276                        active_id: None,
1277                        threads: HashMap::default(),
1278                        connection,
1279                        conversation: cx.new(|_cx| Conversation::default()),
1280                        _connection_entry_subscription: Subscription::new(|| {}),
1281                    }),
1282                    cx,
1283                );
1284            }
1285            cx.notify();
1286        })
1287        .ok();
1288    }
1289
1290    fn handle_load_error(&mut self, err: LoadError, window: &mut Window, cx: &mut Context<Self>) {
1291        if let Some(view) = self.root_thread_view() {
1292            if view
1293                .read(cx)
1294                .message_editor
1295                .focus_handle(cx)
1296                .is_focused(window)
1297            {
1298                self.focus_handle.focus(window, cx)
1299            }
1300        }
1301        self.emit_load_error_telemetry(&err);
1302        self.set_server_state(ServerState::LoadError { error: err }, cx);
1303    }
1304
1305    fn handle_agent_servers_updated(
1306        &mut self,
1307        _agent_server_store: &Entity<project::AgentServerStore>,
1308        _event: &project::AgentServersUpdated,
1309        window: &mut Window,
1310        cx: &mut Context<Self>,
1311    ) {
1312        // If we're in a LoadError state OR have a thread_error set (which can happen
1313        // when agent.connect() fails during loading), retry loading the thread.
1314        // This handles the case where a thread is restored before authentication completes.
1315        let should_retry = match &self.server_state {
1316            ServerState::Loading { .. } => false,
1317            ServerState::LoadError { .. } => true,
1318            ServerState::Connected(connected) => {
1319                connected.auth_state.is_ok() && connected.has_thread_error(cx)
1320            }
1321        };
1322
1323        if should_retry {
1324            if let Some(active) = self.root_thread_view() {
1325                active.update(cx, |active, cx| {
1326                    active.clear_thread_error(cx);
1327                });
1328            }
1329            self.reset(window, cx);
1330        }
1331    }
1332
1333    pub fn agent_key(&self) -> &Agent {
1334        &self.connection_key
1335    }
1336
1337    pub fn title(&self, cx: &App) -> SharedString {
1338        match &self.server_state {
1339            ServerState::Connected(view) => view
1340                .active_view()
1341                .and_then(|v| v.read(cx).thread.read(cx).title())
1342                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()),
1343            ServerState::Loading { .. } => "Loading…".into(),
1344            ServerState::LoadError { error, .. } => match error {
1345                LoadError::Unsupported { .. } => {
1346                    format!("Upgrade {}", self.agent.agent_id()).into()
1347                }
1348                LoadError::FailedToInstall(_) => {
1349                    format!("Failed to Install {}", self.agent.agent_id()).into()
1350                }
1351                LoadError::Exited { .. } => format!("{} Exited", self.agent.agent_id()).into(),
1352                LoadError::Other(_) => format!("Error Loading {}", self.agent.agent_id()).into(),
1353            },
1354        }
1355    }
1356
1357    pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
1358        if let Some(active) = self.active_thread() {
1359            active.update(cx, |active, cx| {
1360                active.cancel_generation(cx);
1361            });
1362        }
1363    }
1364
1365    pub fn parent_id(&self) -> ThreadId {
1366        self.thread_id
1367    }
1368
1369    pub fn is_loading(&self) -> bool {
1370        matches!(self.server_state, ServerState::Loading { .. })
1371    }
1372
1373    fn send_queued_message_at_index(
1374        &mut self,
1375        index: usize,
1376        is_send_now: bool,
1377        window: &mut Window,
1378        cx: &mut Context<Self>,
1379    ) {
1380        if let Some(active) = self.root_thread_view() {
1381            active.update(cx, |active, cx| {
1382                active.send_queued_message_at_index(index, is_send_now, window, cx);
1383            });
1384        }
1385    }
1386
1387    fn move_queued_message_to_main_editor(
1388        &mut self,
1389        index: usize,
1390        inserted_text: Option<&str>,
1391        cursor_offset: Option<usize>,
1392        window: &mut Window,
1393        cx: &mut Context<Self>,
1394    ) {
1395        if let Some(active) = self.root_thread_view() {
1396            active.update(cx, |active, cx| {
1397                active.move_queued_message_to_main_editor(
1398                    index,
1399                    inserted_text,
1400                    cursor_offset,
1401                    window,
1402                    cx,
1403                );
1404            });
1405        }
1406    }
1407
1408    fn handle_thread_event(
1409        &mut self,
1410        thread: &Entity<AcpThread>,
1411        event: &AcpThreadEvent,
1412        window: &mut Window,
1413        cx: &mut Context<Self>,
1414    ) {
1415        let session_id = thread.read(cx).session_id().clone();
1416        let has_thread = self
1417            .as_connected()
1418            .is_some_and(|connected| connected.threads.contains_key(&session_id));
1419        if !has_thread {
1420            return;
1421        };
1422        let is_subagent = thread.read(cx).parent_session_id().is_some();
1423        if !is_subagent && affects_thread_metadata(event) {
1424            cx.emit(RootThreadUpdated);
1425        }
1426        match event {
1427            AcpThreadEvent::NewEntry => {
1428                let len = thread.read(cx).entries().len();
1429                let index = len - 1;
1430                if let Some(active) = self.thread_view(&session_id) {
1431                    let entry_view_state = active.read(cx).entry_view_state.clone();
1432                    let list_state = active.read(cx).list_state.clone();
1433                    entry_view_state.update(cx, |view_state, cx| {
1434                        view_state.sync_entry(index, thread, window, cx);
1435                        list_state.splice_focusable(
1436                            index..index,
1437                            [view_state
1438                                .entry(index)
1439                                .and_then(|entry| entry.focus_handle(cx))],
1440                        );
1441                    });
1442                    active.update(cx, |active, cx| {
1443                        active.sync_editor_mode_for_empty_state(cx);
1444                    });
1445                }
1446            }
1447            AcpThreadEvent::EntryUpdated(index) => {
1448                if let Some(active) = self.thread_view(&session_id) {
1449                    let entry_view_state = active.read(cx).entry_view_state.clone();
1450                    let list_state = active.read(cx).list_state.clone();
1451                    entry_view_state.update(cx, |view_state, cx| {
1452                        view_state.sync_entry(*index, thread, window, cx);
1453                    });
1454                    list_state.remeasure_items(*index..*index + 1);
1455                    active.update(cx, |active, cx| {
1456                        active.auto_expand_streaming_thought(cx);
1457                    });
1458                }
1459            }
1460            AcpThreadEvent::EntriesRemoved(range) => {
1461                if let Some(active) = self.thread_view(&session_id) {
1462                    let entry_view_state = active.read(cx).entry_view_state.clone();
1463                    let list_state = active.read(cx).list_state.clone();
1464                    entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone()));
1465                    list_state.splice(range.clone(), 0);
1466                    active.update(cx, |active, cx| {
1467                        active.sync_editor_mode_for_empty_state(cx);
1468                    });
1469                }
1470            }
1471            AcpThreadEvent::SubagentSpawned(subagent_session_id) => {
1472                self.load_subagent_session(subagent_session_id.clone(), session_id, window, cx)
1473            }
1474            AcpThreadEvent::ToolAuthorizationRequested(_) => {
1475                self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
1476            }
1477            AcpThreadEvent::ToolAuthorizationReceived(_) => {}
1478            AcpThreadEvent::Retry(retry) => {
1479                if let Some(active) = self.thread_view(&session_id) {
1480                    active.update(cx, |active, _cx| {
1481                        active.thread_retry_status = Some(retry.clone());
1482                    });
1483                }
1484            }
1485            AcpThreadEvent::Stopped(stop_reason) => {
1486                if let Some(active) = self.thread_view(&session_id) {
1487                    let is_generating =
1488                        matches!(thread.read(cx).status(), ThreadStatus::Generating);
1489                    active.update(cx, |active, cx| {
1490                        if !is_generating {
1491                            active.thread_retry_status.take();
1492                            active.clear_auto_expand_tracking();
1493                            if active.list_state.is_following_tail() {
1494                                active.list_state.scroll_to_end();
1495                            }
1496                        }
1497                        active.sync_generating_indicator(cx);
1498                    });
1499                }
1500                if is_subagent {
1501                    if *stop_reason == acp::StopReason::EndTurn {
1502                        thread.update(cx, |thread, cx| {
1503                            thread.mark_as_subagent_output(cx);
1504                        });
1505                    }
1506                    return;
1507                }
1508
1509                let used_tools = thread.read(cx).used_tools_since_last_user_message();
1510                self.notify_with_sound(
1511                    if used_tools {
1512                        "Finished running tools"
1513                    } else {
1514                        "New message"
1515                    },
1516                    IconName::ZedAssistant,
1517                    window,
1518                    cx,
1519                );
1520
1521                let should_send_queued = if let Some(active) = self.root_thread_view() {
1522                    active.update(cx, |active, cx| {
1523                        if active.skip_queue_processing_count > 0 {
1524                            active.skip_queue_processing_count -= 1;
1525                            false
1526                        } else if active.user_interrupted_generation {
1527                            // Manual interruption: don't auto-process queue.
1528                            // Reset the flag so future completions can process normally.
1529                            active.user_interrupted_generation = false;
1530                            false
1531                        } else {
1532                            let has_queued = !active.local_queued_messages.is_empty();
1533                            // Don't auto-send if the first message editor is currently focused
1534                            let is_first_editor_focused = active
1535                                .queued_message_editors
1536                                .first()
1537                                .is_some_and(|editor| editor.focus_handle(cx).is_focused(window));
1538                            has_queued && !is_first_editor_focused
1539                        }
1540                    })
1541                } else {
1542                    false
1543                };
1544                if should_send_queued {
1545                    self.send_queued_message_at_index(0, false, window, cx);
1546                }
1547            }
1548            AcpThreadEvent::Refusal => {
1549                let error = ThreadError::Refusal;
1550                if let Some(active) = self.thread_view(&session_id) {
1551                    active.update(cx, |active, cx| {
1552                        active.handle_thread_error(error, cx);
1553                        active.thread_retry_status.take();
1554                    });
1555                }
1556                if !is_subagent {
1557                    let model_or_agent_name = self.current_model_name(cx);
1558                    let notification_message =
1559                        format!("{} refused to respond to this request", model_or_agent_name);
1560                    self.notify_with_sound(&notification_message, IconName::Warning, window, cx);
1561                }
1562            }
1563            AcpThreadEvent::Error => {
1564                if let Some(active) = self.thread_view(&session_id) {
1565                    let is_generating =
1566                        matches!(thread.read(cx).status(), ThreadStatus::Generating);
1567                    active.update(cx, |active, cx| {
1568                        if !is_generating {
1569                            active.thread_retry_status.take();
1570                            if active.list_state.is_following_tail() {
1571                                active.list_state.scroll_to_end();
1572                            }
1573                        }
1574                        active.sync_generating_indicator(cx);
1575                    });
1576                }
1577                if !is_subagent {
1578                    self.notify_with_sound(
1579                        "Agent stopped due to an error",
1580                        IconName::Warning,
1581                        window,
1582                        cx,
1583                    );
1584                }
1585            }
1586            AcpThreadEvent::LoadError(error) => {
1587                if let Some(view) = self.root_thread_view() {
1588                    if view
1589                        .read(cx)
1590                        .message_editor
1591                        .focus_handle(cx)
1592                        .is_focused(window)
1593                    {
1594                        self.focus_handle.focus(window, cx)
1595                    }
1596                }
1597                self.set_server_state(
1598                    ServerState::LoadError {
1599                        error: error.clone(),
1600                    },
1601                    cx,
1602                );
1603            }
1604            AcpThreadEvent::TitleUpdated => {
1605                if let Some(title) = thread.read(cx).title()
1606                    && let Some(active_thread) = self.thread_view(&session_id)
1607                {
1608                    let title_editor = active_thread.read(cx).title_editor.clone();
1609                    title_editor.update(cx, |editor, cx| {
1610                        if editor.text(cx) != title {
1611                            editor.set_text(title, window, cx);
1612                        }
1613                    });
1614                }
1615                cx.notify();
1616            }
1617            AcpThreadEvent::PromptCapabilitiesUpdated => {
1618                if let Some(active) = self.thread_view(&session_id) {
1619                    active.update(cx, |active, _cx| {
1620                        active
1621                            .session_capabilities
1622                            .write()
1623                            .set_prompt_capabilities(thread.read(_cx).prompt_capabilities());
1624                    });
1625                }
1626            }
1627            AcpThreadEvent::TokenUsageUpdated => {
1628                if let Some(active) = self.thread_view(&session_id) {
1629                    active.update(cx, |active, cx| {
1630                        active.update_turn_tokens(cx);
1631                    });
1632                }
1633            }
1634            AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
1635                if let Some(thread_view) = self.thread_view(&session_id) {
1636                    let has_commands = !available_commands.is_empty();
1637
1638                    let agent_display_name = self
1639                        .agent_server_store
1640                        .read(cx)
1641                        .agent_display_name(&self.agent.agent_id())
1642                        .unwrap_or_else(|| self.agent.agent_id().0.to_string().into());
1643
1644                    let new_placeholder =
1645                        placeholder_text(agent_display_name.as_ref(), has_commands);
1646
1647                    thread_view.update(cx, |thread_view, cx| {
1648                        thread_view
1649                            .session_capabilities
1650                            .write()
1651                            .set_available_commands(available_commands.clone());
1652                        thread_view.message_editor.update(cx, |editor, cx| {
1653                            editor.set_placeholder_text(&new_placeholder, window, cx);
1654                        });
1655                    });
1656                }
1657            }
1658            AcpThreadEvent::ModeUpdated(_mode) => {
1659                // The connection keeps track of the mode
1660                cx.notify();
1661            }
1662            AcpThreadEvent::ConfigOptionsUpdated(_) => {
1663                // The watch task in ConfigOptionsView handles rebuilding selectors
1664                cx.notify();
1665            }
1666            AcpThreadEvent::WorkingDirectoriesUpdated => {
1667                cx.notify();
1668            }
1669            AcpThreadEvent::PromptUpdated => {
1670                cx.notify();
1671            }
1672        }
1673        cx.notify();
1674    }
1675
1676    fn authenticate(
1677        &mut self,
1678        method: acp::AuthMethodId,
1679        window: &mut Window,
1680        cx: &mut Context<Self>,
1681    ) {
1682        let Some(workspace) = self.workspace.upgrade() else {
1683            return;
1684        };
1685        let Some(connected) = self.as_connected_mut() else {
1686            return;
1687        };
1688        let connection = connected.connection.clone();
1689
1690        let AuthState::Unauthenticated {
1691            configuration_view,
1692            pending_auth_method,
1693            ..
1694        } = &mut connected.auth_state
1695        else {
1696            return;
1697        };
1698
1699        let agent_telemetry_id = connection.telemetry_id();
1700
1701        if let Some(login_task) = connection.terminal_auth_task(&method, cx) {
1702            configuration_view.take();
1703            pending_auth_method.replace(method.clone());
1704
1705            let project = self.project.clone();
1706            cx.notify();
1707            self.auth_task = Some(cx.spawn_in(window, {
1708                async move |this, cx| {
1709                    let result = async {
1710                        let login = login_task.await?;
1711                        this.update_in(cx, |_this, window, cx| {
1712                            Self::spawn_external_agent_login(
1713                                login,
1714                                workspace,
1715                                project,
1716                                method.clone(),
1717                                false,
1718                                window,
1719                                cx,
1720                            )
1721                        })?
1722                        .await
1723                    }
1724                    .await;
1725
1726                    match &result {
1727                        Ok(_) => telemetry::event!(
1728                            "Authenticate Agent Succeeded",
1729                            agent = agent_telemetry_id
1730                        ),
1731                        Err(_) => {
1732                            telemetry::event!(
1733                                "Authenticate Agent Failed",
1734                                agent = agent_telemetry_id,
1735                            )
1736                        }
1737                    }
1738
1739                    this.update_in(cx, |this, window, cx| {
1740                        if let Err(err) = result {
1741                            if let Some(ConnectedServerState {
1742                                auth_state:
1743                                    AuthState::Unauthenticated {
1744                                        pending_auth_method,
1745                                        ..
1746                                    },
1747                                ..
1748                            }) = this.as_connected_mut()
1749                            {
1750                                pending_auth_method.take();
1751                            }
1752                            if let Some(active) = this.root_thread_view() {
1753                                active.update(cx, |active, cx| {
1754                                    active.handle_thread_error(err, cx);
1755                                })
1756                            }
1757                        } else {
1758                            this.reset(window, cx);
1759                        }
1760                        this.auth_task.take()
1761                    })
1762                    .ok();
1763                }
1764            }));
1765            return;
1766        }
1767
1768        configuration_view.take();
1769        pending_auth_method.replace(method.clone());
1770
1771        let authenticate = connection.authenticate(method, cx);
1772        cx.notify();
1773        self.auth_task = Some(cx.spawn_in(window, {
1774            async move |this, cx| {
1775                let result = authenticate.await;
1776
1777                match &result {
1778                    Ok(_) => telemetry::event!(
1779                        "Authenticate Agent Succeeded",
1780                        agent = agent_telemetry_id
1781                    ),
1782                    Err(_) => {
1783                        telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,)
1784                    }
1785                }
1786
1787                this.update_in(cx, |this, window, cx| {
1788                    if let Err(err) = result {
1789                        if let Some(ConnectedServerState {
1790                            auth_state:
1791                                AuthState::Unauthenticated {
1792                                    pending_auth_method,
1793                                    ..
1794                                },
1795                            ..
1796                        }) = this.as_connected_mut()
1797                        {
1798                            pending_auth_method.take();
1799                        }
1800                        if let Some(active) = this.root_thread_view() {
1801                            active.update(cx, |active, cx| active.handle_thread_error(err, cx));
1802                        }
1803                    } else {
1804                        this.reset(window, cx);
1805                    }
1806                    this.auth_task.take()
1807                })
1808                .ok();
1809            }
1810        }));
1811    }
1812
1813    fn load_subagent_session(
1814        &mut self,
1815        subagent_id: acp::SessionId,
1816        parent_session_id: acp::SessionId,
1817        window: &mut Window,
1818        cx: &mut Context<Self>,
1819    ) {
1820        let Some(connected) = self.as_connected() else {
1821            return;
1822        };
1823        if connected.threads.contains_key(&subagent_id)
1824            || !connected.connection.supports_load_session()
1825        {
1826            return;
1827        }
1828        let Some(parent_thread) = connected.threads.get(&parent_session_id) else {
1829            return;
1830        };
1831        let work_dirs = parent_thread
1832            .read(cx)
1833            .thread
1834            .read(cx)
1835            .work_dirs()
1836            .cloned()
1837            .unwrap_or_else(|| self.project.read(cx).default_path_list(cx));
1838
1839        let subagent_thread_task = connected.connection.clone().load_session(
1840            subagent_id,
1841            self.project.clone(),
1842            work_dirs,
1843            None,
1844            cx,
1845        );
1846
1847        cx.spawn_in(window, async move |this, cx| {
1848            let subagent_thread = subagent_thread_task.await?;
1849            this.update_in(cx, |this, window, cx| {
1850                let Some(conversation) = this
1851                    .as_connected()
1852                    .map(|connected| connected.conversation.clone())
1853                else {
1854                    return;
1855                };
1856                let subagent_session_id = subagent_thread.read(cx).session_id().clone();
1857                conversation.update(cx, |conversation, cx| {
1858                    conversation.register_thread(subagent_thread.clone(), cx);
1859                });
1860                let view =
1861                    this.new_thread_view(subagent_thread, conversation, false, None, window, cx);
1862                let Some(connected) = this.as_connected_mut() else {
1863                    return;
1864                };
1865                connected.threads.insert(subagent_session_id, view);
1866            })
1867        })
1868        .detach();
1869    }
1870
1871    fn spawn_external_agent_login(
1872        login: task::SpawnInTerminal,
1873        workspace: Entity<Workspace>,
1874        project: Entity<Project>,
1875        method: acp::AuthMethodId,
1876        previous_attempt: bool,
1877        window: &mut Window,
1878        cx: &mut App,
1879    ) -> Task<Result<()>> {
1880        let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
1881            return Task::ready(Err(anyhow!("Terminal panel is unavailable")));
1882        };
1883
1884        window.spawn(cx, async move |cx| {
1885            let mut task = login.clone();
1886            if let Some(cmd) = &task.command {
1887                // Have "node" command use Zed's managed Node runtime by default
1888                if cmd == "node" {
1889                    let resolved_node_runtime = project.update(cx, |project, cx| {
1890                        let agent_server_store = project.agent_server_store().clone();
1891                        agent_server_store.update(cx, |store, cx| {
1892                            store.node_runtime().map(|node_runtime| {
1893                                cx.background_spawn(async move { node_runtime.binary_path().await })
1894                            })
1895                        })
1896                    });
1897
1898                    if let Some(resolve_task) = resolved_node_runtime {
1899                        if let Ok(node_path) = resolve_task.await {
1900                            task.command = Some(node_path.to_string_lossy().to_string());
1901                        }
1902                    }
1903                }
1904            }
1905            task.shell = task::Shell::WithArguments {
1906                program: task.command.take().expect("login command should be set"),
1907                args: std::mem::take(&mut task.args),
1908                title_override: None,
1909            };
1910
1911            let terminal = terminal_panel
1912                .update_in(cx, |terminal_panel, window, cx| {
1913                    terminal_panel.spawn_task(&task, window, cx)
1914                })?
1915                .await?;
1916
1917            let success_patterns = match method.0.as_ref() {
1918                "claude-login" | GEMINI_TERMINAL_AUTH_METHOD_ID => vec![
1919                    "Login successful".to_string(),
1920                    "Type your message".to_string(),
1921                ],
1922                _ => Vec::new(),
1923            };
1924            if success_patterns.is_empty() {
1925                // No success patterns specified: wait for the process to exit and check exit code
1926                let exit_status = terminal
1927                    .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1928                    .await;
1929
1930                match exit_status {
1931                    Some(status) if status.success() => Ok(()),
1932                    Some(status) => Err(anyhow!(
1933                        "Login command failed with exit code: {:?}",
1934                        status.code()
1935                    )),
1936                    None => Err(anyhow!("Login command terminated without exit status")),
1937                }
1938            } else {
1939                // Look for specific output patterns to detect successful login
1940                let mut exit_status = terminal
1941                    .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1942                    .fuse();
1943
1944                let logged_in = cx
1945                    .spawn({
1946                        let terminal = terminal.clone();
1947                        async move |cx| {
1948                            loop {
1949                                cx.background_executor().timer(Duration::from_secs(1)).await;
1950                                let content =
1951                                    terminal.update(cx, |terminal, _cx| terminal.get_content())?;
1952                                if success_patterns
1953                                    .iter()
1954                                    .any(|pattern| content.contains(pattern))
1955                                {
1956                                    return anyhow::Ok(());
1957                                }
1958                            }
1959                        }
1960                    })
1961                    .fuse();
1962                futures::pin_mut!(logged_in);
1963                futures::select_biased! {
1964                    result = logged_in => {
1965                        if let Err(e) = result {
1966                            log::error!("{e}");
1967                            return Err(anyhow!("exited before logging in"));
1968                        }
1969                    }
1970                    _ = exit_status => {
1971                        if !previous_attempt
1972                            && project.read_with(cx, |project, _| project.is_via_remote_server())
1973                            && method.0.as_ref() == GEMINI_TERMINAL_AUTH_METHOD_ID
1974                        {
1975                            return cx
1976                                .update(|window, cx| {
1977                                    Self::spawn_external_agent_login(
1978                                        login,
1979                                        workspace,
1980                                        project.clone(),
1981                                        method,
1982                                        true,
1983                                        window,
1984                                        cx,
1985                                    )
1986                                })?
1987                                .await;
1988                        }
1989                        return Err(anyhow!("exited before logging in"));
1990                    }
1991                }
1992                terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
1993                Ok(())
1994            }
1995        })
1996    }
1997
1998    pub fn has_user_submitted_prompt(&self, cx: &App) -> bool {
1999        self.root_thread_view().is_some_and(|active| {
2000            active
2001                .read(cx)
2002                .thread
2003                .read(cx)
2004                .entries()
2005                .iter()
2006                .any(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
2007        })
2008    }
2009
2010    fn render_auth_required_state(
2011        &self,
2012        connection: &Rc<dyn AgentConnection>,
2013        description: Option<&Entity<Markdown>>,
2014        configuration_view: Option<&AnyView>,
2015        pending_auth_method: Option<&acp::AuthMethodId>,
2016        window: &mut Window,
2017        cx: &Context<Self>,
2018    ) -> impl IntoElement {
2019        let auth_methods = connection.auth_methods();
2020
2021        let agent_display_name = self
2022            .agent_server_store
2023            .read(cx)
2024            .agent_display_name(&self.agent.agent_id())
2025            .unwrap_or_else(|| self.agent.agent_id().0);
2026
2027        let show_fallback_description = auth_methods.len() > 1
2028            && configuration_view.is_none()
2029            && description.is_none()
2030            && pending_auth_method.is_none();
2031
2032        let auth_buttons = || {
2033            h_flex().justify_end().flex_wrap().gap_1().children(
2034                connection
2035                    .auth_methods()
2036                    .iter()
2037                    .enumerate()
2038                    .rev()
2039                    .map(|(ix, method)| {
2040                        let (method_id, name) = (method.id().0.clone(), method.name().to_string());
2041                        let agent_telemetry_id = connection.telemetry_id();
2042
2043                        Button::new(method_id.clone(), name)
2044                            .label_size(LabelSize::Small)
2045                            .map(|this| {
2046                                if ix == 0 {
2047                                    this.style(ButtonStyle::Tinted(TintColor::Accent))
2048                                } else {
2049                                    this.style(ButtonStyle::Outlined)
2050                                }
2051                            })
2052                            .when_some(method.description(), |this, description| {
2053                                this.tooltip(Tooltip::text(description.to_string()))
2054                            })
2055                            .on_click({
2056                                cx.listener(move |this, _, window, cx| {
2057                                    telemetry::event!(
2058                                        "Authenticate Agent Started",
2059                                        agent = agent_telemetry_id,
2060                                        method = method_id
2061                                    );
2062
2063                                    this.authenticate(
2064                                        acp::AuthMethodId::new(method_id.clone()),
2065                                        window,
2066                                        cx,
2067                                    )
2068                                })
2069                            })
2070                    }),
2071            )
2072        };
2073
2074        if pending_auth_method.is_some() {
2075            return Callout::new()
2076                .icon(IconName::Info)
2077                .title(format!("Authenticating to {}", agent_display_name))
2078                .actions_slot(
2079                    Icon::new(IconName::ArrowCircle)
2080                        .size(IconSize::Small)
2081                        .color(Color::Muted)
2082                        .with_rotate_animation(2)
2083                        .into_any_element(),
2084                )
2085                .into_any_element();
2086        }
2087
2088        Callout::new()
2089            .icon(IconName::Info)
2090            .title(format!("Authenticate to {}", agent_display_name))
2091            .when(auth_methods.len() == 1, |this| {
2092                this.actions_slot(auth_buttons())
2093            })
2094            .description_slot(
2095                v_flex()
2096                    .text_ui(cx)
2097                    .map(|this| {
2098                        if show_fallback_description {
2099                            this.child(
2100                                Label::new("Choose one of the following authentication options:")
2101                                    .size(LabelSize::Small)
2102                                    .color(Color::Muted),
2103                            )
2104                        } else {
2105                            this.children(
2106                                configuration_view
2107                                    .cloned()
2108                                    .map(|view| div().w_full().child(view)),
2109                            )
2110                            .children(description.map(|desc| {
2111                                self.render_markdown(
2112                                    desc.clone(),
2113                                    MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
2114                                )
2115                            }))
2116                        }
2117                    })
2118                    .when(auth_methods.len() > 1, |this| {
2119                        this.gap_1().child(auth_buttons())
2120                    }),
2121            )
2122            .into_any_element()
2123    }
2124
2125    fn emit_load_error_telemetry(&self, error: &LoadError) {
2126        let error_kind = match error {
2127            LoadError::Unsupported { .. } => "unsupported",
2128            LoadError::FailedToInstall(_) => "failed_to_install",
2129            LoadError::Exited { .. } => "exited",
2130            LoadError::Other(_) => "other",
2131        };
2132
2133        let agent_name = self.agent.agent_id();
2134
2135        telemetry::event!(
2136            "Agent Panel Error Shown",
2137            agent = agent_name,
2138            kind = error_kind,
2139            message = error.to_string(),
2140        );
2141    }
2142
2143    fn render_load_error(
2144        &self,
2145        e: &LoadError,
2146        window: &mut Window,
2147        cx: &mut Context<Self>,
2148    ) -> AnyElement {
2149        let (title, message, action_slot): (_, SharedString, _) = match e {
2150            LoadError::Unsupported {
2151                command: path,
2152                current_version,
2153                minimum_version,
2154            } => {
2155                return self.render_unsupported(path, current_version, minimum_version, window, cx);
2156            }
2157            LoadError::FailedToInstall(msg) => (
2158                "Failed to Install",
2159                msg.into(),
2160                Some(self.create_copy_button(msg.to_string()).into_any_element()),
2161            ),
2162            LoadError::Exited { status } => (
2163                "Failed to Launch",
2164                format!("Server exited with status {status}").into(),
2165                None,
2166            ),
2167            LoadError::Other(msg) => (
2168                "Failed to Launch",
2169                msg.into(),
2170                Some(self.create_copy_button(msg.to_string()).into_any_element()),
2171            ),
2172        };
2173
2174        Callout::new()
2175            .severity(Severity::Error)
2176            .icon(IconName::XCircleFilled)
2177            .title(title)
2178            .description(message)
2179            .actions_slot(div().children(action_slot))
2180            .into_any_element()
2181    }
2182
2183    fn render_unsupported(
2184        &self,
2185        path: &SharedString,
2186        version: &SharedString,
2187        minimum_version: &SharedString,
2188        _window: &mut Window,
2189        cx: &mut Context<Self>,
2190    ) -> AnyElement {
2191        let (heading_label, description_label) = (
2192            format!("Upgrade {} to work with Zed", self.agent.agent_id()),
2193            if version.is_empty() {
2194                format!(
2195                    "Currently using {}, which does not report a valid --version",
2196                    path,
2197                )
2198            } else {
2199                format!(
2200                    "Currently using {}, which is only version {} (need at least {minimum_version})",
2201                    path, version
2202                )
2203            },
2204        );
2205
2206        v_flex()
2207            .w_full()
2208            .p_3p5()
2209            .gap_2p5()
2210            .border_t_1()
2211            .border_color(cx.theme().colors().border)
2212            .bg(linear_gradient(
2213                180.,
2214                linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.),
2215                linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.),
2216            ))
2217            .child(
2218                v_flex().gap_0p5().child(Label::new(heading_label)).child(
2219                    Label::new(description_label)
2220                        .size(LabelSize::Small)
2221                        .color(Color::Muted),
2222                ),
2223            )
2224            .into_any_element()
2225    }
2226
2227    pub(crate) fn as_native_connection(
2228        &self,
2229        cx: &App,
2230    ) -> Option<Rc<agent::NativeAgentConnection>> {
2231        self.root_thread(cx)?
2232            .read(cx)
2233            .connection()
2234            .clone()
2235            .downcast()
2236    }
2237
2238    pub fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
2239        self.as_native_connection(cx)?
2240            .thread(self.root_session_id.as_ref()?, cx)
2241    }
2242
2243    fn queued_messages_len(&self, cx: &App) -> usize {
2244        self.root_thread_view()
2245            .map(|thread| thread.read(cx).local_queued_messages.len())
2246            .unwrap_or_default()
2247    }
2248
2249    fn update_queued_message(
2250        &mut self,
2251        index: usize,
2252        content: Vec<acp::ContentBlock>,
2253        tracked_buffers: Vec<Entity<Buffer>>,
2254        cx: &mut Context<Self>,
2255    ) -> bool {
2256        match self.root_thread_view() {
2257            Some(thread) => thread.update(cx, |thread, _cx| {
2258                if index < thread.local_queued_messages.len() {
2259                    thread.local_queued_messages[index] = QueuedMessage {
2260                        content,
2261                        tracked_buffers,
2262                    };
2263                    true
2264                } else {
2265                    false
2266                }
2267            }),
2268            None => false,
2269        }
2270    }
2271
2272    fn queued_message_contents(&self, cx: &App) -> Vec<Vec<acp::ContentBlock>> {
2273        match self.root_thread_view() {
2274            None => Vec::new(),
2275            Some(thread) => thread
2276                .read(cx)
2277                .local_queued_messages
2278                .iter()
2279                .map(|q| q.content.clone())
2280                .collect(),
2281        }
2282    }
2283
2284    fn save_queued_message_at_index(&mut self, index: usize, cx: &mut Context<Self>) {
2285        let editor = match self.root_thread_view() {
2286            Some(thread) => thread.read(cx).queued_message_editors.get(index).cloned(),
2287            None => None,
2288        };
2289        let Some(editor) = editor else {
2290            return;
2291        };
2292
2293        let contents_task = editor.update(cx, |editor, cx| editor.contents(false, cx));
2294
2295        cx.spawn(async move |this, cx| {
2296            let Ok((content, tracked_buffers)) = contents_task.await else {
2297                return Ok::<(), anyhow::Error>(());
2298            };
2299
2300            this.update(cx, |this, cx| {
2301                this.update_queued_message(index, content, tracked_buffers, cx);
2302                cx.notify();
2303            })?;
2304
2305            Ok(())
2306        })
2307        .detach_and_log_err(cx);
2308    }
2309
2310    fn sync_queued_message_editors(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2311        let needed_count = self.queued_messages_len(cx);
2312        let queued_messages = self.queued_message_contents(cx);
2313
2314        let agent_name = self.agent.agent_id();
2315        let workspace = self.workspace.clone();
2316        let project = self.project.downgrade();
2317        let Some(connected) = self.as_connected() else {
2318            return;
2319        };
2320        let Some(thread) = connected.active_view() else {
2321            return;
2322        };
2323        let session_capabilities = thread.read(cx).session_capabilities.clone();
2324
2325        let current_count = thread.read(cx).queued_message_editors.len();
2326        let last_synced = thread.read(cx).last_synced_queue_length;
2327
2328        if current_count == needed_count && needed_count == last_synced {
2329            return;
2330        }
2331
2332        if current_count > needed_count {
2333            thread.update(cx, |thread, _cx| {
2334                thread.queued_message_editors.truncate(needed_count);
2335                thread
2336                    .queued_message_editor_subscriptions
2337                    .truncate(needed_count);
2338            });
2339
2340            let editors = thread.read(cx).queued_message_editors.clone();
2341            for (index, editor) in editors.into_iter().enumerate() {
2342                if let Some(content) = queued_messages.get(index) {
2343                    editor.update(cx, |editor, cx| {
2344                        editor.set_read_only(true, cx);
2345                        editor.set_message(content.clone(), window, cx);
2346                    });
2347                }
2348            }
2349        }
2350
2351        while thread.read(cx).queued_message_editors.len() < needed_count {
2352            let index = thread.read(cx).queued_message_editors.len();
2353            let content = queued_messages.get(index).cloned().unwrap_or_default();
2354
2355            let editor = cx.new(|cx| {
2356                let mut editor = MessageEditor::new(
2357                    workspace.clone(),
2358                    project.clone(),
2359                    None,
2360                    None,
2361                    session_capabilities.clone(),
2362                    agent_name.clone(),
2363                    "",
2364                    EditorMode::AutoHeight {
2365                        min_lines: 1,
2366                        max_lines: Some(10),
2367                    },
2368                    window,
2369                    cx,
2370                );
2371                editor.set_read_only(true, cx);
2372                editor.set_message(content, window, cx);
2373                editor
2374            });
2375
2376            let subscription = cx.subscribe_in(
2377                &editor,
2378                window,
2379                move |this, _editor, event, window, cx| match event {
2380                    MessageEditorEvent::InputAttempted {
2381                        text,
2382                        cursor_offset,
2383                    } => this.move_queued_message_to_main_editor(
2384                        index,
2385                        Some(text.as_ref()),
2386                        Some(*cursor_offset),
2387                        window,
2388                        cx,
2389                    ),
2390                    MessageEditorEvent::LostFocus => {
2391                        this.save_queued_message_at_index(index, cx);
2392                    }
2393                    MessageEditorEvent::Cancel => {
2394                        window.focus(&this.focus_handle(cx), cx);
2395                    }
2396                    MessageEditorEvent::Send => {
2397                        window.focus(&this.focus_handle(cx), cx);
2398                    }
2399                    MessageEditorEvent::SendImmediately => {
2400                        this.send_queued_message_at_index(index, true, window, cx);
2401                    }
2402                    _ => {}
2403                },
2404            );
2405
2406            thread.update(cx, |thread, _cx| {
2407                thread.queued_message_editors.push(editor);
2408                thread
2409                    .queued_message_editor_subscriptions
2410                    .push(subscription);
2411            });
2412        }
2413
2414        if let Some(active) = self.root_thread_view() {
2415            active.update(cx, |active, _cx| {
2416                active.last_synced_queue_length = needed_count;
2417            });
2418        }
2419    }
2420
2421    fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
2422        let workspace = self.workspace.clone();
2423        MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
2424            crate::conversation_view::thread_view::open_link(text, &workspace, window, cx);
2425        })
2426    }
2427
2428    fn notify_with_sound(
2429        &mut self,
2430        caption: impl Into<SharedString>,
2431        icon: IconName,
2432        window: &mut Window,
2433        cx: &mut Context<Self>,
2434    ) {
2435        #[cfg(feature = "audio")]
2436        self.play_notification_sound(window, cx);
2437        self.show_notification(caption, icon, window, cx);
2438    }
2439
2440    fn is_visible(&self, multi_workspace: &Entity<MultiWorkspace>, cx: &Context<Self>) -> bool {
2441        let Some(workspace) = self.workspace.upgrade() else {
2442            return false;
2443        };
2444
2445        multi_workspace.read(cx).sidebar_open()
2446            || multi_workspace.read(cx).workspace() == &workspace
2447                && AgentPanel::is_visible(&workspace, cx)
2448                && multi_workspace
2449                    .read(cx)
2450                    .workspace()
2451                    .read(cx)
2452                    .panel::<AgentPanel>(cx)
2453                    .map_or(false, |p| {
2454                        p.read(cx).active_conversation_view().map(|c| c.entity_id())
2455                            == Some(cx.entity_id())
2456                    })
2457    }
2458
2459    fn agent_status_visible(&self, window: &Window, cx: &Context<Self>) -> bool {
2460        if !window.is_window_active() {
2461            return false;
2462        }
2463
2464        if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
2465            self.is_visible(&multi_workspace, cx)
2466        } else {
2467            self.workspace
2468                .upgrade()
2469                .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
2470        }
2471    }
2472
2473    #[cfg(feature = "audio")]
2474    fn play_notification_sound(&self, window: &Window, cx: &mut Context<Self>) {
2475        let visible = window.is_window_active()
2476            && if let Some(mw) = window.root::<MultiWorkspace>().flatten() {
2477                self.is_visible(&mw, cx)
2478            } else {
2479                self.workspace
2480                    .upgrade()
2481                    .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
2482            };
2483        let settings = AgentSettings::get_global(cx);
2484        if settings.play_sound_when_agent_done.should_play(visible) {
2485            Audio::play_sound(Sound::AgentDone, cx);
2486        }
2487    }
2488
2489    fn show_notification(
2490        &mut self,
2491        caption: impl Into<SharedString>,
2492        icon: IconName,
2493        window: &mut Window,
2494        cx: &mut Context<Self>,
2495    ) {
2496        if !self.notifications.is_empty() {
2497            return;
2498        }
2499
2500        let settings = AgentSettings::get_global(cx);
2501
2502        let should_notify = !self.agent_status_visible(window, cx);
2503
2504        if !should_notify {
2505            return;
2506        }
2507
2508        let Some(root_thread) = self.root_thread_view() else {
2509            return;
2510        };
2511        let root_thread = root_thread.read(cx).thread.read(cx);
2512        let root_session_id = root_thread.session_id().clone();
2513        let root_work_dirs = root_thread.work_dirs().cloned();
2514        let root_title = root_thread.title();
2515
2516        // TODO: Change this once we have title summarization for external agents.
2517        let title = self.agent.agent_id().0;
2518
2519        match settings.notify_when_agent_waiting {
2520            NotifyWhenAgentWaiting::PrimaryScreen => {
2521                if let Some(primary) = cx.primary_display() {
2522                    self.pop_up(
2523                        icon,
2524                        caption.into(),
2525                        title,
2526                        root_session_id,
2527                        root_work_dirs,
2528                        root_title,
2529                        window,
2530                        primary,
2531                        cx,
2532                    );
2533                }
2534            }
2535            NotifyWhenAgentWaiting::AllScreens => {
2536                let caption = caption.into();
2537                for screen in cx.displays() {
2538                    self.pop_up(
2539                        icon,
2540                        caption.clone(),
2541                        title.clone(),
2542                        root_session_id.clone(),
2543                        root_work_dirs.clone(),
2544                        root_title.clone(),
2545                        window,
2546                        screen,
2547                        cx,
2548                    );
2549                }
2550            }
2551            NotifyWhenAgentWaiting::Never => {
2552                // Don't show anything
2553            }
2554        }
2555    }
2556
2557    fn pop_up(
2558        &mut self,
2559        icon: IconName,
2560        caption: SharedString,
2561        title: SharedString,
2562        root_session_id: acp::SessionId,
2563        root_work_dirs: Option<PathList>,
2564        root_title: Option<SharedString>,
2565        window: &mut Window,
2566        screen: Rc<dyn PlatformDisplay>,
2567        cx: &mut Context<Self>,
2568    ) {
2569        let options = AgentNotification::window_options(screen, cx);
2570
2571        let project_name = self.workspace.upgrade().and_then(|workspace| {
2572            workspace
2573                .read(cx)
2574                .project()
2575                .read(cx)
2576                .visible_worktrees(cx)
2577                .next()
2578                .map(|worktree| worktree.read(cx).root_name_str().to_string())
2579        });
2580
2581        if let Some(screen_window) = cx
2582            .open_window(options, |_window, cx| {
2583                cx.new(|_cx| {
2584                    AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
2585                })
2586            })
2587            .log_err()
2588            && let Some(pop_up) = screen_window.entity(cx).log_err()
2589        {
2590            self.notification_subscriptions
2591                .entry(screen_window)
2592                .or_insert_with(Vec::new)
2593                .push(cx.subscribe_in(&pop_up, window, {
2594                    move |this, _, event, window, cx| match event {
2595                        AgentNotificationEvent::Accepted => {
2596                            let Some(handle) = window.window_handle().downcast::<MultiWorkspace>()
2597                            else {
2598                                log::error!("root view should be a MultiWorkspace");
2599                                return;
2600                            };
2601                            cx.activate(true);
2602
2603                            let workspace_handle = this.workspace.clone();
2604                            let agent = this.connection_key.clone();
2605                            let root_session_id = root_session_id.clone();
2606                            let root_work_dirs = root_work_dirs.clone();
2607                            let root_title = root_title.clone();
2608
2609                            cx.defer(move |cx| {
2610                                handle
2611                                    .update(cx, |multi_workspace, window, cx| {
2612                                        window.activate_window();
2613                                        if let Some(workspace) = workspace_handle.upgrade() {
2614                                            multi_workspace.activate(
2615                                                workspace.clone(),
2616                                                None,
2617                                                window,
2618                                                cx,
2619                                            );
2620                                            workspace.update(cx, |workspace, cx| {
2621                                                workspace.reveal_panel::<AgentPanel>(window, cx);
2622                                                if let Some(panel) =
2623                                                    workspace.panel::<AgentPanel>(cx)
2624                                                {
2625                                                    panel.update(cx, |panel, cx| {
2626                                                        panel.load_agent_thread(
2627                                                            agent.clone(),
2628                                                            root_session_id.clone(),
2629                                                            root_work_dirs.clone(),
2630                                                            root_title.clone(),
2631                                                            true,
2632                                                            "agent_panel",
2633                                                            window,
2634                                                            cx,
2635                                                        );
2636                                                    });
2637                                                }
2638                                                workspace.focus_panel::<AgentPanel>(window, cx);
2639                                            });
2640                                        }
2641                                    })
2642                                    .log_err();
2643                            });
2644
2645                            this.dismiss_notifications(cx);
2646                        }
2647                        AgentNotificationEvent::Dismissed => {
2648                            this.dismiss_notifications(cx);
2649                        }
2650                    }
2651                }));
2652
2653            self.notifications.push(screen_window);
2654
2655            let dismiss_if_visible = {
2656                let pop_up_weak = pop_up.downgrade();
2657                move |this: &ConversationView,
2658                      window: &mut Window,
2659                      cx: &mut Context<ConversationView>| {
2660                    if this.agent_status_visible(window, cx)
2661                        && let Some(pop_up) = pop_up_weak.upgrade()
2662                    {
2663                        pop_up.update(cx, |notification, cx| {
2664                            notification.dismiss(cx);
2665                        });
2666                    }
2667                }
2668            };
2669
2670            let subscriptions = self
2671                .notification_subscriptions
2672                .entry(screen_window)
2673                .or_insert_with(Vec::new);
2674
2675            subscriptions.push({
2676                let dismiss_if_visible = dismiss_if_visible.clone();
2677                cx.observe_window_activation(window, move |this, window, cx| {
2678                    dismiss_if_visible(this, window, cx);
2679                })
2680            });
2681
2682            if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
2683                let dismiss_if_visible = dismiss_if_visible.clone();
2684                subscriptions.push(cx.observe_in(
2685                    &multi_workspace,
2686                    window,
2687                    move |this, _, window, cx| {
2688                        dismiss_if_visible(this, window, cx);
2689                    },
2690                ));
2691            }
2692
2693            if let Some(panel) = self
2694                .workspace
2695                .upgrade()
2696                .and_then(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
2697            {
2698                subscriptions.push(cx.subscribe_in(
2699                    &panel,
2700                    window,
2701                    move |this, _, event: &AgentPanelEvent, window, cx| match event {
2702                        AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused => {
2703                            dismiss_if_visible(this, window, cx);
2704                        }
2705                        AgentPanelEvent::RetainedThreadChanged
2706                        | AgentPanelEvent::ThreadInteracted { .. } => {}
2707                    },
2708                ));
2709            }
2710        }
2711    }
2712
2713    fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
2714        for window in self.notifications.drain(..) {
2715            window
2716                .update(cx, |_, window, _| {
2717                    window.remove_window();
2718                })
2719                .ok();
2720
2721            self.notification_subscriptions.remove(&window);
2722        }
2723    }
2724
2725    fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
2726        if let Some(entry_view_state) = self
2727            .active_thread()
2728            .map(|active| active.read(cx).entry_view_state.clone())
2729        {
2730            entry_view_state.update(cx, |entry_view_state, cx| {
2731                entry_view_state.agent_ui_font_size_changed(cx);
2732            });
2733        }
2734    }
2735
2736    pub(crate) fn insert_dragged_files(
2737        &self,
2738        paths: Vec<project::ProjectPath>,
2739        added_worktrees: Vec<Entity<project::Worktree>>,
2740        window: &mut Window,
2741        cx: &mut Context<Self>,
2742    ) {
2743        if let Some(active_thread) = self.active_thread() {
2744            active_thread.update(cx, |thread, cx| {
2745                thread.message_editor.update(cx, |editor, cx| {
2746                    editor.insert_dragged_files(paths, added_worktrees, window, cx);
2747                    editor.focus_handle(cx).focus(window, cx);
2748                })
2749            });
2750        }
2751    }
2752
2753    /// Inserts the selected text into the message editor or the message being
2754    /// edited, if any.
2755    pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
2756        if let Some(active_thread) = self.active_thread() {
2757            active_thread.update(cx, |thread, cx| {
2758                thread.active_editor(cx).update(cx, |editor, cx| {
2759                    editor.insert_selections(window, cx);
2760                })
2761            });
2762        }
2763    }
2764
2765    fn current_model_name(&self, cx: &App) -> SharedString {
2766        // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
2767        // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI")
2768        // This provides better clarity about what refused the request
2769        if self.as_native_connection(cx).is_some() {
2770            self.root_thread_view()
2771                .and_then(|active| active.read(cx).model_selector.clone())
2772                .and_then(|selector| selector.read(cx).active_model(cx))
2773                .map(|model| model.name.clone())
2774                .unwrap_or_else(|| SharedString::from("The model"))
2775        } else {
2776            // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI")
2777            self.agent.agent_id().0
2778        }
2779    }
2780
2781    fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
2782        let message = message.into();
2783
2784        CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
2785    }
2786
2787    pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2788        let agent_id = self.agent.agent_id();
2789        if let Some(active) = self.root_thread_view() {
2790            active.update(cx, |active, cx| active.clear_thread_error(cx));
2791        }
2792        let this = cx.weak_entity();
2793        let Some(connection) = self.as_connected().map(|c| c.connection.clone()) else {
2794            debug_panic!("This should not be possible");
2795            return;
2796        };
2797        window.defer(cx, |window, cx| {
2798            Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx);
2799        })
2800    }
2801}
2802
2803fn loading_contents_spinner(size: IconSize) -> AnyElement {
2804    Icon::new(IconName::LoadCircle)
2805        .size(size)
2806        .color(Color::Accent)
2807        .with_rotate_animation(3)
2808        .into_any_element()
2809}
2810
2811fn placeholder_text(agent_name: &str, has_commands: bool) -> String {
2812    if agent_name == agent::ZED_AGENT_ID.as_ref() {
2813        format!("Message the {} — @ to include context", agent_name)
2814    } else if has_commands {
2815        format!(
2816            "Message {} — @ to include context, / for commands",
2817            agent_name
2818        )
2819    } else {
2820        format!("Message {} — @ to include context", agent_name)
2821    }
2822}
2823
2824impl Focusable for ConversationView {
2825    fn focus_handle(&self, cx: &App) -> FocusHandle {
2826        match self.active_thread() {
2827            Some(thread) => thread.read(cx).focus_handle(cx),
2828            None => self.focus_handle.clone(),
2829        }
2830    }
2831}
2832
2833#[cfg(any(test, feature = "test-support"))]
2834impl ConversationView {
2835    /// Expands a tool call so its content is visible.
2836    /// This is primarily useful for visual testing.
2837    pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context<Self>) {
2838        if let Some(active) = self.active_thread() {
2839            active.update(cx, |active, _cx| {
2840                active.expanded_tool_calls.insert(tool_call_id);
2841            });
2842            cx.notify();
2843        }
2844    }
2845
2846    #[cfg(any(test, feature = "test-support"))]
2847    pub fn set_updated_at(&mut self, updated_at: Instant, cx: &mut Context<Self>) {
2848        let Some(connected) = self.as_connected_mut() else {
2849            return;
2850        };
2851
2852        connected.conversation.update(cx, |conversation, _cx| {
2853            conversation.updated_at = Some(updated_at);
2854        });
2855    }
2856}
2857
2858impl Render for ConversationView {
2859    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2860        self.sync_queued_message_editors(window, cx);
2861
2862        v_flex()
2863            .track_focus(&self.focus_handle)
2864            .size_full()
2865            .bg(cx.theme().colors().panel_background)
2866            .child(match &self.server_state {
2867                ServerState::Loading { .. } => v_flex()
2868                    .flex_1()
2869                    .size_full()
2870                    .items_center()
2871                    .justify_center()
2872                    .child(
2873                        Label::new("Loading…").color(Color::Muted).with_animation(
2874                            "loading-agent-label",
2875                            Animation::new(Duration::from_secs(2))
2876                                .repeat()
2877                                .with_easing(pulsating_between(0.3, 0.7)),
2878                            |label, delta| label.alpha(delta),
2879                        ),
2880                    )
2881                    .into_any(),
2882                ServerState::LoadError { error: e, .. } => v_flex()
2883                    .flex_1()
2884                    .size_full()
2885                    .items_center()
2886                    .justify_end()
2887                    .child(self.render_load_error(e, window, cx))
2888                    .into_any(),
2889                ServerState::Connected(ConnectedServerState {
2890                    connection,
2891                    auth_state:
2892                        AuthState::Unauthenticated {
2893                            description,
2894                            configuration_view,
2895                            pending_auth_method,
2896                            _subscription,
2897                        },
2898                    ..
2899                }) => v_flex()
2900                    .flex_1()
2901                    .size_full()
2902                    .justify_end()
2903                    .child(self.render_auth_required_state(
2904                        connection,
2905                        description.as_ref(),
2906                        configuration_view.as_ref(),
2907                        pending_auth_method.as_ref(),
2908                        window,
2909                        cx,
2910                    ))
2911                    .into_any_element(),
2912                ServerState::Connected(connected) => {
2913                    if let Some(view) = connected.active_view() {
2914                        view.clone().into_any_element()
2915                    } else {
2916                        debug_panic!("This state should never be reached");
2917                        div().into_any_element()
2918                    }
2919                }
2920            })
2921    }
2922}
2923
2924fn plan_label_markdown_style(
2925    status: &acp::PlanEntryStatus,
2926    window: &Window,
2927    cx: &App,
2928) -> MarkdownStyle {
2929    let default_md_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
2930
2931    MarkdownStyle {
2932        base_text_style: TextStyle {
2933            color: cx.theme().colors().text_muted,
2934            strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
2935                Some(gpui::StrikethroughStyle {
2936                    thickness: px(1.),
2937                    color: Some(cx.theme().colors().text_muted.opacity(0.8)),
2938                })
2939            } else {
2940                None
2941            },
2942            ..default_md_style.base_text_style
2943        },
2944        ..default_md_style
2945    }
2946}
2947
2948#[cfg(test)]
2949pub(crate) mod tests {
2950    use acp_thread::StubAgentConnection;
2951    use action_log::ActionLog;
2952    use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
2953    use agent_servers::FakeAcpAgentServer;
2954    use editor::MultiBufferOffset;
2955    use fs::FakeFs;
2956    use gpui::{EventEmitter, TestAppContext, VisualTestContext};
2957    use parking_lot::Mutex;
2958    use project::Project;
2959    use serde_json::json;
2960    use settings::SettingsStore;
2961    use std::any::Any;
2962    use std::path::{Path, PathBuf};
2963    use std::rc::Rc;
2964    use std::sync::Arc;
2965    use workspace::{Item, MultiWorkspace};
2966
2967    use crate::agent_panel;
2968    use crate::thread_metadata_store::ThreadMetadataStore;
2969
2970    use super::*;
2971
2972    #[gpui::test]
2973    async fn test_drop(cx: &mut TestAppContext) {
2974        init_test(cx);
2975
2976        let (conversation_view, _cx) =
2977            setup_conversation_view(StubAgentServer::default_response(), cx).await;
2978        let weak_view = conversation_view.downgrade();
2979        drop(conversation_view);
2980        assert!(!weak_view.is_upgradable());
2981    }
2982
2983    #[gpui::test]
2984    async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) {
2985        init_test(cx);
2986
2987        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2988            panic!("expected prompt from external source to sanitize successfully");
2989        };
2990        let initial_content = AgentInitialContent::FromExternalSource(prompt);
2991
2992        let (conversation_view, cx) = setup_conversation_view_with_initial_content(
2993            StubAgentServer::default_response(),
2994            initial_content,
2995            cx,
2996        )
2997        .await;
2998
2999        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
3000            assert!(view.show_external_source_prompt_warning);
3001            assert_eq!(view.thread.read(cx).entries().len(), 0);
3002            assert_eq!(view.message_editor.read(cx).text(cx), "Write me a script");
3003        });
3004    }
3005
3006    #[gpui::test]
3007    async fn test_external_source_prompt_warning_clears_after_send(cx: &mut TestAppContext) {
3008        init_test(cx);
3009
3010        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
3011            panic!("expected prompt from external source to sanitize successfully");
3012        };
3013        let initial_content = AgentInitialContent::FromExternalSource(prompt);
3014
3015        let (conversation_view, cx) = setup_conversation_view_with_initial_content(
3016            StubAgentServer::default_response(),
3017            initial_content,
3018            cx,
3019        )
3020        .await;
3021
3022        active_thread(&conversation_view, cx)
3023            .update_in(cx, |view, window, cx| view.send(window, cx));
3024        cx.run_until_parked();
3025
3026        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
3027            assert!(!view.show_external_source_prompt_warning);
3028            assert_eq!(view.message_editor.read(cx).text(cx), "");
3029            assert_eq!(view.thread.read(cx).entries().len(), 2);
3030        });
3031    }
3032
3033    #[gpui::test]
3034    async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
3035        init_test(cx);
3036
3037        let (conversation_view, cx) =
3038            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3039
3040        let message_editor = message_editor(&conversation_view, cx);
3041        message_editor.update_in(cx, |editor, window, cx| {
3042            editor.set_text("Hello", window, cx);
3043        });
3044
3045        cx.deactivate_window();
3046
3047        active_thread(&conversation_view, cx)
3048            .update_in(cx, |view, window, cx| view.send(window, cx));
3049
3050        cx.run_until_parked();
3051
3052        assert!(
3053            cx.windows()
3054                .iter()
3055                .any(|window| window.downcast::<AgentNotification>().is_some())
3056        );
3057    }
3058
3059    #[gpui::test]
3060    async fn test_notification_for_error(cx: &mut TestAppContext) {
3061        init_test(cx);
3062
3063        let server = FakeAcpAgentServer::new();
3064        let (conversation_view, cx) = setup_conversation_view(server.clone(), cx).await;
3065
3066        let message_editor = message_editor(&conversation_view, cx);
3067        message_editor.update_in(cx, |editor, window, cx| {
3068            editor.set_text("Hello", window, cx);
3069        });
3070
3071        cx.deactivate_window();
3072        server.fail_next_prompt();
3073
3074        active_thread(&conversation_view, cx)
3075            .update_in(cx, |view, window, cx| view.send(window, cx));
3076
3077        cx.run_until_parked();
3078
3079        assert!(
3080            cx.windows()
3081                .iter()
3082                .any(|window| window.downcast::<AgentNotification>().is_some())
3083        );
3084    }
3085
3086    #[gpui::test]
3087    async fn test_acp_server_exit_transitions_conversation_to_load_error_without_panic(
3088        cx: &mut TestAppContext,
3089    ) {
3090        init_test(cx);
3091
3092        let server = FakeAcpAgentServer::new();
3093        let close_session_count = server.close_session_count();
3094        let (conversation_view, cx) = setup_conversation_view(server.clone(), cx).await;
3095
3096        cx.run_until_parked();
3097
3098        server.simulate_server_exit();
3099        cx.run_until_parked();
3100
3101        conversation_view.read_with(cx, |view, _cx| {
3102            assert!(
3103                matches!(view.server_state, ServerState::LoadError { .. }),
3104                "Conversation should transition to LoadError when an ACP thread exits"
3105            );
3106        });
3107        assert_eq!(
3108            close_session_count.load(std::sync::atomic::Ordering::SeqCst),
3109            1,
3110            "ConversationView should close the ACP session after a thread exit"
3111        );
3112    }
3113
3114    #[gpui::test]
3115    async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
3116        init_test(cx);
3117
3118        let fs = FakeFs::new(cx.executor());
3119        let project = Project::test(fs, [], cx).await;
3120        let (multi_workspace, cx) =
3121            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3122        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3123
3124        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3125        let connection_store =
3126            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3127
3128        let conversation_view = cx.update(|window, cx| {
3129            cx.new(|cx| {
3130                ConversationView::new(
3131                    Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
3132                    connection_store,
3133                    Agent::Custom { id: "Test".into() },
3134                    Some(acp::SessionId::new("resume-session")),
3135                    None,
3136                    None,
3137                    None,
3138                    None,
3139                    workspace.downgrade(),
3140                    project,
3141                    Some(thread_store),
3142                    None,
3143                    "agent_panel",
3144                    window,
3145                    cx,
3146                )
3147            })
3148        });
3149
3150        cx.run_until_parked();
3151
3152        conversation_view.read_with(cx, |view, cx| {
3153            let state = view.active_thread().unwrap();
3154            assert!(state.read(cx).resumed_without_history);
3155            assert_eq!(state.read(cx).list_state.item_count(), 0);
3156        });
3157    }
3158
3159    #[derive(Clone)]
3160    struct RestoredAvailableCommandsConnection;
3161
3162    impl AgentConnection for RestoredAvailableCommandsConnection {
3163        fn agent_id(&self) -> AgentId {
3164            AgentId::new("restored-available-commands")
3165        }
3166
3167        fn telemetry_id(&self) -> SharedString {
3168            "restored-available-commands".into()
3169        }
3170
3171        fn new_session(
3172            self: Rc<Self>,
3173            project: Entity<Project>,
3174            _work_dirs: PathList,
3175            cx: &mut App,
3176        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3177            let thread = build_test_thread(
3178                self,
3179                project,
3180                "RestoredAvailableCommandsConnection",
3181                acp::SessionId::new("new-session"),
3182                cx,
3183            );
3184            Task::ready(Ok(thread))
3185        }
3186
3187        fn supports_load_session(&self) -> bool {
3188            true
3189        }
3190
3191        fn load_session(
3192            self: Rc<Self>,
3193            session_id: acp::SessionId,
3194            project: Entity<Project>,
3195            _work_dirs: PathList,
3196            _title: Option<SharedString>,
3197            cx: &mut App,
3198        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3199            let thread = build_test_thread(
3200                self,
3201                project,
3202                "RestoredAvailableCommandsConnection",
3203                session_id,
3204                cx,
3205            );
3206
3207            thread
3208                .update(cx, |thread, cx| {
3209                    thread.handle_session_update(
3210                        acp::SessionUpdate::AvailableCommandsUpdate(
3211                            acp::AvailableCommandsUpdate::new(vec![acp::AvailableCommand::new(
3212                                "help", "Get help",
3213                            )]),
3214                        ),
3215                        cx,
3216                    )
3217                })
3218                .expect("available commands update should succeed");
3219
3220            Task::ready(Ok(thread))
3221        }
3222
3223        fn auth_methods(&self) -> &[acp::AuthMethod] {
3224            &[]
3225        }
3226
3227        fn authenticate(
3228            &self,
3229            _method_id: acp::AuthMethodId,
3230            _cx: &mut App,
3231        ) -> Task<gpui::Result<()>> {
3232            Task::ready(Ok(()))
3233        }
3234
3235        fn prompt(
3236            &self,
3237            _id: acp_thread::UserMessageId,
3238            _params: acp::PromptRequest,
3239            _cx: &mut App,
3240        ) -> Task<gpui::Result<acp::PromptResponse>> {
3241            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
3242        }
3243
3244        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
3245
3246        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3247            self
3248        }
3249    }
3250
3251    #[gpui::test]
3252    async fn test_restored_threads_keep_available_commands(cx: &mut TestAppContext) {
3253        init_test(cx);
3254
3255        let fs = FakeFs::new(cx.executor());
3256        let project = Project::test(fs, [], cx).await;
3257        let (multi_workspace, cx) =
3258            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3259        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3260
3261        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3262        let connection_store =
3263            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3264
3265        let conversation_view = cx.update(|window, cx| {
3266            cx.new(|cx| {
3267                ConversationView::new(
3268                    Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
3269                    connection_store,
3270                    Agent::Custom { id: "Test".into() },
3271                    Some(acp::SessionId::new("restored-session")),
3272                    None,
3273                    None,
3274                    None,
3275                    None,
3276                    workspace.downgrade(),
3277                    project,
3278                    Some(thread_store),
3279                    None,
3280                    "agent_panel",
3281                    window,
3282                    cx,
3283                )
3284            })
3285        });
3286
3287        cx.run_until_parked();
3288
3289        let message_editor = message_editor(&conversation_view, cx);
3290        let editor =
3291            message_editor.update(cx, |message_editor, _cx| message_editor.editor().clone());
3292        let placeholder = editor.update(cx, |editor, cx| editor.placeholder_text(cx));
3293
3294        active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
3295            let available_commands = view
3296                .session_capabilities
3297                .read()
3298                .available_commands()
3299                .to_vec();
3300            assert_eq!(available_commands.len(), 1);
3301            assert_eq!(available_commands[0].name.as_str(), "help");
3302            assert_eq!(available_commands[0].description.as_str(), "Get help");
3303        });
3304
3305        assert_eq!(
3306            placeholder,
3307            Some("Message Test — @ to include context, / for commands".to_string())
3308        );
3309
3310        message_editor.update_in(cx, |editor, window, cx| {
3311            editor.set_text("/help", window, cx);
3312        });
3313
3314        let contents_result = message_editor
3315            .update(cx, |editor, cx| editor.contents(false, cx))
3316            .await;
3317
3318        assert!(contents_result.is_ok());
3319    }
3320
3321    #[gpui::test]
3322    async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) {
3323        init_test(cx);
3324
3325        let fs = FakeFs::new(cx.executor());
3326        fs.insert_tree(
3327            "/project",
3328            json!({
3329                "subdir": {
3330                    "file.txt": "hello"
3331                }
3332            }),
3333        )
3334        .await;
3335        let project = Project::test(fs, [Path::new("/project")], cx).await;
3336        let (multi_workspace, cx) =
3337            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3338        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3339
3340        let connection = CwdCapturingConnection::new();
3341        let captured_cwd = connection.captured_work_dirs.clone();
3342
3343        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3344        let connection_store =
3345            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3346
3347        let _conversation_view = cx.update(|window, cx| {
3348            cx.new(|cx| {
3349                ConversationView::new(
3350                    Rc::new(StubAgentServer::new(connection)),
3351                    connection_store,
3352                    Agent::Custom { id: "Test".into() },
3353                    Some(acp::SessionId::new("session-1")),
3354                    None,
3355                    Some(PathList::new(&[PathBuf::from("/project/subdir")])),
3356                    None,
3357                    None,
3358                    workspace.downgrade(),
3359                    project,
3360                    Some(thread_store),
3361                    None,
3362                    "agent_panel",
3363                    window,
3364                    cx,
3365                )
3366            })
3367        });
3368
3369        cx.run_until_parked();
3370
3371        assert_eq!(
3372            captured_cwd.lock().as_ref().unwrap(),
3373            &PathList::new(&[Path::new("/project/subdir")]),
3374            "Should use session cwd when it's inside the project"
3375        );
3376    }
3377
3378    #[gpui::test]
3379    async fn test_refusal_handling(cx: &mut TestAppContext) {
3380        init_test(cx);
3381
3382        let (conversation_view, cx) =
3383            setup_conversation_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
3384
3385        let message_editor = message_editor(&conversation_view, cx);
3386        message_editor.update_in(cx, |editor, window, cx| {
3387            editor.set_text("Do something harmful", window, cx);
3388        });
3389
3390        active_thread(&conversation_view, cx)
3391            .update_in(cx, |view, window, cx| view.send(window, cx));
3392
3393        cx.run_until_parked();
3394
3395        // Check that the refusal error is set
3396        conversation_view.read_with(cx, |thread_view, cx| {
3397            let state = thread_view.active_thread().unwrap();
3398            assert!(
3399                matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)),
3400                "Expected refusal error to be set"
3401            );
3402        });
3403    }
3404
3405    #[gpui::test]
3406    async fn test_connect_failure_transitions_to_load_error(cx: &mut TestAppContext) {
3407        init_test(cx);
3408
3409        let (conversation_view, cx) = setup_conversation_view(FailingAgentServer, cx).await;
3410
3411        conversation_view.read_with(cx, |view, cx| {
3412            let title = view.title(cx);
3413            assert_eq!(
3414                title.as_ref(),
3415                "Error Loading Codex CLI",
3416                "Tab title should show the agent name with an error prefix"
3417            );
3418            match &view.server_state {
3419                ServerState::LoadError {
3420                    error: LoadError::Other(msg),
3421                    ..
3422                } => {
3423                    assert!(
3424                        msg.contains("Invalid gzip header"),
3425                        "Error callout should contain the underlying extraction error, got: {msg}"
3426                    );
3427                }
3428                other => panic!(
3429                    "Expected LoadError::Other, got: {}",
3430                    match other {
3431                        ServerState::Loading { .. } => "Loading (stuck!)",
3432                        ServerState::LoadError { .. } => "LoadError (wrong variant)",
3433                        ServerState::Connected(_) => "Connected",
3434                    }
3435                ),
3436            }
3437        });
3438    }
3439
3440    #[gpui::test]
3441    async fn test_reset_preserves_session_id_after_load_error(cx: &mut TestAppContext) {
3442        use crate::thread_metadata_store::{ThreadId, ThreadMetadata};
3443        use chrono::Utc;
3444        use project::{AgentId as ProjectAgentId, WorktreePaths};
3445        use std::sync::atomic::Ordering;
3446
3447        init_test(cx);
3448
3449        let fs = FakeFs::new(cx.executor());
3450        let project = Project::test(fs, [], cx).await;
3451        let (multi_workspace, cx) =
3452            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3453        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3454
3455        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3456        let connection_store =
3457            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3458
3459        // Simulate a previous run that persisted metadata for this session.
3460        let resume_session_id = acp::SessionId::new("persistent-session");
3461        let stored_title: SharedString = "Persistent chat".into();
3462        cx.update(|_window, cx| {
3463            ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3464                store.save(
3465                    ThreadMetadata {
3466                        thread_id: ThreadId::new(),
3467                        session_id: Some(resume_session_id.clone()),
3468                        agent_id: ProjectAgentId::new("Flaky"),
3469                        title: Some(stored_title.clone()),
3470                        updated_at: Utc::now(),
3471                        created_at: Some(Utc::now()),
3472                        interacted_at: None,
3473                        worktree_paths: WorktreePaths::from_folder_paths(&PathList::default()),
3474                        remote_connection: None,
3475                        archived: false,
3476                    },
3477                    cx,
3478                );
3479            });
3480        });
3481
3482        let connection = StubAgentConnection::new().with_supports_load_session(true);
3483        let (server, fail) = FlakyAgentServer::new(connection);
3484
3485        let conversation_view = cx.update(|window, cx| {
3486            cx.new(|cx| {
3487                ConversationView::new(
3488                    Rc::new(server),
3489                    connection_store,
3490                    Agent::Custom { id: "Flaky".into() },
3491                    Some(resume_session_id.clone()),
3492                    None,
3493                    None,
3494                    None,
3495                    None,
3496                    workspace.downgrade(),
3497                    project.clone(),
3498                    Some(thread_store),
3499                    None,
3500                    "agent_panel",
3501                    window,
3502                    cx,
3503                )
3504            })
3505        });
3506        cx.run_until_parked();
3507
3508        // The first connect() fails, so we land in LoadError.
3509        conversation_view.read_with(cx, |view, _cx| {
3510            assert!(
3511                matches!(view.server_state, ServerState::LoadError { .. }),
3512                "expected LoadError after failed initial connect"
3513            );
3514            assert_eq!(
3515                view.root_session_id.as_ref(),
3516                Some(&resume_session_id),
3517                "root_session_id should still hold the original id while in LoadError"
3518            );
3519        });
3520
3521        // Now let the agent come online and emit AgentServersUpdated. This is
3522        // the moment the bug would have stomped on root_session_id.
3523        fail.store(false, Ordering::SeqCst);
3524        project.update(cx, |project, cx| {
3525            project
3526                .agent_server_store()
3527                .update(cx, |_store, cx| cx.emit(project::AgentServersUpdated));
3528        });
3529        cx.run_until_parked();
3530
3531        // The retry should have resumed the ORIGINAL session, not created a
3532        // brand-new one.
3533        conversation_view.read_with(cx, |view, cx| {
3534            let connected = view
3535                .as_connected()
3536                .expect("should be Connected after flaky server comes online");
3537            let active_id = connected
3538                .active_id
3539                .as_ref()
3540                .expect("Connected state should have an active_id");
3541            assert_eq!(
3542                active_id, &resume_session_id,
3543                "reset() must resume the original session id, not call new_session()"
3544            );
3545            let active_thread = view
3546                .active_thread()
3547                .expect("should have an active thread view");
3548            let thread_session = active_thread.read(cx).thread.read(cx).session_id().clone();
3549            assert_eq!(
3550                thread_session, resume_session_id,
3551                "the live AcpThread should hold the resumed session id"
3552            );
3553        });
3554    }
3555
3556    #[gpui::test]
3557    async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) {
3558        init_test(cx);
3559
3560        let connection = AuthGatedAgentConnection::new();
3561        let (conversation_view, cx) =
3562            setup_conversation_view(StubAgentServer::new(connection), cx).await;
3563
3564        // When new_session returns AuthRequired, the server should transition
3565        // to Connected + Unauthenticated rather than getting stuck in Loading.
3566        conversation_view.read_with(cx, |view, _cx| {
3567            let connected = view
3568                .as_connected()
3569                .expect("Should be in Connected state even though auth is required");
3570            assert!(
3571                !connected.auth_state.is_ok(),
3572                "Auth state should be Unauthenticated"
3573            );
3574            assert!(
3575                connected.active_id.is_none(),
3576                "There should be no active thread since no session was created"
3577            );
3578            assert!(
3579                connected.threads.is_empty(),
3580                "There should be no threads since no session was created"
3581            );
3582        });
3583
3584        conversation_view.read_with(cx, |view, _cx| {
3585            assert!(
3586                view.active_thread().is_none(),
3587                "active_thread() should be None when unauthenticated without a session"
3588            );
3589        });
3590
3591        // Authenticate using the real authenticate flow on ConnectionView.
3592        // This calls connection.authenticate(), which flips the internal flag,
3593        // then on success triggers reset() -> new_session() which now succeeds.
3594        conversation_view.update_in(cx, |view, window, cx| {
3595            view.authenticate(
3596                acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID),
3597                window,
3598                cx,
3599            );
3600        });
3601        cx.run_until_parked();
3602
3603        // After auth, the server should have an active thread in the Ok state.
3604        conversation_view.read_with(cx, |view, cx| {
3605            let connected = view
3606                .as_connected()
3607                .expect("Should still be in Connected state after auth");
3608            assert!(connected.auth_state.is_ok(), "Auth state should be Ok");
3609            assert!(
3610                connected.active_id.is_some(),
3611                "There should be an active thread after successful auth"
3612            );
3613            assert_eq!(
3614                connected.threads.len(),
3615                1,
3616                "There should be exactly one thread"
3617            );
3618
3619            let active = view
3620                .active_thread()
3621                .expect("active_thread() should return the new thread");
3622            assert!(
3623                active.read(cx).thread_error.is_none(),
3624                "The new thread should have no errors"
3625            );
3626        });
3627    }
3628
3629    #[gpui::test]
3630    async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3631        init_test(cx);
3632
3633        let tool_call_id = acp::ToolCallId::new("1");
3634        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label")
3635            .kind(acp::ToolKind::Edit)
3636            .content(vec!["hi".into()]);
3637        let connection =
3638            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
3639                tool_call_id,
3640                PermissionOptions::Flat(vec![acp::PermissionOption::new(
3641                    "1",
3642                    "Allow",
3643                    acp::PermissionOptionKind::AllowOnce,
3644                )]),
3645            )]));
3646
3647        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
3648
3649        let (conversation_view, cx) =
3650            setup_conversation_view(StubAgentServer::new(connection), cx).await;
3651
3652        let message_editor = message_editor(&conversation_view, cx);
3653        message_editor.update_in(cx, |editor, window, cx| {
3654            editor.set_text("Hello", window, cx);
3655        });
3656
3657        cx.deactivate_window();
3658
3659        active_thread(&conversation_view, cx)
3660            .update_in(cx, |view, window, cx| view.send(window, cx));
3661
3662        cx.run_until_parked();
3663
3664        assert!(
3665            cx.windows()
3666                .iter()
3667                .any(|window| window.downcast::<AgentNotification>().is_some())
3668        );
3669    }
3670
3671    #[gpui::test]
3672    async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) {
3673        init_test(cx);
3674
3675        let (conversation_view, cx) =
3676            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3677
3678        add_to_workspace(conversation_view.clone(), cx);
3679
3680        let message_editor = message_editor(&conversation_view, cx);
3681
3682        message_editor.update_in(cx, |editor, window, cx| {
3683            editor.set_text("Hello", window, cx);
3684        });
3685
3686        // Window is active (don't deactivate), but panel will be hidden
3687        // Note: In the test environment, the panel is not actually added to the dock,
3688        // so is_agent_panel_hidden will return true
3689
3690        active_thread(&conversation_view, cx)
3691            .update_in(cx, |view, window, cx| view.send(window, cx));
3692
3693        cx.run_until_parked();
3694
3695        // Should show notification because window is active but panel is hidden
3696        assert!(
3697            cx.windows()
3698                .iter()
3699                .any(|window| window.downcast::<AgentNotification>().is_some()),
3700            "Expected notification when panel is hidden"
3701        );
3702    }
3703
3704    #[gpui::test]
3705    async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) {
3706        init_test(cx);
3707
3708        let (conversation_view, cx) =
3709            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3710
3711        let message_editor = message_editor(&conversation_view, cx);
3712        message_editor.update_in(cx, |editor, window, cx| {
3713            editor.set_text("Hello", window, cx);
3714        });
3715
3716        // Deactivate window - should show notification regardless of setting
3717        cx.deactivate_window();
3718
3719        active_thread(&conversation_view, cx)
3720            .update_in(cx, |view, window, cx| view.send(window, cx));
3721
3722        cx.run_until_parked();
3723
3724        // Should still show notification when window is inactive (existing behavior)
3725        assert!(
3726            cx.windows()
3727                .iter()
3728                .any(|window| window.downcast::<AgentNotification>().is_some()),
3729            "Expected notification when window is inactive"
3730        );
3731    }
3732
3733    #[gpui::test]
3734    async fn test_notification_when_different_conversation_is_active_in_visible_panel(
3735        cx: &mut TestAppContext,
3736    ) {
3737        init_test(cx);
3738
3739        let fs = FakeFs::new(cx.executor());
3740
3741        cx.update(|cx| {
3742            cx.update_flags(true, vec!["agent-v2".to_string()]);
3743            agent::ThreadStore::init_global(cx);
3744            language_model::LanguageModelRegistry::test(cx);
3745            <dyn Fs>::set_global(fs.clone(), cx);
3746        });
3747
3748        let project = Project::test(fs, [], cx).await;
3749        let multi_workspace_handle =
3750            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3751
3752        let workspace = multi_workspace_handle
3753            .read_with(cx, |mw, _cx| mw.workspace().clone())
3754            .unwrap();
3755
3756        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3757
3758        let panel = workspace.update_in(cx, |workspace, window, cx| {
3759            let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx));
3760            workspace.add_panel(panel.clone(), window, cx);
3761            workspace.focus_panel::<crate::AgentPanel>(window, cx);
3762            panel
3763        });
3764
3765        cx.run_until_parked();
3766
3767        panel.update_in(cx, |panel, window, cx| {
3768            panel.open_external_thread_with_server(
3769                Rc::new(StubAgentServer::default_response()),
3770                window,
3771                cx,
3772            );
3773        });
3774
3775        cx.run_until_parked();
3776
3777        panel.read_with(cx, |panel, cx| {
3778            assert!(crate::AgentPanel::is_visible(&workspace, cx));
3779            assert!(panel.active_conversation_view().is_some());
3780        });
3781
3782        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3783        let connection_store =
3784            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3785
3786        let conversation_view = cx.update(|window, cx| {
3787            cx.new(|cx| {
3788                ConversationView::new(
3789                    Rc::new(StubAgentServer::default_response()),
3790                    connection_store,
3791                    Agent::Custom { id: "Test".into() },
3792                    None,
3793                    None,
3794                    None,
3795                    None,
3796                    None,
3797                    workspace.downgrade(),
3798                    project.clone(),
3799                    Some(thread_store),
3800                    None,
3801                    "agent_panel",
3802                    window,
3803                    cx,
3804                )
3805            })
3806        });
3807
3808        cx.run_until_parked();
3809
3810        panel.read_with(cx, |panel, _cx| {
3811            assert_ne!(
3812                panel
3813                    .active_conversation_view()
3814                    .map(|view| view.entity_id()),
3815                Some(conversation_view.entity_id()),
3816                "The visible panel should still be showing a different conversation"
3817            );
3818        });
3819
3820        let message_editor = message_editor(&conversation_view, cx);
3821        message_editor.update_in(cx, |editor, window, cx| {
3822            editor.set_text("Hello", window, cx);
3823        });
3824
3825        active_thread(&conversation_view, cx)
3826            .update_in(cx, |view, window, cx| view.send(window, cx));
3827
3828        cx.run_until_parked();
3829
3830        assert!(
3831            cx.windows()
3832                .iter()
3833                .any(|window| window.downcast::<AgentNotification>().is_some()),
3834            "Expected notification when a different conversation is active in the visible panel"
3835        );
3836    }
3837
3838    #[gpui::test]
3839    async fn test_no_notification_when_sidebar_open_but_different_thread_focused(
3840        cx: &mut TestAppContext,
3841    ) {
3842        init_test(cx);
3843
3844        let fs = FakeFs::new(cx.executor());
3845
3846        cx.update(|cx| {
3847            cx.update_flags(true, vec!["agent-v2".to_string()]);
3848            agent::ThreadStore::init_global(cx);
3849            language_model::LanguageModelRegistry::test(cx);
3850            <dyn Fs>::set_global(fs.clone(), cx);
3851        });
3852
3853        let project = Project::test(fs, [], cx).await;
3854        let multi_workspace_handle =
3855            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3856
3857        let workspace = multi_workspace_handle
3858            .read_with(cx, |mw, _cx| mw.workspace().clone())
3859            .unwrap();
3860
3861        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3862
3863        // Open the sidebar so that sidebar_open() returns true.
3864        multi_workspace_handle
3865            .update(cx, |mw, _window, cx| {
3866                mw.open_sidebar(cx);
3867            })
3868            .unwrap();
3869
3870        cx.run_until_parked();
3871
3872        assert!(
3873            multi_workspace_handle
3874                .read_with(cx, |mw, _cx| mw.sidebar_open())
3875                .unwrap(),
3876            "Sidebar should be open"
3877        );
3878
3879        // Create a conversation view that is NOT the active one in the panel.
3880        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3881        let connection_store =
3882            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3883
3884        let conversation_view = cx.update(|window, cx| {
3885            cx.new(|cx| {
3886                ConversationView::new(
3887                    Rc::new(StubAgentServer::default_response()),
3888                    connection_store,
3889                    Agent::Custom { id: "Test".into() },
3890                    None,
3891                    None,
3892                    None,
3893                    None,
3894                    None,
3895                    workspace.downgrade(),
3896                    project.clone(),
3897                    Some(thread_store),
3898                    None,
3899                    "agent_panel",
3900                    window,
3901                    cx,
3902                )
3903            })
3904        });
3905
3906        cx.run_until_parked();
3907
3908        let message_editor = message_editor(&conversation_view, cx);
3909        message_editor.update_in(cx, |editor, window, cx| {
3910            editor.set_text("Hello", window, cx);
3911        });
3912
3913        active_thread(&conversation_view, cx)
3914            .update_in(cx, |view, window, cx| view.send(window, cx));
3915
3916        cx.run_until_parked();
3917
3918        assert!(
3919            !cx.windows()
3920                .iter()
3921                .any(|window| window.downcast::<AgentNotification>().is_some()),
3922            "Expected no notification when the sidebar is open, even if focused on another thread"
3923        );
3924    }
3925
3926    #[gpui::test]
3927    async fn test_notification_dismissed_when_sidebar_opens(cx: &mut TestAppContext) {
3928        init_test(cx);
3929
3930        let fs = FakeFs::new(cx.executor());
3931
3932        cx.update(|cx| {
3933            cx.update_flags(true, vec!["agent-v2".to_string()]);
3934            agent::ThreadStore::init_global(cx);
3935            language_model::LanguageModelRegistry::test(cx);
3936            <dyn Fs>::set_global(fs.clone(), cx);
3937        });
3938
3939        let project = Project::test(fs, [], cx).await;
3940        let multi_workspace_handle =
3941            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3942
3943        let workspace = multi_workspace_handle
3944            .read_with(cx, |mw, _cx| mw.workspace().clone())
3945            .unwrap();
3946
3947        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3948
3949        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3950        let connection_store =
3951            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3952
3953        let conversation_view = cx.update(|window, cx| {
3954            cx.new(|cx| {
3955                ConversationView::new(
3956                    Rc::new(StubAgentServer::default_response()),
3957                    connection_store,
3958                    Agent::Custom { id: "Test".into() },
3959                    None,
3960                    None,
3961                    None,
3962                    None,
3963                    None,
3964                    workspace.downgrade(),
3965                    project.clone(),
3966                    Some(thread_store),
3967                    None,
3968                    "agent_panel",
3969                    window,
3970                    cx,
3971                )
3972            })
3973        });
3974
3975        cx.run_until_parked();
3976
3977        let message_editor = message_editor(&conversation_view, cx);
3978        message_editor.update_in(cx, |editor, window, cx| {
3979            editor.set_text("Hello", window, cx);
3980        });
3981
3982        active_thread(&conversation_view, cx)
3983            .update_in(cx, |view, window, cx| view.send(window, cx));
3984
3985        cx.run_until_parked();
3986
3987        assert_eq!(
3988            cx.windows()
3989                .iter()
3990                .filter(|window| window.downcast::<AgentNotification>().is_some())
3991                .count(),
3992            1,
3993            "Expected a notification while the thread is not visible"
3994        );
3995
3996        multi_workspace_handle
3997            .update(cx, |mw, _window, cx| {
3998                mw.open_sidebar(cx);
3999            })
4000            .unwrap();
4001
4002        cx.run_until_parked();
4003
4004        assert_eq!(
4005            cx.windows()
4006                .iter()
4007                .filter(|window| window.downcast::<AgentNotification>().is_some())
4008                .count(),
4009            0,
4010            "Notification should auto-dismiss when the sidebar opens and makes the thread visible"
4011        );
4012    }
4013
4014    #[gpui::test]
4015    async fn test_notification_when_workspace_is_background_in_multi_workspace(
4016        cx: &mut TestAppContext,
4017    ) {
4018        init_test(cx);
4019
4020        // Enable multi-workspace feature flag and init globals needed by AgentPanel
4021        let fs = FakeFs::new(cx.executor());
4022
4023        cx.update(|cx| {
4024            agent::ThreadStore::init_global(cx);
4025            language_model::LanguageModelRegistry::test(cx);
4026            <dyn Fs>::set_global(fs.clone(), cx);
4027        });
4028
4029        let project1 = Project::test(fs.clone(), [], cx).await;
4030
4031        // Create a MultiWorkspace window with one workspace
4032        let multi_workspace_handle =
4033            cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
4034
4035        // Get workspace 1 (the initial workspace)
4036        let workspace1 = multi_workspace_handle
4037            .read_with(cx, |mw, _cx| mw.workspace().clone())
4038            .unwrap();
4039
4040        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
4041
4042        let panel = workspace1.update_in(cx, |workspace, window, cx| {
4043            let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx));
4044            workspace.add_panel(panel.clone(), window, cx);
4045
4046            // Open the dock and activate the agent panel so it's visible
4047            workspace.focus_panel::<crate::AgentPanel>(window, cx);
4048            panel
4049        });
4050
4051        cx.run_until_parked();
4052
4053        panel.update_in(cx, |panel, window, cx| {
4054            panel.open_external_thread_with_server(
4055                Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
4056                window,
4057                cx,
4058            );
4059        });
4060
4061        cx.run_until_parked();
4062
4063        cx.read(|cx| {
4064            assert!(
4065                crate::AgentPanel::is_visible(&workspace1, cx),
4066                "AgentPanel should be visible in workspace1's dock"
4067            );
4068        });
4069
4070        // Set up thread view in workspace 1
4071        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4072        let connection_store =
4073            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
4074
4075        let conversation_view = cx.update(|window, cx| {
4076            cx.new(|cx| {
4077                ConversationView::new(
4078                    Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
4079                    connection_store,
4080                    Agent::Custom { id: "Test".into() },
4081                    None,
4082                    None,
4083                    None,
4084                    None,
4085                    None,
4086                    workspace1.downgrade(),
4087                    project1.clone(),
4088                    Some(thread_store),
4089                    None,
4090                    "agent_panel",
4091                    window,
4092                    cx,
4093                )
4094            })
4095        });
4096        cx.run_until_parked();
4097
4098        let root_session_id = conversation_view
4099            .read_with(cx, |view, cx| {
4100                view.root_thread_view()
4101                    .map(|thread| thread.read(cx).thread.read(cx).session_id().clone())
4102            })
4103            .expect("Conversation view should have a root thread");
4104
4105        let message_editor = message_editor(&conversation_view, cx);
4106        message_editor.update_in(cx, |editor, window, cx| {
4107            editor.set_text("Hello", window, cx);
4108        });
4109
4110        // Create a second workspace and switch to it.
4111        // This makes workspace1 the "background" workspace.
4112        let project2 = Project::test(fs, [], cx).await;
4113        multi_workspace_handle
4114            .update(cx, |mw, window, cx| {
4115                mw.test_add_workspace(project2, window, cx);
4116            })
4117            .unwrap();
4118
4119        cx.run_until_parked();
4120
4121        // Verify workspace1 is no longer the active workspace
4122        multi_workspace_handle
4123            .read_with(cx, |mw, _cx| {
4124                assert_ne!(mw.workspace(), &workspace1);
4125            })
4126            .unwrap();
4127
4128        // Window is active, agent panel is visible in workspace1, but workspace1
4129        // is in the background. The notification should show because the user
4130        // can't actually see the agent panel.
4131        active_thread(&conversation_view, cx)
4132            .update_in(cx, |view, window, cx| view.send(window, cx));
4133
4134        cx.run_until_parked();
4135
4136        assert!(
4137            cx.windows()
4138                .iter()
4139                .any(|window| window.downcast::<AgentNotification>().is_some()),
4140            "Expected notification when workspace is in background within MultiWorkspace"
4141        );
4142
4143        // Also verify: clicking "View Panel" should switch to workspace1.
4144        cx.windows()
4145            .iter()
4146            .find_map(|window| window.downcast::<AgentNotification>())
4147            .unwrap()
4148            .update(cx, |window, _, cx| window.accept(cx))
4149            .unwrap();
4150
4151        cx.run_until_parked();
4152
4153        multi_workspace_handle
4154            .read_with(cx, |mw, _cx| {
4155                assert_eq!(
4156                    mw.workspace(),
4157                    &workspace1,
4158                    "Expected workspace1 to become the active workspace after accepting notification"
4159                );
4160            })
4161            .unwrap();
4162
4163        panel.read_with(cx, |panel, cx| {
4164            let active_session_id = panel
4165                .active_agent_thread(cx)
4166                .map(|thread| thread.read(cx).session_id().clone());
4167            assert_eq!(
4168                active_session_id,
4169                Some(root_session_id),
4170                "Expected accepting the notification to load the notified thread in AgentPanel"
4171            );
4172        });
4173    }
4174
4175    #[gpui::test]
4176    async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
4177        init_test(cx);
4178
4179        // Set notify_when_agent_waiting to Never
4180        cx.update(|cx| {
4181            AgentSettings::override_global(
4182                AgentSettings {
4183                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
4184                    ..AgentSettings::get_global(cx).clone()
4185                },
4186                cx,
4187            );
4188        });
4189
4190        let (conversation_view, cx) =
4191            setup_conversation_view(StubAgentServer::default_response(), cx).await;
4192
4193        let message_editor = message_editor(&conversation_view, cx);
4194        message_editor.update_in(cx, |editor, window, cx| {
4195            editor.set_text("Hello", window, cx);
4196        });
4197
4198        // Window is active
4199
4200        active_thread(&conversation_view, cx)
4201            .update_in(cx, |view, window, cx| view.send(window, cx));
4202
4203        cx.run_until_parked();
4204
4205        // Should NOT show notification because notify_when_agent_waiting is Never
4206        assert!(
4207            !cx.windows()
4208                .iter()
4209                .any(|window| window.downcast::<AgentNotification>().is_some()),
4210            "Expected no notification when notify_when_agent_waiting is Never"
4211        );
4212    }
4213
4214    #[gpui::test]
4215    async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
4216        init_test(cx);
4217
4218        let (conversation_view, cx) =
4219            setup_conversation_view(StubAgentServer::default_response(), cx).await;
4220
4221        let weak_view = conversation_view.downgrade();
4222
4223        let message_editor = message_editor(&conversation_view, cx);
4224        message_editor.update_in(cx, |editor, window, cx| {
4225            editor.set_text("Hello", window, cx);
4226        });
4227
4228        cx.deactivate_window();
4229
4230        active_thread(&conversation_view, cx)
4231            .update_in(cx, |view, window, cx| view.send(window, cx));
4232
4233        cx.run_until_parked();
4234
4235        // Verify notification is shown
4236        assert!(
4237            cx.windows()
4238                .iter()
4239                .any(|window| window.downcast::<AgentNotification>().is_some()),
4240            "Expected notification to be shown"
4241        );
4242
4243        // Drop the thread view (simulating navigation to a new thread)
4244        drop(conversation_view);
4245        drop(message_editor);
4246        // Trigger an update to flush effects, which will call release_dropped_entities
4247        cx.update(|_window, _cx| {});
4248        cx.run_until_parked();
4249
4250        // Verify the entity was actually released
4251        assert!(
4252            !weak_view.is_upgradable(),
4253            "Thread view entity should be released after dropping"
4254        );
4255
4256        // The notification should be automatically closed via on_release
4257        assert!(
4258            !cx.windows()
4259                .iter()
4260                .any(|window| window.downcast::<AgentNotification>().is_some()),
4261            "Notification should be closed when thread view is dropped"
4262        );
4263    }
4264
4265    async fn setup_conversation_view(
4266        agent: impl AgentServer + 'static,
4267        cx: &mut TestAppContext,
4268    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
4269        setup_conversation_view_with_initial_content_opt(agent, None, cx).await
4270    }
4271
4272    async fn setup_conversation_view_with_initial_content(
4273        agent: impl AgentServer + 'static,
4274        initial_content: AgentInitialContent,
4275        cx: &mut TestAppContext,
4276    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
4277        setup_conversation_view_with_initial_content_opt(agent, Some(initial_content), cx).await
4278    }
4279
4280    async fn setup_conversation_view_with_initial_content_opt(
4281        agent: impl AgentServer + 'static,
4282        initial_content: Option<AgentInitialContent>,
4283        cx: &mut TestAppContext,
4284    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
4285        let fs = FakeFs::new(cx.executor());
4286        let project = Project::test(fs, [], cx).await;
4287        let (multi_workspace, cx) =
4288            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4289        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4290
4291        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4292        let connection_store =
4293            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4294
4295        let agent_key = Agent::Custom { id: "Test".into() };
4296
4297        let conversation_view = cx.update(|window, cx| {
4298            cx.new(|cx| {
4299                ConversationView::new(
4300                    Rc::new(agent),
4301                    connection_store.clone(),
4302                    agent_key.clone(),
4303                    None,
4304                    None,
4305                    None,
4306                    None,
4307                    initial_content,
4308                    workspace.downgrade(),
4309                    project,
4310                    Some(thread_store),
4311                    None,
4312                    "agent_panel",
4313                    window,
4314                    cx,
4315                )
4316            })
4317        });
4318        cx.run_until_parked();
4319
4320        (conversation_view, cx)
4321    }
4322
4323    fn add_to_workspace(conversation_view: Entity<ConversationView>, cx: &mut VisualTestContext) {
4324        let workspace =
4325            conversation_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
4326
4327        workspace
4328            .update_in(cx, |workspace, window, cx| {
4329                workspace.add_item_to_active_pane(
4330                    Box::new(cx.new(|_| ThreadViewItem(conversation_view.clone()))),
4331                    None,
4332                    true,
4333                    window,
4334                    cx,
4335                );
4336            })
4337            .unwrap();
4338    }
4339
4340    struct ThreadViewItem(Entity<ConversationView>);
4341
4342    impl Item for ThreadViewItem {
4343        type Event = ();
4344
4345        fn include_in_nav_history() -> bool {
4346            false
4347        }
4348
4349        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
4350            "Test".into()
4351        }
4352    }
4353
4354    impl EventEmitter<()> for ThreadViewItem {}
4355
4356    impl Focusable for ThreadViewItem {
4357        fn focus_handle(&self, cx: &App) -> FocusHandle {
4358            self.0.read(cx).focus_handle(cx)
4359        }
4360    }
4361
4362    impl Render for ThreadViewItem {
4363        fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4364            // Render the title editor in the element tree too. In the real app
4365            // it is part of the agent panel
4366            let title_editor = self
4367                .0
4368                .read(cx)
4369                .active_thread()
4370                .map(|t| t.read(cx).title_editor.clone());
4371
4372            v_flex().children(title_editor).child(self.0.clone())
4373        }
4374    }
4375
4376    pub(crate) struct StubAgentServer<C> {
4377        connection: C,
4378    }
4379
4380    impl<C> StubAgentServer<C> {
4381        pub(crate) fn new(connection: C) -> Self {
4382            Self { connection }
4383        }
4384    }
4385
4386    impl StubAgentServer<StubAgentConnection> {
4387        pub(crate) fn default_response() -> Self {
4388            let conn = StubAgentConnection::new();
4389            conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4390                acp::ContentChunk::new("Default response".into()),
4391            )]);
4392            Self::new(conn)
4393        }
4394    }
4395
4396    impl<C> AgentServer for StubAgentServer<C>
4397    where
4398        C: 'static + AgentConnection + Send + Clone,
4399    {
4400        fn logo(&self) -> ui::IconName {
4401            ui::IconName::ZedAgent
4402        }
4403
4404        fn agent_id(&self) -> AgentId {
4405            "Test".into()
4406        }
4407
4408        fn connect(
4409            &self,
4410            _delegate: AgentServerDelegate,
4411            _project: Entity<Project>,
4412            _cx: &mut App,
4413        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
4414            Task::ready(Ok(Rc::new(self.connection.clone())))
4415        }
4416
4417        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4418            self
4419        }
4420    }
4421
4422    struct FailingAgentServer;
4423
4424    impl AgentServer for FailingAgentServer {
4425        fn logo(&self) -> ui::IconName {
4426            ui::IconName::AiOpenAi
4427        }
4428
4429        fn agent_id(&self) -> AgentId {
4430            AgentId::new("Codex CLI")
4431        }
4432
4433        fn connect(
4434            &self,
4435            _delegate: AgentServerDelegate,
4436            _project: Entity<Project>,
4437            _cx: &mut App,
4438        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
4439            Task::ready(Err(anyhow!(
4440                "extracting downloaded asset for \
4441                 https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\
4442                 codex-acp-0.9.4-aarch64-pc-windows-msvc.zip: \
4443                 failed to iterate over archive: Invalid gzip header"
4444            )))
4445        }
4446
4447        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4448            self
4449        }
4450    }
4451
4452    /// Agent server whose `connect()` fails while `fail` is `true` and
4453    /// returns the wrapped connection otherwise. Used to simulate the
4454    /// race where an external agent isn't yet registered at startup.
4455    pub(crate) struct FlakyAgentServer {
4456        connection: StubAgentConnection,
4457        fail: Arc<std::sync::atomic::AtomicBool>,
4458    }
4459
4460    impl FlakyAgentServer {
4461        pub(crate) fn new(
4462            connection: StubAgentConnection,
4463        ) -> (Self, Arc<std::sync::atomic::AtomicBool>) {
4464            let fail = Arc::new(std::sync::atomic::AtomicBool::new(true));
4465            (
4466                Self {
4467                    connection,
4468                    fail: fail.clone(),
4469                },
4470                fail,
4471            )
4472        }
4473    }
4474
4475    impl AgentServer for FlakyAgentServer {
4476        fn logo(&self) -> ui::IconName {
4477            ui::IconName::ZedAgent
4478        }
4479
4480        fn agent_id(&self) -> AgentId {
4481            "Flaky".into()
4482        }
4483
4484        fn connect(
4485            &self,
4486            _delegate: AgentServerDelegate,
4487            _project: Entity<Project>,
4488            _cx: &mut App,
4489        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
4490            if self.fail.load(std::sync::atomic::Ordering::SeqCst) {
4491                Task::ready(Err(anyhow!(
4492                    "Custom agent server `Flaky` is not registered"
4493                )))
4494            } else {
4495                Task::ready(Ok(Rc::new(self.connection.clone())))
4496            }
4497        }
4498
4499        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4500            self
4501        }
4502    }
4503
4504    fn build_test_thread(
4505        connection: Rc<dyn AgentConnection>,
4506        project: Entity<Project>,
4507        name: &'static str,
4508        session_id: acp::SessionId,
4509        cx: &mut App,
4510    ) -> Entity<AcpThread> {
4511        let action_log = cx.new(|_| ActionLog::new(project.clone()));
4512        cx.new(|cx| {
4513            AcpThread::new(
4514                None,
4515                Some(name.into()),
4516                None,
4517                connection,
4518                project,
4519                action_log,
4520                session_id,
4521                watch::Receiver::constant(
4522                    acp::PromptCapabilities::new()
4523                        .image(true)
4524                        .audio(true)
4525                        .embedded_context(true),
4526                ),
4527                cx,
4528            )
4529        })
4530    }
4531
4532    #[derive(Clone)]
4533    struct ResumeOnlyAgentConnection;
4534
4535    impl AgentConnection for ResumeOnlyAgentConnection {
4536        fn agent_id(&self) -> AgentId {
4537            AgentId::new("resume-only")
4538        }
4539
4540        fn telemetry_id(&self) -> SharedString {
4541            "resume-only".into()
4542        }
4543
4544        fn new_session(
4545            self: Rc<Self>,
4546            project: Entity<Project>,
4547            _work_dirs: PathList,
4548            cx: &mut gpui::App,
4549        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4550            let thread = build_test_thread(
4551                self,
4552                project,
4553                "ResumeOnlyAgentConnection",
4554                acp::SessionId::new("new-session"),
4555                cx,
4556            );
4557            Task::ready(Ok(thread))
4558        }
4559
4560        fn supports_resume_session(&self) -> bool {
4561            true
4562        }
4563
4564        fn resume_session(
4565            self: Rc<Self>,
4566            session_id: acp::SessionId,
4567            project: Entity<Project>,
4568            _work_dirs: PathList,
4569            _title: Option<SharedString>,
4570            cx: &mut App,
4571        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4572            let thread =
4573                build_test_thread(self, project, "ResumeOnlyAgentConnection", session_id, cx);
4574            Task::ready(Ok(thread))
4575        }
4576
4577        fn auth_methods(&self) -> &[acp::AuthMethod] {
4578            &[]
4579        }
4580
4581        fn authenticate(
4582            &self,
4583            _method_id: acp::AuthMethodId,
4584            _cx: &mut App,
4585        ) -> Task<gpui::Result<()>> {
4586            Task::ready(Ok(()))
4587        }
4588
4589        fn prompt(
4590            &self,
4591            _id: acp_thread::UserMessageId,
4592            _params: acp::PromptRequest,
4593            _cx: &mut App,
4594        ) -> Task<gpui::Result<acp::PromptResponse>> {
4595            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4596        }
4597
4598        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4599
4600        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4601            self
4602        }
4603    }
4604
4605    /// Simulates an agent that requires authentication before a session can be
4606    /// created. `new_session` returns `AuthRequired` until `authenticate` is
4607    /// called with the correct method, after which sessions are created normally.
4608    #[derive(Clone)]
4609    struct AuthGatedAgentConnection {
4610        authenticated: Arc<Mutex<bool>>,
4611        auth_method: acp::AuthMethod,
4612    }
4613
4614    impl AuthGatedAgentConnection {
4615        const AUTH_METHOD_ID: &str = "test-login";
4616
4617        fn new() -> Self {
4618            Self {
4619                authenticated: Arc::new(Mutex::new(false)),
4620                auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
4621                    Self::AUTH_METHOD_ID,
4622                    "Test Login",
4623                )),
4624            }
4625        }
4626    }
4627
4628    impl AgentConnection for AuthGatedAgentConnection {
4629        fn agent_id(&self) -> AgentId {
4630            AgentId::new("auth-gated")
4631        }
4632
4633        fn telemetry_id(&self) -> SharedString {
4634            "auth-gated".into()
4635        }
4636
4637        fn new_session(
4638            self: Rc<Self>,
4639            project: Entity<Project>,
4640            work_dirs: PathList,
4641            cx: &mut gpui::App,
4642        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4643            if !*self.authenticated.lock() {
4644                return Task::ready(Err(acp_thread::AuthRequired::new()
4645                    .with_description("Sign in to continue".to_string())
4646                    .into()));
4647            }
4648
4649            let session_id = acp::SessionId::new("auth-gated-session");
4650            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4651            Task::ready(Ok(cx.new(|cx| {
4652                AcpThread::new(
4653                    None,
4654                    None,
4655                    Some(work_dirs),
4656                    self,
4657                    project,
4658                    action_log,
4659                    session_id,
4660                    watch::Receiver::constant(
4661                        acp::PromptCapabilities::new()
4662                            .image(true)
4663                            .audio(true)
4664                            .embedded_context(true),
4665                    ),
4666                    cx,
4667                )
4668            })))
4669        }
4670
4671        fn auth_methods(&self) -> &[acp::AuthMethod] {
4672            std::slice::from_ref(&self.auth_method)
4673        }
4674
4675        fn authenticate(
4676            &self,
4677            method_id: acp::AuthMethodId,
4678            _cx: &mut App,
4679        ) -> Task<gpui::Result<()>> {
4680            if &method_id == self.auth_method.id() {
4681                *self.authenticated.lock() = true;
4682                Task::ready(Ok(()))
4683            } else {
4684                Task::ready(Err(anyhow::anyhow!("Unknown auth method")))
4685            }
4686        }
4687
4688        fn prompt(
4689            &self,
4690            _id: acp_thread::UserMessageId,
4691            _params: acp::PromptRequest,
4692            _cx: &mut App,
4693        ) -> Task<gpui::Result<acp::PromptResponse>> {
4694            unimplemented!()
4695        }
4696
4697        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4698            unimplemented!()
4699        }
4700
4701        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4702            self
4703        }
4704    }
4705
4706    /// Simulates a model which always returns a refusal response
4707    #[derive(Clone)]
4708    struct RefusalAgentConnection;
4709
4710    impl AgentConnection for RefusalAgentConnection {
4711        fn agent_id(&self) -> AgentId {
4712            AgentId::new("refusal")
4713        }
4714
4715        fn telemetry_id(&self) -> SharedString {
4716            "refusal".into()
4717        }
4718
4719        fn new_session(
4720            self: Rc<Self>,
4721            project: Entity<Project>,
4722            work_dirs: PathList,
4723            cx: &mut gpui::App,
4724        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4725            Task::ready(Ok(cx.new(|cx| {
4726                let action_log = cx.new(|_| ActionLog::new(project.clone()));
4727                AcpThread::new(
4728                    None,
4729                    None,
4730                    Some(work_dirs),
4731                    self,
4732                    project,
4733                    action_log,
4734                    acp::SessionId::new("test"),
4735                    watch::Receiver::constant(
4736                        acp::PromptCapabilities::new()
4737                            .image(true)
4738                            .audio(true)
4739                            .embedded_context(true),
4740                    ),
4741                    cx,
4742                )
4743            })))
4744        }
4745
4746        fn auth_methods(&self) -> &[acp::AuthMethod] {
4747            &[]
4748        }
4749
4750        fn authenticate(
4751            &self,
4752            _method_id: acp::AuthMethodId,
4753            _cx: &mut App,
4754        ) -> Task<gpui::Result<()>> {
4755            unimplemented!()
4756        }
4757
4758        fn prompt(
4759            &self,
4760            _id: acp_thread::UserMessageId,
4761            _params: acp::PromptRequest,
4762            _cx: &mut App,
4763        ) -> Task<gpui::Result<acp::PromptResponse>> {
4764            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal)))
4765        }
4766
4767        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4768            unimplemented!()
4769        }
4770
4771        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4772            self
4773        }
4774    }
4775
4776    #[derive(Clone)]
4777    struct CwdCapturingConnection {
4778        captured_work_dirs: Arc<Mutex<Option<PathList>>>,
4779    }
4780
4781    impl CwdCapturingConnection {
4782        fn new() -> Self {
4783            Self {
4784                captured_work_dirs: Arc::new(Mutex::new(None)),
4785            }
4786        }
4787    }
4788
4789    impl AgentConnection for CwdCapturingConnection {
4790        fn agent_id(&self) -> AgentId {
4791            AgentId::new("cwd-capturing")
4792        }
4793
4794        fn telemetry_id(&self) -> SharedString {
4795            "cwd-capturing".into()
4796        }
4797
4798        fn new_session(
4799            self: Rc<Self>,
4800            project: Entity<Project>,
4801            work_dirs: PathList,
4802            cx: &mut gpui::App,
4803        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4804            *self.captured_work_dirs.lock() = Some(work_dirs.clone());
4805            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4806            let thread = cx.new(|cx| {
4807                AcpThread::new(
4808                    None,
4809                    None,
4810                    Some(work_dirs),
4811                    self.clone(),
4812                    project,
4813                    action_log,
4814                    acp::SessionId::new("new-session"),
4815                    watch::Receiver::constant(
4816                        acp::PromptCapabilities::new()
4817                            .image(true)
4818                            .audio(true)
4819                            .embedded_context(true),
4820                    ),
4821                    cx,
4822                )
4823            });
4824            Task::ready(Ok(thread))
4825        }
4826
4827        fn supports_load_session(&self) -> bool {
4828            true
4829        }
4830
4831        fn load_session(
4832            self: Rc<Self>,
4833            session_id: acp::SessionId,
4834            project: Entity<Project>,
4835            work_dirs: PathList,
4836            _title: Option<SharedString>,
4837            cx: &mut App,
4838        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4839            *self.captured_work_dirs.lock() = Some(work_dirs.clone());
4840            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4841            let thread = cx.new(|cx| {
4842                AcpThread::new(
4843                    None,
4844                    None,
4845                    Some(work_dirs),
4846                    self.clone(),
4847                    project,
4848                    action_log,
4849                    session_id,
4850                    watch::Receiver::constant(
4851                        acp::PromptCapabilities::new()
4852                            .image(true)
4853                            .audio(true)
4854                            .embedded_context(true),
4855                    ),
4856                    cx,
4857                )
4858            });
4859            Task::ready(Ok(thread))
4860        }
4861
4862        fn auth_methods(&self) -> &[acp::AuthMethod] {
4863            &[]
4864        }
4865
4866        fn authenticate(
4867            &self,
4868            _method_id: acp::AuthMethodId,
4869            _cx: &mut App,
4870        ) -> Task<gpui::Result<()>> {
4871            Task::ready(Ok(()))
4872        }
4873
4874        fn prompt(
4875            &self,
4876            _id: acp_thread::UserMessageId,
4877            _params: acp::PromptRequest,
4878            _cx: &mut App,
4879        ) -> Task<gpui::Result<acp::PromptResponse>> {
4880            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4881        }
4882
4883        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4884
4885        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4886            self
4887        }
4888    }
4889
4890    pub(crate) fn init_test(cx: &mut TestAppContext) {
4891        cx.update(|cx| {
4892            let settings_store = SettingsStore::test(cx);
4893            cx.set_global(settings_store);
4894            ThreadMetadataStore::init_global(cx);
4895            theme_settings::init(theme::LoadThemes::JustBase, cx);
4896            editor::init(cx);
4897            agent_panel::init(cx);
4898            release_channel::init(semver::Version::new(0, 0, 0), cx);
4899            prompt_store::init(cx)
4900        });
4901    }
4902
4903    fn active_thread(
4904        conversation_view: &Entity<ConversationView>,
4905        cx: &TestAppContext,
4906    ) -> Entity<ThreadView> {
4907        cx.read(|cx| {
4908            conversation_view
4909                .read(cx)
4910                .active_thread()
4911                .expect("No active thread")
4912                .clone()
4913        })
4914    }
4915
4916    fn message_editor(
4917        conversation_view: &Entity<ConversationView>,
4918        cx: &TestAppContext,
4919    ) -> Entity<MessageEditor> {
4920        let thread = active_thread(conversation_view, cx);
4921        cx.read(|cx| thread.read(cx).message_editor.clone())
4922    }
4923
4924    #[gpui::test]
4925    async fn test_rewind_views(cx: &mut TestAppContext) {
4926        init_test(cx);
4927
4928        let fs = FakeFs::new(cx.executor());
4929        fs.insert_tree(
4930            "/project",
4931            json!({
4932                "test1.txt": "old content 1",
4933                "test2.txt": "old content 2"
4934            }),
4935        )
4936        .await;
4937        let project = Project::test(fs, [Path::new("/project")], cx).await;
4938        let (multi_workspace, cx) =
4939            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4940        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4941
4942        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4943        let connection_store =
4944            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4945
4946        let connection = Rc::new(StubAgentConnection::new());
4947        let conversation_view = cx.update(|window, cx| {
4948            cx.new(|cx| {
4949                ConversationView::new(
4950                    Rc::new(StubAgentServer::new(connection.as_ref().clone())),
4951                    connection_store,
4952                    Agent::Custom { id: "Test".into() },
4953                    None,
4954                    None,
4955                    None,
4956                    None,
4957                    None,
4958                    workspace.downgrade(),
4959                    project.clone(),
4960                    Some(thread_store.clone()),
4961                    None,
4962                    "agent_panel",
4963                    window,
4964                    cx,
4965                )
4966            })
4967        });
4968
4969        cx.run_until_parked();
4970
4971        let thread = conversation_view
4972            .read_with(cx, |view, cx| {
4973                view.active_thread().map(|r| r.read(cx).thread.clone())
4974            })
4975            .unwrap();
4976
4977        // First user message
4978        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4979            acp::ToolCall::new("tool1", "Edit file 1")
4980                .kind(acp::ToolKind::Edit)
4981                .status(acp::ToolCallStatus::Completed)
4982                .content(vec![acp::ToolCallContent::Diff(
4983                    acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"),
4984                )]),
4985        )]);
4986
4987        thread
4988            .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
4989            .await
4990            .unwrap();
4991        cx.run_until_parked();
4992
4993        thread.read_with(cx, |thread, _cx| {
4994            assert_eq!(thread.entries().len(), 2);
4995        });
4996
4997        conversation_view.read_with(cx, |view, cx| {
4998            let entry_view_state = view
4999                .active_thread()
5000                .map(|active| active.read(cx).entry_view_state.clone())
5001                .unwrap();
5002            entry_view_state.read_with(cx, |entry_view_state, _| {
5003                assert!(
5004                    entry_view_state
5005                        .entry(0)
5006                        .unwrap()
5007                        .message_editor()
5008                        .is_some()
5009                );
5010                assert!(entry_view_state.entry(1).unwrap().has_content());
5011            });
5012        });
5013
5014        // Second user message
5015        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
5016            acp::ToolCall::new("tool2", "Edit file 2")
5017                .kind(acp::ToolKind::Edit)
5018                .status(acp::ToolCallStatus::Completed)
5019                .content(vec![acp::ToolCallContent::Diff(
5020                    acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"),
5021                )]),
5022        )]);
5023
5024        thread
5025            .update(cx, |thread, cx| thread.send_raw("Another one", cx))
5026            .await
5027            .unwrap();
5028        cx.run_until_parked();
5029
5030        let second_user_message_id = thread.read_with(cx, |thread, _| {
5031            assert_eq!(thread.entries().len(), 4);
5032            let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
5033                panic!();
5034            };
5035            user_message.id.clone().unwrap()
5036        });
5037
5038        conversation_view.read_with(cx, |view, cx| {
5039            let entry_view_state = view
5040                .active_thread()
5041                .unwrap()
5042                .read(cx)
5043                .entry_view_state
5044                .clone();
5045            entry_view_state.read_with(cx, |entry_view_state, _| {
5046                assert!(
5047                    entry_view_state
5048                        .entry(0)
5049                        .unwrap()
5050                        .message_editor()
5051                        .is_some()
5052                );
5053                assert!(entry_view_state.entry(1).unwrap().has_content());
5054                assert!(
5055                    entry_view_state
5056                        .entry(2)
5057                        .unwrap()
5058                        .message_editor()
5059                        .is_some()
5060                );
5061                assert!(entry_view_state.entry(3).unwrap().has_content());
5062            });
5063        });
5064
5065        // Rewind to first message
5066        thread
5067            .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
5068            .await
5069            .unwrap();
5070
5071        cx.run_until_parked();
5072
5073        thread.read_with(cx, |thread, _| {
5074            assert_eq!(thread.entries().len(), 2);
5075        });
5076
5077        conversation_view.read_with(cx, |view, cx| {
5078            let active = view.active_thread().unwrap();
5079            active
5080                .read(cx)
5081                .entry_view_state
5082                .read_with(cx, |entry_view_state, _| {
5083                    assert!(
5084                        entry_view_state
5085                            .entry(0)
5086                            .unwrap()
5087                            .message_editor()
5088                            .is_some()
5089                    );
5090                    assert!(entry_view_state.entry(1).unwrap().has_content());
5091
5092                    // Old views should be dropped
5093                    assert!(entry_view_state.entry(2).is_none());
5094                    assert!(entry_view_state.entry(3).is_none());
5095                });
5096        });
5097    }
5098
5099    #[gpui::test]
5100    async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
5101        init_test(cx);
5102
5103        let connection = StubAgentConnection::new();
5104
5105        // Each user prompt will result in a user message entry plus an agent message entry.
5106        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5107            acp::ContentChunk::new("Response 1".into()),
5108        )]);
5109
5110        let (conversation_view, cx) =
5111            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5112
5113        let thread = conversation_view
5114            .read_with(cx, |view, cx| {
5115                view.active_thread().map(|r| r.read(cx).thread.clone())
5116            })
5117            .unwrap();
5118
5119        thread
5120            .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
5121            .await
5122            .unwrap();
5123        cx.run_until_parked();
5124
5125        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5126            acp::ContentChunk::new("Response 2".into()),
5127        )]);
5128
5129        thread
5130            .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
5131            .await
5132            .unwrap();
5133        cx.run_until_parked();
5134
5135        // Move somewhere else first so we're not trivially already on the last user prompt.
5136        active_thread(&conversation_view, cx).update(cx, |view, cx| {
5137            view.scroll_to_top(cx);
5138        });
5139        cx.run_until_parked();
5140
5141        active_thread(&conversation_view, cx).update(cx, |view, cx| {
5142            view.scroll_to_most_recent_user_prompt(cx);
5143            let scroll_top = view.list_state.logical_scroll_top();
5144            // Entries layout is: [User1, Assistant1, User2, Assistant2]
5145            assert_eq!(scroll_top.item_ix, 2);
5146        });
5147    }
5148
5149    #[gpui::test]
5150    async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
5151        cx: &mut TestAppContext,
5152    ) {
5153        init_test(cx);
5154
5155        let (conversation_view, cx) =
5156            setup_conversation_view(StubAgentServer::default_response(), cx).await;
5157
5158        // With no entries, scrolling should be a no-op and must not panic.
5159        active_thread(&conversation_view, cx).update(cx, |view, cx| {
5160            view.scroll_to_most_recent_user_prompt(cx);
5161            let scroll_top = view.list_state.logical_scroll_top();
5162            assert_eq!(scroll_top.item_ix, 0);
5163        });
5164    }
5165
5166    #[gpui::test]
5167    async fn test_message_editing_cancel(cx: &mut TestAppContext) {
5168        init_test(cx);
5169
5170        let connection = StubAgentConnection::new();
5171
5172        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5173            acp::ContentChunk::new("Response".into()),
5174        )]);
5175
5176        let (conversation_view, cx) =
5177            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5178        add_to_workspace(conversation_view.clone(), cx);
5179
5180        let message_editor = message_editor(&conversation_view, cx);
5181        message_editor.update_in(cx, |editor, window, cx| {
5182            editor.set_text("Original message to edit", window, cx);
5183        });
5184        active_thread(&conversation_view, cx)
5185            .update_in(cx, |view, window, cx| view.send(window, cx));
5186
5187        cx.run_until_parked();
5188
5189        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
5190            assert_eq!(
5191                view.active_thread()
5192                    .and_then(|active| active.read(cx).editing_message),
5193                None
5194            );
5195
5196            view.active_thread()
5197                .map(|active| &active.read(cx).entry_view_state)
5198                .as_ref()
5199                .unwrap()
5200                .read(cx)
5201                .entry(0)
5202                .unwrap()
5203                .message_editor()
5204                .unwrap()
5205                .clone()
5206        });
5207
5208        // Focus
5209        cx.focus(&user_message_editor);
5210        conversation_view.read_with(cx, |view, cx| {
5211            assert_eq!(
5212                view.active_thread()
5213                    .and_then(|active| active.read(cx).editing_message),
5214                Some(0)
5215            );
5216        });
5217
5218        // Edit
5219        user_message_editor.update_in(cx, |editor, window, cx| {
5220            editor.set_text("Edited message content", window, cx);
5221        });
5222
5223        // Cancel
5224        user_message_editor.update_in(cx, |_editor, window, cx| {
5225            window.dispatch_action(Box::new(editor::actions::Cancel), cx);
5226        });
5227
5228        conversation_view.read_with(cx, |view, cx| {
5229            assert_eq!(
5230                view.active_thread()
5231                    .and_then(|active| active.read(cx).editing_message),
5232                None
5233            );
5234        });
5235
5236        user_message_editor.read_with(cx, |editor, cx| {
5237            assert_eq!(editor.text(cx), "Original message to edit");
5238        });
5239    }
5240
5241    #[gpui::test]
5242    async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
5243        init_test(cx);
5244
5245        let connection = StubAgentConnection::new();
5246
5247        let (conversation_view, cx) =
5248            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5249        add_to_workspace(conversation_view.clone(), cx);
5250
5251        let message_editor = message_editor(&conversation_view, cx);
5252        message_editor.update_in(cx, |editor, window, cx| {
5253            editor.set_text("", window, cx);
5254        });
5255
5256        let thread = cx.read(|cx| {
5257            conversation_view
5258                .read(cx)
5259                .active_thread()
5260                .unwrap()
5261                .read(cx)
5262                .thread
5263                .clone()
5264        });
5265        let entries_before = cx.read(|cx| thread.read(cx).entries().len());
5266
5267        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
5268            view.send(window, cx);
5269        });
5270        cx.run_until_parked();
5271
5272        let entries_after = cx.read(|cx| thread.read(cx).entries().len());
5273        assert_eq!(
5274            entries_before, entries_after,
5275            "No message should be sent when editor is empty"
5276        );
5277    }
5278
5279    #[gpui::test]
5280    async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
5281        init_test(cx);
5282
5283        let connection = StubAgentConnection::new();
5284
5285        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5286            acp::ContentChunk::new("Response".into()),
5287        )]);
5288
5289        let (conversation_view, cx) =
5290            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5291        add_to_workspace(conversation_view.clone(), cx);
5292
5293        let message_editor = message_editor(&conversation_view, cx);
5294        message_editor.update_in(cx, |editor, window, cx| {
5295            editor.set_text("Original message to edit", window, cx);
5296        });
5297        active_thread(&conversation_view, cx)
5298            .update_in(cx, |view, window, cx| view.send(window, cx));
5299
5300        cx.run_until_parked();
5301
5302        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
5303            assert_eq!(
5304                view.active_thread()
5305                    .and_then(|active| active.read(cx).editing_message),
5306                None
5307            );
5308            assert_eq!(
5309                view.active_thread()
5310                    .unwrap()
5311                    .read(cx)
5312                    .thread
5313                    .read(cx)
5314                    .entries()
5315                    .len(),
5316                2
5317            );
5318
5319            view.active_thread()
5320                .map(|active| &active.read(cx).entry_view_state)
5321                .as_ref()
5322                .unwrap()
5323                .read(cx)
5324                .entry(0)
5325                .unwrap()
5326                .message_editor()
5327                .unwrap()
5328                .clone()
5329        });
5330
5331        // Focus
5332        cx.focus(&user_message_editor);
5333
5334        // Edit
5335        user_message_editor.update_in(cx, |editor, window, cx| {
5336            editor.set_text("Edited message content", window, cx);
5337        });
5338
5339        // Send
5340        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5341            acp::ContentChunk::new("New Response".into()),
5342        )]);
5343
5344        user_message_editor.update_in(cx, |_editor, window, cx| {
5345            window.dispatch_action(Box::new(Chat), cx);
5346        });
5347
5348        cx.run_until_parked();
5349
5350        conversation_view.read_with(cx, |view, cx| {
5351            assert_eq!(
5352                view.active_thread()
5353                    .and_then(|active| active.read(cx).editing_message),
5354                None
5355            );
5356
5357            let entries = view
5358                .active_thread()
5359                .unwrap()
5360                .read(cx)
5361                .thread
5362                .read(cx)
5363                .entries();
5364            assert_eq!(entries.len(), 2);
5365            assert_eq!(
5366                entries[0].to_markdown(cx),
5367                "## User\n\nEdited message content\n\n"
5368            );
5369            assert_eq!(
5370                entries[1].to_markdown(cx),
5371                "## Assistant\n\nNew Response\n\n"
5372            );
5373
5374            let entry_view_state = view
5375                .active_thread()
5376                .map(|active| &active.read(cx).entry_view_state)
5377                .unwrap();
5378            let new_editor = entry_view_state.read_with(cx, |state, _cx| {
5379                assert!(!state.entry(1).unwrap().has_content());
5380                state.entry(0).unwrap().message_editor().unwrap().clone()
5381            });
5382
5383            assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
5384        })
5385    }
5386
5387    #[gpui::test]
5388    async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
5389        init_test(cx);
5390
5391        let connection = StubAgentConnection::new();
5392
5393        let (conversation_view, cx) =
5394            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5395        add_to_workspace(conversation_view.clone(), cx);
5396
5397        let message_editor = message_editor(&conversation_view, cx);
5398        message_editor.update_in(cx, |editor, window, cx| {
5399            editor.set_text("Original message to edit", window, cx);
5400        });
5401        active_thread(&conversation_view, cx)
5402            .update_in(cx, |view, window, cx| view.send(window, cx));
5403
5404        cx.run_until_parked();
5405
5406        let (user_message_editor, session_id) = conversation_view.read_with(cx, |view, cx| {
5407            let thread = view.active_thread().unwrap().read(cx).thread.read(cx);
5408            assert_eq!(thread.entries().len(), 1);
5409
5410            let editor = view
5411                .active_thread()
5412                .map(|active| &active.read(cx).entry_view_state)
5413                .as_ref()
5414                .unwrap()
5415                .read(cx)
5416                .entry(0)
5417                .unwrap()
5418                .message_editor()
5419                .unwrap()
5420                .clone();
5421
5422            (editor, thread.session_id().clone())
5423        });
5424
5425        // Focus
5426        cx.focus(&user_message_editor);
5427
5428        conversation_view.read_with(cx, |view, cx| {
5429            assert_eq!(
5430                view.active_thread()
5431                    .and_then(|active| active.read(cx).editing_message),
5432                Some(0)
5433            );
5434        });
5435
5436        // Edit
5437        user_message_editor.update_in(cx, |editor, window, cx| {
5438            editor.set_text("Edited message content", window, cx);
5439        });
5440
5441        conversation_view.read_with(cx, |view, cx| {
5442            assert_eq!(
5443                view.active_thread()
5444                    .and_then(|active| active.read(cx).editing_message),
5445                Some(0)
5446            );
5447        });
5448
5449        // Finish streaming response
5450        cx.update(|_, cx| {
5451            connection.send_update(
5452                session_id.clone(),
5453                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())),
5454                cx,
5455            );
5456            connection.end_turn(session_id, acp::StopReason::EndTurn);
5457        });
5458
5459        conversation_view.read_with(cx, |view, cx| {
5460            assert_eq!(
5461                view.active_thread()
5462                    .and_then(|active| active.read(cx).editing_message),
5463                Some(0)
5464            );
5465        });
5466
5467        cx.run_until_parked();
5468
5469        // Should still be editing
5470        cx.update(|window, cx| {
5471            assert!(user_message_editor.focus_handle(cx).is_focused(window));
5472            assert_eq!(
5473                conversation_view
5474                    .read(cx)
5475                    .active_thread()
5476                    .and_then(|active| active.read(cx).editing_message),
5477                Some(0)
5478            );
5479            assert_eq!(
5480                user_message_editor.read(cx).text(cx),
5481                "Edited message content"
5482            );
5483        });
5484    }
5485
5486    #[gpui::test]
5487    async fn test_stale_stop_does_not_disable_follow_tail_during_regenerate(
5488        cx: &mut TestAppContext,
5489    ) {
5490        init_test(cx);
5491
5492        let connection = StubAgentConnection::new();
5493
5494        let (conversation_view, cx) =
5495            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5496        add_to_workspace(conversation_view.clone(), cx);
5497
5498        let message_editor = message_editor(&conversation_view, cx);
5499        message_editor.update_in(cx, |editor, window, cx| {
5500            editor.set_text("Original message to edit", window, cx);
5501        });
5502        active_thread(&conversation_view, cx)
5503            .update_in(cx, |view, window, cx| view.send(window, cx));
5504
5505        cx.run_until_parked();
5506
5507        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
5508            view.active_thread()
5509                .map(|active| &active.read(cx).entry_view_state)
5510                .as_ref()
5511                .unwrap()
5512                .read(cx)
5513                .entry(0)
5514                .unwrap()
5515                .message_editor()
5516                .unwrap()
5517                .clone()
5518        });
5519
5520        cx.focus(&user_message_editor);
5521        user_message_editor.update_in(cx, |editor, window, cx| {
5522            editor.set_text("Edited message content", window, cx);
5523        });
5524
5525        user_message_editor.update_in(cx, |_editor, window, cx| {
5526            window.dispatch_action(Box::new(Chat), cx);
5527        });
5528
5529        cx.run_until_parked();
5530
5531        conversation_view.read_with(cx, |view, cx| {
5532            let active = view.active_thread().unwrap();
5533            let active = active.read(cx);
5534
5535            assert_eq!(active.thread.read(cx).status(), ThreadStatus::Generating);
5536            assert!(
5537                active.list_state.is_following_tail(),
5538                "stale stop events from the cancelled turn must not disable follow-tail for the new turn"
5539            );
5540        });
5541    }
5542
5543    struct GeneratingThreadSetup {
5544        conversation_view: Entity<ConversationView>,
5545        thread: Entity<AcpThread>,
5546        message_editor: Entity<MessageEditor>,
5547    }
5548
5549    async fn setup_generating_thread(
5550        cx: &mut TestAppContext,
5551    ) -> (GeneratingThreadSetup, &mut VisualTestContext) {
5552        let connection = StubAgentConnection::new();
5553
5554        let (conversation_view, cx) =
5555            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5556        add_to_workspace(conversation_view.clone(), cx);
5557
5558        let message_editor = message_editor(&conversation_view, cx);
5559        message_editor.update_in(cx, |editor, window, cx| {
5560            editor.set_text("Hello", window, cx);
5561        });
5562        active_thread(&conversation_view, cx)
5563            .update_in(cx, |view, window, cx| view.send(window, cx));
5564
5565        let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
5566            let thread = view
5567                .active_thread()
5568                .as_ref()
5569                .unwrap()
5570                .read(cx)
5571                .thread
5572                .clone();
5573            (thread.clone(), thread.read(cx).session_id().clone())
5574        });
5575
5576        cx.run_until_parked();
5577
5578        cx.update(|_, cx| {
5579            connection.send_update(
5580                session_id.clone(),
5581                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5582                    "Response chunk".into(),
5583                )),
5584                cx,
5585            );
5586        });
5587
5588        cx.run_until_parked();
5589
5590        thread.read_with(cx, |thread, _cx| {
5591            assert_eq!(thread.status(), ThreadStatus::Generating);
5592        });
5593
5594        (
5595            GeneratingThreadSetup {
5596                conversation_view,
5597                thread,
5598                message_editor,
5599            },
5600            cx,
5601        )
5602    }
5603
5604    #[gpui::test]
5605    async fn test_escape_cancels_generation_from_conversation_focus(cx: &mut TestAppContext) {
5606        init_test(cx);
5607
5608        let (setup, cx) = setup_generating_thread(cx).await;
5609
5610        let focus_handle = setup
5611            .conversation_view
5612            .read_with(cx, |view, cx| view.focus_handle(cx));
5613        cx.update(|window, cx| {
5614            window.focus(&focus_handle, cx);
5615        });
5616
5617        setup.conversation_view.update_in(cx, |_, window, cx| {
5618            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
5619        });
5620
5621        cx.run_until_parked();
5622
5623        setup.thread.read_with(cx, |thread, _cx| {
5624            assert_eq!(thread.status(), ThreadStatus::Idle);
5625        });
5626    }
5627
5628    #[gpui::test]
5629    async fn test_escape_cancels_generation_from_editor_focus(cx: &mut TestAppContext) {
5630        init_test(cx);
5631
5632        let (setup, cx) = setup_generating_thread(cx).await;
5633
5634        let editor_focus_handle = setup
5635            .message_editor
5636            .read_with(cx, |editor, cx| editor.focus_handle(cx));
5637        cx.update(|window, cx| {
5638            window.focus(&editor_focus_handle, cx);
5639        });
5640
5641        setup.message_editor.update_in(cx, |_, window, cx| {
5642            window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx);
5643        });
5644
5645        cx.run_until_parked();
5646
5647        setup.thread.read_with(cx, |thread, _cx| {
5648            assert_eq!(thread.status(), ThreadStatus::Idle);
5649        });
5650    }
5651
5652    #[gpui::test]
5653    async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) {
5654        init_test(cx);
5655
5656        let (conversation_view, cx) =
5657            setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
5658        add_to_workspace(conversation_view.clone(), cx);
5659
5660        let thread = conversation_view.read_with(cx, |view, cx| {
5661            view.active_thread().unwrap().read(cx).thread.clone()
5662        });
5663
5664        thread.read_with(cx, |thread, _cx| {
5665            assert_eq!(thread.status(), ThreadStatus::Idle);
5666        });
5667
5668        let focus_handle = conversation_view.read_with(cx, |view, _cx| view.focus_handle.clone());
5669        cx.update(|window, cx| {
5670            window.focus(&focus_handle, cx);
5671        });
5672
5673        conversation_view.update_in(cx, |_, window, cx| {
5674            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
5675        });
5676
5677        cx.run_until_parked();
5678
5679        thread.read_with(cx, |thread, _cx| {
5680            assert_eq!(thread.status(), ThreadStatus::Idle);
5681        });
5682    }
5683
5684    #[gpui::test]
5685    async fn test_interrupt(cx: &mut TestAppContext) {
5686        init_test(cx);
5687
5688        let connection = StubAgentConnection::new();
5689
5690        let (conversation_view, cx) =
5691            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5692        add_to_workspace(conversation_view.clone(), cx);
5693
5694        let message_editor = message_editor(&conversation_view, cx);
5695        message_editor.update_in(cx, |editor, window, cx| {
5696            editor.set_text("Message 1", window, cx);
5697        });
5698        active_thread(&conversation_view, cx)
5699            .update_in(cx, |view, window, cx| view.send(window, cx));
5700
5701        let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
5702            let thread = view.active_thread().unwrap().read(cx).thread.clone();
5703
5704            (thread.clone(), thread.read(cx).session_id().clone())
5705        });
5706
5707        cx.run_until_parked();
5708
5709        cx.update(|_, cx| {
5710            connection.send_update(
5711                session_id.clone(),
5712                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5713                    "Message 1 resp".into(),
5714                )),
5715                cx,
5716            );
5717        });
5718
5719        cx.run_until_parked();
5720
5721        thread.read_with(cx, |thread, cx| {
5722            assert_eq!(
5723                thread.to_markdown(cx),
5724                indoc::indoc! {"
5725                        ## User
5726
5727                        Message 1
5728
5729                        ## Assistant
5730
5731                        Message 1 resp
5732
5733                    "}
5734            )
5735        });
5736
5737        message_editor.update_in(cx, |editor, window, cx| {
5738            editor.set_text("Message 2", window, cx);
5739        });
5740        active_thread(&conversation_view, cx)
5741            .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx));
5742
5743        cx.update(|_, cx| {
5744            // Simulate a response sent after beginning to cancel
5745            connection.send_update(
5746                session_id.clone(),
5747                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())),
5748                cx,
5749            );
5750        });
5751
5752        cx.run_until_parked();
5753
5754        // Last Message 1 response should appear before Message 2
5755        thread.read_with(cx, |thread, cx| {
5756            assert_eq!(
5757                thread.to_markdown(cx),
5758                indoc::indoc! {"
5759                        ## User
5760
5761                        Message 1
5762
5763                        ## Assistant
5764
5765                        Message 1 response
5766
5767                        ## User
5768
5769                        Message 2
5770
5771                    "}
5772            )
5773        });
5774
5775        cx.update(|_, cx| {
5776            connection.send_update(
5777                session_id.clone(),
5778                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5779                    "Message 2 response".into(),
5780                )),
5781                cx,
5782            );
5783            connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5784        });
5785
5786        cx.run_until_parked();
5787
5788        thread.read_with(cx, |thread, cx| {
5789            assert_eq!(
5790                thread.to_markdown(cx),
5791                indoc::indoc! {"
5792                        ## User
5793
5794                        Message 1
5795
5796                        ## Assistant
5797
5798                        Message 1 response
5799
5800                        ## User
5801
5802                        Message 2
5803
5804                        ## Assistant
5805
5806                        Message 2 response
5807
5808                    "}
5809            )
5810        });
5811    }
5812
5813    #[gpui::test]
5814    async fn test_message_editing_insert_selections(cx: &mut TestAppContext) {
5815        init_test(cx);
5816
5817        let connection = StubAgentConnection::new();
5818        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5819            acp::ContentChunk::new("Response".into()),
5820        )]);
5821
5822        let (conversation_view, cx) =
5823            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5824        add_to_workspace(conversation_view.clone(), cx);
5825
5826        let message_editor = message_editor(&conversation_view, cx);
5827        message_editor.update_in(cx, |editor, window, cx| {
5828            editor.set_text("Original message to edit", window, cx)
5829        });
5830        active_thread(&conversation_view, cx)
5831            .update_in(cx, |view, window, cx| view.send(window, cx));
5832        cx.run_until_parked();
5833
5834        let user_message_editor = conversation_view.read_with(cx, |conversation_view, cx| {
5835            conversation_view
5836                .active_thread()
5837                .map(|active| &active.read(cx).entry_view_state)
5838                .as_ref()
5839                .unwrap()
5840                .read(cx)
5841                .entry(0)
5842                .expect("Should have at least one entry")
5843                .message_editor()
5844                .expect("Should have message editor")
5845                .clone()
5846        });
5847
5848        cx.focus(&user_message_editor);
5849        conversation_view.read_with(cx, |view, cx| {
5850            assert_eq!(
5851                view.active_thread()
5852                    .and_then(|active| active.read(cx).editing_message),
5853                Some(0)
5854            );
5855        });
5856
5857        // Ensure to edit the focused message before proceeding otherwise, since
5858        // its content is not different from what was sent, focus will be lost.
5859        user_message_editor.update_in(cx, |editor, window, cx| {
5860            editor.set_text("Original message to edit with ", window, cx)
5861        });
5862
5863        // Create a simple buffer with some text so we can create a selection
5864        // that will then be added to the message being edited.
5865        let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
5866            (
5867                conversation_view.workspace.clone(),
5868                conversation_view.project.clone(),
5869            )
5870        });
5871        let buffer = project.update(cx, |project, cx| {
5872            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5873        });
5874
5875        workspace
5876            .update_in(cx, |workspace, window, cx| {
5877                let editor = cx.new(|cx| {
5878                    let mut editor =
5879                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5880
5881                    editor.change_selections(Default::default(), window, cx, |selections| {
5882                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5883                    });
5884
5885                    editor
5886                });
5887                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5888            })
5889            .unwrap();
5890
5891        conversation_view.update_in(cx, |view, window, cx| {
5892            assert_eq!(
5893                view.active_thread()
5894                    .and_then(|active| active.read(cx).editing_message),
5895                Some(0)
5896            );
5897            view.insert_selections(window, cx);
5898        });
5899
5900        user_message_editor.read_with(cx, |editor, cx| {
5901            let text = editor.editor().read(cx).text(cx);
5902            let expected_text = String::from("Original message to edit with selection ");
5903
5904            assert_eq!(text, expected_text);
5905        });
5906    }
5907
5908    #[gpui::test]
5909    async fn test_insert_selections(cx: &mut TestAppContext) {
5910        init_test(cx);
5911
5912        let connection = StubAgentConnection::new();
5913        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5914            acp::ContentChunk::new("Response".into()),
5915        )]);
5916
5917        let (conversation_view, cx) =
5918            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5919        add_to_workspace(conversation_view.clone(), cx);
5920
5921        let message_editor = message_editor(&conversation_view, cx);
5922        message_editor.update_in(cx, |editor, window, cx| {
5923            editor.set_text("Can you review this snippet ", window, cx)
5924        });
5925
5926        // Create a simple buffer with some text so we can create a selection
5927        // that will then be added to the message being edited.
5928        let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
5929            (
5930                conversation_view.workspace.clone(),
5931                conversation_view.project.clone(),
5932            )
5933        });
5934        let buffer = project.update(cx, |project, cx| {
5935            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5936        });
5937
5938        workspace
5939            .update_in(cx, |workspace, window, cx| {
5940                let editor = cx.new(|cx| {
5941                    let mut editor =
5942                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5943
5944                    editor.change_selections(Default::default(), window, cx, |selections| {
5945                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5946                    });
5947
5948                    editor
5949                });
5950                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5951            })
5952            .unwrap();
5953
5954        conversation_view.update_in(cx, |view, window, cx| {
5955            assert_eq!(
5956                view.active_thread()
5957                    .and_then(|active| active.read(cx).editing_message),
5958                None
5959            );
5960            view.insert_selections(window, cx);
5961        });
5962
5963        message_editor.read_with(cx, |editor, cx| {
5964            let text = editor.text(cx);
5965            let expected_txt = String::from("Can you review this snippet selection ");
5966
5967            assert_eq!(text, expected_txt);
5968        })
5969    }
5970
5971    #[gpui::test]
5972    async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
5973        init_test(cx);
5974
5975        let tool_call_id = acp::ToolCallId::new("terminal-1");
5976        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`")
5977            .kind(acp::ToolKind::Edit);
5978
5979        let permission_options = ToolPermissionContext::new(
5980            TerminalTool::NAME,
5981            vec!["cargo build --release".to_string()],
5982        )
5983        .build_permission_options();
5984
5985        let connection =
5986            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5987                tool_call_id.clone(),
5988                permission_options,
5989            )]));
5990
5991        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5992
5993        let (conversation_view, cx) =
5994            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5995
5996        // Disable notifications to avoid popup windows
5997        cx.update(|_window, cx| {
5998            AgentSettings::override_global(
5999                AgentSettings {
6000                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6001                    ..AgentSettings::get_global(cx).clone()
6002                },
6003                cx,
6004            );
6005        });
6006
6007        let message_editor = message_editor(&conversation_view, cx);
6008        message_editor.update_in(cx, |editor, window, cx| {
6009            editor.set_text("Run cargo build", window, cx);
6010        });
6011
6012        active_thread(&conversation_view, cx)
6013            .update_in(cx, |view, window, cx| view.send(window, cx));
6014
6015        cx.run_until_parked();
6016
6017        // Verify the tool call is in WaitingForConfirmation state with the expected options
6018        conversation_view.read_with(cx, |conversation_view, cx| {
6019            let thread = conversation_view
6020                .active_thread()
6021                .expect("Thread should exist")
6022                .read(cx)
6023                .thread
6024                .clone();
6025            let thread = thread.read(cx);
6026
6027            let tool_call = thread.entries().iter().find_map(|entry| {
6028                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
6029                    Some(call)
6030                } else {
6031                    None
6032                }
6033            });
6034
6035            assert!(tool_call.is_some(), "Expected a tool call entry");
6036            let tool_call = tool_call.unwrap();
6037
6038            // Verify it's waiting for confirmation
6039            assert!(
6040                matches!(
6041                    tool_call.status,
6042                    acp_thread::ToolCallStatus::WaitingForConfirmation { .. }
6043                ),
6044                "Expected WaitingForConfirmation status, got {:?}",
6045                tool_call.status
6046            );
6047
6048            // Verify the options count (granularity options only, no separate Deny option)
6049            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
6050                &tool_call.status
6051            {
6052                let PermissionOptions::Dropdown(choices) = options else {
6053                    panic!("Expected dropdown permission options");
6054                };
6055
6056                assert_eq!(
6057                    choices.len(),
6058                    3,
6059                    "Expected 3 permission options (granularity only)"
6060                );
6061
6062                // Verify specific button labels (now using neutral names)
6063                let labels: Vec<&str> = choices
6064                    .iter()
6065                    .map(|choice| choice.allow.name.as_ref())
6066                    .collect();
6067                assert!(
6068                    labels.contains(&"Always for terminal"),
6069                    "Missing 'Always for terminal' option"
6070                );
6071                assert!(
6072                    labels.contains(&"Always for `cargo build` commands"),
6073                    "Missing pattern option"
6074                );
6075                assert!(
6076                    labels.contains(&"Only this time"),
6077                    "Missing 'Only this time' option"
6078                );
6079            }
6080        });
6081    }
6082
6083    #[gpui::test]
6084    async fn test_tool_permission_buttons_edit_file_with_path_pattern(cx: &mut TestAppContext) {
6085        init_test(cx);
6086
6087        let tool_call_id = acp::ToolCallId::new("edit-file-1");
6088        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`")
6089            .kind(acp::ToolKind::Edit);
6090
6091        let permission_options =
6092            ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()])
6093                .build_permission_options();
6094
6095        let connection =
6096            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6097                tool_call_id.clone(),
6098                permission_options,
6099            )]));
6100
6101        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6102
6103        let (conversation_view, cx) =
6104            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6105
6106        // Disable notifications
6107        cx.update(|_window, cx| {
6108            AgentSettings::override_global(
6109                AgentSettings {
6110                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6111                    ..AgentSettings::get_global(cx).clone()
6112                },
6113                cx,
6114            );
6115        });
6116
6117        let message_editor = message_editor(&conversation_view, cx);
6118        message_editor.update_in(cx, |editor, window, cx| {
6119            editor.set_text("Edit the main file", window, cx);
6120        });
6121
6122        active_thread(&conversation_view, cx)
6123            .update_in(cx, |view, window, cx| view.send(window, cx));
6124
6125        cx.run_until_parked();
6126
6127        // Verify the options
6128        conversation_view.read_with(cx, |conversation_view, cx| {
6129            let thread = conversation_view
6130                .active_thread()
6131                .expect("Thread should exist")
6132                .read(cx)
6133                .thread
6134                .clone();
6135            let thread = thread.read(cx);
6136
6137            let tool_call = thread.entries().iter().find_map(|entry| {
6138                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
6139                    Some(call)
6140                } else {
6141                    None
6142                }
6143            });
6144
6145            assert!(tool_call.is_some(), "Expected a tool call entry");
6146            let tool_call = tool_call.unwrap();
6147
6148            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
6149                &tool_call.status
6150            {
6151                let PermissionOptions::Dropdown(choices) = options else {
6152                    panic!("Expected dropdown permission options");
6153                };
6154
6155                let labels: Vec<&str> = choices
6156                    .iter()
6157                    .map(|choice| choice.allow.name.as_ref())
6158                    .collect();
6159                assert!(
6160                    labels.contains(&"Always for edit file"),
6161                    "Missing 'Always for edit file' option"
6162                );
6163                assert!(
6164                    labels.contains(&"Always for `src/`"),
6165                    "Missing path pattern option"
6166                );
6167            } else {
6168                panic!("Expected WaitingForConfirmation status");
6169            }
6170        });
6171    }
6172
6173    #[gpui::test]
6174    async fn test_tool_permission_buttons_fetch_with_domain_pattern(cx: &mut TestAppContext) {
6175        init_test(cx);
6176
6177        let tool_call_id = acp::ToolCallId::new("fetch-1");
6178        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Fetch `https://docs.rs/gpui`")
6179            .kind(acp::ToolKind::Fetch);
6180
6181        let permission_options =
6182            ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()])
6183                .build_permission_options();
6184
6185        let connection =
6186            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6187                tool_call_id.clone(),
6188                permission_options,
6189            )]));
6190
6191        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6192
6193        let (conversation_view, cx) =
6194            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6195
6196        // Disable notifications
6197        cx.update(|_window, cx| {
6198            AgentSettings::override_global(
6199                AgentSettings {
6200                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6201                    ..AgentSettings::get_global(cx).clone()
6202                },
6203                cx,
6204            );
6205        });
6206
6207        let message_editor = message_editor(&conversation_view, cx);
6208        message_editor.update_in(cx, |editor, window, cx| {
6209            editor.set_text("Fetch the docs", window, cx);
6210        });
6211
6212        active_thread(&conversation_view, cx)
6213            .update_in(cx, |view, window, cx| view.send(window, cx));
6214
6215        cx.run_until_parked();
6216
6217        // Verify the options
6218        conversation_view.read_with(cx, |conversation_view, cx| {
6219            let thread = conversation_view
6220                .active_thread()
6221                .expect("Thread should exist")
6222                .read(cx)
6223                .thread
6224                .clone();
6225            let thread = thread.read(cx);
6226
6227            let tool_call = thread.entries().iter().find_map(|entry| {
6228                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
6229                    Some(call)
6230                } else {
6231                    None
6232                }
6233            });
6234
6235            assert!(tool_call.is_some(), "Expected a tool call entry");
6236            let tool_call = tool_call.unwrap();
6237
6238            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
6239                &tool_call.status
6240            {
6241                let PermissionOptions::Dropdown(choices) = options else {
6242                    panic!("Expected dropdown permission options");
6243                };
6244
6245                let labels: Vec<&str> = choices
6246                    .iter()
6247                    .map(|choice| choice.allow.name.as_ref())
6248                    .collect();
6249                assert!(
6250                    labels.contains(&"Always for fetch"),
6251                    "Missing 'Always for fetch' option"
6252                );
6253                assert!(
6254                    labels.contains(&"Always for `docs.rs`"),
6255                    "Missing domain pattern option"
6256                );
6257            } else {
6258                panic!("Expected WaitingForConfirmation status");
6259            }
6260        });
6261    }
6262
6263    #[gpui::test]
6264    async fn test_tool_permission_buttons_without_pattern(cx: &mut TestAppContext) {
6265        init_test(cx);
6266
6267        let tool_call_id = acp::ToolCallId::new("terminal-no-pattern-1");
6268        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `./deploy.sh --production`")
6269            .kind(acp::ToolKind::Edit);
6270
6271        // No pattern button since ./deploy.sh doesn't match the alphanumeric pattern
6272        let permission_options = ToolPermissionContext::new(
6273            TerminalTool::NAME,
6274            vec!["./deploy.sh --production".to_string()],
6275        )
6276        .build_permission_options();
6277
6278        let connection =
6279            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6280                tool_call_id.clone(),
6281                permission_options,
6282            )]));
6283
6284        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6285
6286        let (conversation_view, cx) =
6287            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6288
6289        // Disable notifications
6290        cx.update(|_window, cx| {
6291            AgentSettings::override_global(
6292                AgentSettings {
6293                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6294                    ..AgentSettings::get_global(cx).clone()
6295                },
6296                cx,
6297            );
6298        });
6299
6300        let message_editor = message_editor(&conversation_view, cx);
6301        message_editor.update_in(cx, |editor, window, cx| {
6302            editor.set_text("Run the deploy script", window, cx);
6303        });
6304
6305        active_thread(&conversation_view, cx)
6306            .update_in(cx, |view, window, cx| view.send(window, cx));
6307
6308        cx.run_until_parked();
6309
6310        // Verify only 2 options (no pattern button when command doesn't match pattern)
6311        conversation_view.read_with(cx, |conversation_view, cx| {
6312            let thread = conversation_view
6313                .active_thread()
6314                .expect("Thread should exist")
6315                .read(cx)
6316                .thread
6317                .clone();
6318            let thread = thread.read(cx);
6319
6320            let tool_call = thread.entries().iter().find_map(|entry| {
6321                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
6322                    Some(call)
6323                } else {
6324                    None
6325                }
6326            });
6327
6328            assert!(tool_call.is_some(), "Expected a tool call entry");
6329            let tool_call = tool_call.unwrap();
6330
6331            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
6332                &tool_call.status
6333            {
6334                let PermissionOptions::Dropdown(choices) = options else {
6335                    panic!("Expected dropdown permission options");
6336                };
6337
6338                assert_eq!(
6339                    choices.len(),
6340                    2,
6341                    "Expected 2 permission options (no pattern option)"
6342                );
6343
6344                let labels: Vec<&str> = choices
6345                    .iter()
6346                    .map(|choice| choice.allow.name.as_ref())
6347                    .collect();
6348                assert!(
6349                    labels.contains(&"Always for terminal"),
6350                    "Missing 'Always for terminal' option"
6351                );
6352                assert!(
6353                    labels.contains(&"Only this time"),
6354                    "Missing 'Only this time' option"
6355                );
6356                // Should NOT contain a pattern option
6357                assert!(
6358                    !labels.iter().any(|l| l.contains("commands")),
6359                    "Should not have pattern option"
6360                );
6361            } else {
6362                panic!("Expected WaitingForConfirmation status");
6363            }
6364        });
6365    }
6366
6367    #[gpui::test]
6368    async fn test_authorize_tool_call_action_triggers_authorization(cx: &mut TestAppContext) {
6369        init_test(cx);
6370
6371        let tool_call_id = acp::ToolCallId::new("action-test-1");
6372        let tool_call =
6373            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit);
6374
6375        let permission_options =
6376            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo test".to_string()])
6377                .build_permission_options();
6378
6379        let connection =
6380            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6381                tool_call_id.clone(),
6382                permission_options,
6383            )]));
6384
6385        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6386
6387        let (conversation_view, cx) =
6388            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6389        add_to_workspace(conversation_view.clone(), cx);
6390
6391        cx.update(|_window, cx| {
6392            AgentSettings::override_global(
6393                AgentSettings {
6394                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6395                    ..AgentSettings::get_global(cx).clone()
6396                },
6397                cx,
6398            );
6399        });
6400
6401        let message_editor = message_editor(&conversation_view, cx);
6402        message_editor.update_in(cx, |editor, window, cx| {
6403            editor.set_text("Run tests", window, cx);
6404        });
6405
6406        active_thread(&conversation_view, cx)
6407            .update_in(cx, |view, window, cx| view.send(window, cx));
6408
6409        cx.run_until_parked();
6410
6411        // Verify tool call is waiting for confirmation
6412        conversation_view.read_with(cx, |conversation_view, cx| {
6413            let tool_call = conversation_view.pending_tool_call(cx);
6414            assert!(
6415                tool_call.is_some(),
6416                "Expected a tool call waiting for confirmation"
6417            );
6418        });
6419
6420        // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection)
6421        conversation_view.update_in(cx, |_, window, cx| {
6422            window.dispatch_action(
6423                crate::AuthorizeToolCall {
6424                    tool_call_id: "action-test-1".to_string(),
6425                    option_id: "allow".to_string(),
6426                    option_kind: "AllowOnce".to_string(),
6427                }
6428                .boxed_clone(),
6429                cx,
6430            );
6431        });
6432
6433        cx.run_until_parked();
6434
6435        // Verify tool call is no longer waiting for confirmation (was authorized)
6436        conversation_view.read_with(cx, |conversation_view, cx| {
6437            let tool_call = conversation_view.pending_tool_call(cx);
6438            assert!(
6439                tool_call.is_none(),
6440                "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action"
6441            );
6442        });
6443    }
6444
6445    #[gpui::test]
6446    async fn test_authorize_tool_call_action_with_pattern_option(cx: &mut TestAppContext) {
6447        init_test(cx);
6448
6449        let tool_call_id = acp::ToolCallId::new("pattern-action-test-1");
6450        let tool_call =
6451            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
6452
6453        let permission_options =
6454            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
6455                .build_permission_options();
6456
6457        let connection =
6458            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6459                tool_call_id.clone(),
6460                permission_options.clone(),
6461            )]));
6462
6463        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6464
6465        let (conversation_view, cx) =
6466            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6467        add_to_workspace(conversation_view.clone(), cx);
6468
6469        cx.update(|_window, cx| {
6470            AgentSettings::override_global(
6471                AgentSettings {
6472                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6473                    ..AgentSettings::get_global(cx).clone()
6474                },
6475                cx,
6476            );
6477        });
6478
6479        let message_editor = message_editor(&conversation_view, cx);
6480        message_editor.update_in(cx, |editor, window, cx| {
6481            editor.set_text("Install dependencies", window, cx);
6482        });
6483
6484        active_thread(&conversation_view, cx)
6485            .update_in(cx, |view, window, cx| view.send(window, cx));
6486
6487        cx.run_until_parked();
6488
6489        // Find the pattern option ID (the choice with non-empty sub_patterns)
6490        let pattern_option = match &permission_options {
6491            PermissionOptions::Dropdown(choices) => choices
6492                .iter()
6493                .find(|choice| !choice.sub_patterns.is_empty())
6494                .map(|choice| &choice.allow)
6495                .expect("Should have a pattern option for npm command"),
6496            _ => panic!("Expected dropdown permission options"),
6497        };
6498
6499        // Dispatch action with the pattern option (simulating "Always allow `npm` commands")
6500        conversation_view.update_in(cx, |_, window, cx| {
6501            window.dispatch_action(
6502                crate::AuthorizeToolCall {
6503                    tool_call_id: "pattern-action-test-1".to_string(),
6504                    option_id: pattern_option.option_id.0.to_string(),
6505                    option_kind: "AllowAlways".to_string(),
6506                }
6507                .boxed_clone(),
6508                cx,
6509            );
6510        });
6511
6512        cx.run_until_parked();
6513
6514        // Verify tool call was authorized
6515        conversation_view.read_with(cx, |conversation_view, cx| {
6516            let tool_call = conversation_view.pending_tool_call(cx);
6517            assert!(
6518                tool_call.is_none(),
6519                "Tool call should be authorized after selecting pattern option"
6520            );
6521        });
6522    }
6523
6524    #[gpui::test]
6525    async fn test_granularity_selection_updates_state(cx: &mut TestAppContext) {
6526        init_test(cx);
6527
6528        let tool_call_id = acp::ToolCallId::new("granularity-test-1");
6529        let tool_call =
6530            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit);
6531
6532        let permission_options =
6533            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
6534                .build_permission_options();
6535
6536        let connection =
6537            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6538                tool_call_id.clone(),
6539                permission_options.clone(),
6540            )]));
6541
6542        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6543
6544        let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
6545        add_to_workspace(thread_view.clone(), cx);
6546
6547        cx.update(|_window, cx| {
6548            AgentSettings::override_global(
6549                AgentSettings {
6550                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6551                    ..AgentSettings::get_global(cx).clone()
6552                },
6553                cx,
6554            );
6555        });
6556
6557        let message_editor = message_editor(&thread_view, cx);
6558        message_editor.update_in(cx, |editor, window, cx| {
6559            editor.set_text("Build the project", window, cx);
6560        });
6561
6562        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
6563
6564        cx.run_until_parked();
6565
6566        // Verify default granularity is the last option (index 2 = "Only this time")
6567        thread_view.read_with(cx, |thread_view, cx| {
6568            let state = thread_view.active_thread().unwrap();
6569            let selected = state.read(cx).permission_selections.get(&tool_call_id);
6570            assert!(
6571                selected.is_none(),
6572                "Should have no selection initially (defaults to last)"
6573            );
6574        });
6575
6576        // Select the first option (index 0 = "Always for terminal")
6577        thread_view.update_in(cx, |_, window, cx| {
6578            window.dispatch_action(
6579                crate::SelectPermissionGranularity {
6580                    tool_call_id: "granularity-test-1".to_string(),
6581                    index: 0,
6582                }
6583                .boxed_clone(),
6584                cx,
6585            );
6586        });
6587
6588        cx.run_until_parked();
6589
6590        // Verify the selection was updated
6591        thread_view.read_with(cx, |thread_view, cx| {
6592            let state = thread_view.active_thread().unwrap();
6593            let selected = state.read(cx).permission_selections.get(&tool_call_id);
6594            assert_eq!(
6595                selected.and_then(|s| s.choice_index()),
6596                Some(0),
6597                "Should have selected index 0"
6598            );
6599        });
6600    }
6601
6602    #[gpui::test]
6603    async fn test_allow_button_uses_selected_granularity(cx: &mut TestAppContext) {
6604        init_test(cx);
6605
6606        let tool_call_id = acp::ToolCallId::new("allow-granularity-test-1");
6607        let tool_call =
6608            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
6609
6610        let permission_options =
6611            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
6612                .build_permission_options();
6613
6614        // Verify we have the expected options
6615        let PermissionOptions::Dropdown(choices) = &permission_options else {
6616            panic!("Expected dropdown permission options");
6617        };
6618
6619        assert_eq!(choices.len(), 3);
6620        assert!(
6621            choices[0]
6622                .allow
6623                .option_id
6624                .0
6625                .contains("always_allow:terminal")
6626        );
6627        assert!(
6628            choices[1]
6629                .allow
6630                .option_id
6631                .0
6632                .contains("always_allow:terminal")
6633        );
6634        assert!(!choices[1].sub_patterns.is_empty());
6635        assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
6636
6637        let connection =
6638            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6639                tool_call_id.clone(),
6640                permission_options.clone(),
6641            )]));
6642
6643        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6644
6645        let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
6646        add_to_workspace(thread_view.clone(), cx);
6647
6648        cx.update(|_window, cx| {
6649            AgentSettings::override_global(
6650                AgentSettings {
6651                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6652                    ..AgentSettings::get_global(cx).clone()
6653                },
6654                cx,
6655            );
6656        });
6657
6658        let message_editor = message_editor(&thread_view, cx);
6659        message_editor.update_in(cx, |editor, window, cx| {
6660            editor.set_text("Install dependencies", window, cx);
6661        });
6662
6663        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
6664
6665        cx.run_until_parked();
6666
6667        // Select the pattern option (index 1 = "Always for `npm` commands")
6668        thread_view.update_in(cx, |_, window, cx| {
6669            window.dispatch_action(
6670                crate::SelectPermissionGranularity {
6671                    tool_call_id: "allow-granularity-test-1".to_string(),
6672                    index: 1,
6673                }
6674                .boxed_clone(),
6675                cx,
6676            );
6677        });
6678
6679        cx.run_until_parked();
6680
6681        // Simulate clicking the Allow button by dispatching AllowOnce action
6682        // which should use the selected granularity
6683        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
6684            view.allow_once(&AllowOnce, window, cx)
6685        });
6686
6687        cx.run_until_parked();
6688
6689        // Verify tool call was authorized
6690        thread_view.read_with(cx, |thread_view, cx| {
6691            let tool_call = thread_view.pending_tool_call(cx);
6692            assert!(
6693                tool_call.is_none(),
6694                "Tool call should be authorized after Allow with pattern granularity"
6695            );
6696        });
6697    }
6698
6699    #[gpui::test]
6700    async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) {
6701        init_test(cx);
6702
6703        let tool_call_id = acp::ToolCallId::new("deny-granularity-test-1");
6704        let tool_call =
6705            acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit);
6706
6707        let permission_options =
6708            ToolPermissionContext::new(TerminalTool::NAME, vec!["git push".to_string()])
6709                .build_permission_options();
6710
6711        let connection =
6712            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6713                tool_call_id.clone(),
6714                permission_options.clone(),
6715            )]));
6716
6717        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6718
6719        let (conversation_view, cx) =
6720            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6721        add_to_workspace(conversation_view.clone(), cx);
6722
6723        cx.update(|_window, cx| {
6724            AgentSettings::override_global(
6725                AgentSettings {
6726                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6727                    ..AgentSettings::get_global(cx).clone()
6728                },
6729                cx,
6730            );
6731        });
6732
6733        let message_editor = message_editor(&conversation_view, cx);
6734        message_editor.update_in(cx, |editor, window, cx| {
6735            editor.set_text("Push changes", window, cx);
6736        });
6737
6738        active_thread(&conversation_view, cx)
6739            .update_in(cx, |view, window, cx| view.send(window, cx));
6740
6741        cx.run_until_parked();
6742
6743        // Use default granularity (last option = "Only this time")
6744        // Simulate clicking the Deny button
6745        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
6746            view.reject_once(&RejectOnce, window, cx)
6747        });
6748
6749        cx.run_until_parked();
6750
6751        // Verify tool call was rejected (no longer waiting for confirmation)
6752        conversation_view.read_with(cx, |conversation_view, cx| {
6753            let tool_call = conversation_view.pending_tool_call(cx);
6754            assert!(
6755                tool_call.is_none(),
6756                "Tool call should be rejected after Deny"
6757            );
6758        });
6759    }
6760
6761    #[gpui::test]
6762    async fn test_option_id_transformation_for_allow() {
6763        let permission_options = ToolPermissionContext::new(
6764            TerminalTool::NAME,
6765            vec!["cargo build --release".to_string()],
6766        )
6767        .build_permission_options();
6768
6769        let PermissionOptions::Dropdown(choices) = permission_options else {
6770            panic!("Expected dropdown permission options");
6771        };
6772
6773        let allow_ids: Vec<String> = choices
6774            .iter()
6775            .map(|choice| choice.allow.option_id.0.to_string())
6776            .collect();
6777
6778        assert!(allow_ids.contains(&"allow".to_string()));
6779        assert_eq!(
6780            allow_ids
6781                .iter()
6782                .filter(|id| *id == "always_allow:terminal")
6783                .count(),
6784            2,
6785            "Expected two always_allow:terminal IDs (one whole-tool, one pattern with sub_patterns)"
6786        );
6787    }
6788
6789    #[gpui::test]
6790    async fn test_option_id_transformation_for_deny() {
6791        let permission_options = ToolPermissionContext::new(
6792            TerminalTool::NAME,
6793            vec!["cargo build --release".to_string()],
6794        )
6795        .build_permission_options();
6796
6797        let PermissionOptions::Dropdown(choices) = permission_options else {
6798            panic!("Expected dropdown permission options");
6799        };
6800
6801        let deny_ids: Vec<String> = choices
6802            .iter()
6803            .map(|choice| choice.deny.option_id.0.to_string())
6804            .collect();
6805
6806        assert!(deny_ids.contains(&"deny".to_string()));
6807        assert_eq!(
6808            deny_ids
6809                .iter()
6810                .filter(|id| *id == "always_deny:terminal")
6811                .count(),
6812            2,
6813            "Expected two always_deny:terminal IDs (one whole-tool, one pattern with sub_patterns)"
6814        );
6815    }
6816
6817    fn flat_allow_deny_options() -> PermissionOptions {
6818        PermissionOptions::Flat(vec![
6819            acp::PermissionOption::new(
6820                acp::PermissionOptionId::new("allow"),
6821                "Yes",
6822                acp::PermissionOptionKind::AllowOnce,
6823            ),
6824            acp::PermissionOption::new(
6825                acp::PermissionOptionId::new("deny"),
6826                "No",
6827                acp::PermissionOptionKind::RejectOnce,
6828            ),
6829        ])
6830    }
6831
6832    #[test]
6833    fn resolve_outcome_from_selection_flat_allow_picks_allow_once() {
6834        let options = flat_allow_deny_options();
6835
6836        let outcome = super::resolve_outcome_from_selection(&options, None, true).unwrap();
6837
6838        assert_eq!(outcome.option_id.0.as_ref(), "allow");
6839        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
6840    }
6841
6842    #[test]
6843    fn resolve_outcome_from_selection_flat_deny_picks_reject_once() {
6844        let options = flat_allow_deny_options();
6845
6846        let outcome = super::resolve_outcome_from_selection(&options, None, false).unwrap();
6847
6848        assert_eq!(outcome.option_id.0.as_ref(), "deny");
6849        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::RejectOnce);
6850    }
6851
6852    #[test]
6853    fn resolve_outcome_from_selection_flat_ignores_selection() {
6854        let options = flat_allow_deny_options();
6855        // Flat options never consult the granularity choice, even if one is set.
6856        let selection = thread_view::PermissionSelection::Choice(42);
6857
6858        let outcome =
6859            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
6860
6861        assert_eq!(outcome.option_id.0.as_ref(), "allow");
6862    }
6863
6864    #[test]
6865    fn resolve_outcome_from_selection_dropdown_defaults_to_last_choice_when_no_selection() {
6866        let options =
6867            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
6868                .build_permission_options();
6869
6870        let outcome = super::resolve_outcome_from_selection(&options, None, true).unwrap();
6871
6872        // Last choice is "Only this time" → option_id "allow".
6873        assert_eq!(outcome.option_id.0.as_ref(), "allow");
6874        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
6875    }
6876
6877    #[test]
6878    fn resolve_outcome_from_selection_dropdown_uses_selected_choice() {
6879        let options =
6880            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
6881                .build_permission_options();
6882        let selection = thread_view::PermissionSelection::Choice(0);
6883
6884        let outcome =
6885            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
6886
6887        // Choice 0 = "Always for terminal".
6888        assert!(outcome.option_id.0.contains("always_allow:terminal"));
6889        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowAlways);
6890    }
6891
6892    #[test]
6893    fn resolve_outcome_from_selection_dropdown_out_of_range_falls_back_to_last() {
6894        let options =
6895            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
6896                .build_permission_options();
6897        let selection = thread_view::PermissionSelection::Choice(999);
6898
6899        let outcome =
6900            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
6901
6902        // choices.get(999) is None, falls back to choices.last() → "Only this time".
6903        assert_eq!(outcome.option_id.0.as_ref(), "allow");
6904    }
6905
6906    #[test]
6907    fn resolve_outcome_from_selection_pattern_mode_with_empty_checked_falls_back_to_last_choice() {
6908        // Pipeline commands produce `DropdownWithPatterns`, which is required for
6909        // `SelectedPatterns` to be meaningful.
6910        let options = ToolPermissionContext::new(
6911            TerminalTool::NAME,
6912            vec!["cargo test 2>&1 | tail".to_string()],
6913        )
6914        .build_permission_options();
6915        assert!(matches!(
6916            options,
6917            PermissionOptions::DropdownWithPatterns { .. }
6918        ));
6919        // Pattern mode with zero checked patterns: `build_outcome_for_checked_patterns`
6920        // returns None, so we fall through to `choice_index()` (which is None for
6921        // `SelectedPatterns`) and default to `choices.last()`.
6922        let selection = thread_view::PermissionSelection::SelectedPatterns(vec![]);
6923
6924        let outcome =
6925            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
6926
6927        assert_eq!(outcome.option_id.0.as_ref(), "allow");
6928        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
6929    }
6930
6931    #[test]
6932    fn resolve_outcome_from_selection_pattern_mode_with_checked_uses_always_with_params() {
6933        let options = ToolPermissionContext::new(
6934            TerminalTool::NAME,
6935            vec!["cargo test 2>&1 | tail".to_string()],
6936        )
6937        .build_permission_options();
6938        assert!(matches!(
6939            options,
6940            PermissionOptions::DropdownWithPatterns { .. }
6941        ));
6942        let selection = thread_view::PermissionSelection::SelectedPatterns(vec![0]);
6943
6944        let outcome =
6945            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
6946
6947        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowAlways);
6948        assert!(
6949            outcome.params.is_some(),
6950            "checked patterns should attach terminal params"
6951        );
6952    }
6953
6954    #[gpui::test]
6955    async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) {
6956        init_test(cx);
6957
6958        let (conversation_view, cx) =
6959            setup_conversation_view(StubAgentServer::default_response(), cx).await;
6960        add_to_workspace(conversation_view.clone(), cx);
6961
6962        let active = active_thread(&conversation_view, cx);
6963        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6964        let thread = cx.read(|cx| active.read(cx).thread.clone());
6965
6966        title_editor.read_with(cx, |editor, cx| {
6967            assert!(!editor.read_only(cx));
6968        });
6969
6970        cx.focus(&conversation_view);
6971        cx.focus(&title_editor);
6972
6973        cx.dispatch_action(editor::actions::DeleteLine);
6974        cx.simulate_input("My Custom Title");
6975
6976        cx.run_until_parked();
6977
6978        title_editor.read_with(cx, |editor, cx| {
6979            assert_eq!(editor.text(cx), "My Custom Title");
6980        });
6981        thread.read_with(cx, |thread, _cx| {
6982            assert_eq!(thread.title(), Some("My Custom Title".into()));
6983        });
6984    }
6985
6986    #[gpui::test]
6987    async fn test_title_editor_is_read_only_when_set_title_unsupported(cx: &mut TestAppContext) {
6988        init_test(cx);
6989
6990        let (conversation_view, cx) =
6991            setup_conversation_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await;
6992
6993        let active = active_thread(&conversation_view, cx);
6994        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6995
6996        title_editor.read_with(cx, |editor, cx| {
6997            assert!(
6998                editor.read_only(cx),
6999                "Title editor should be read-only when the connection does not support set_title"
7000            );
7001        });
7002    }
7003
7004    #[gpui::test]
7005    async fn test_max_tokens_error_is_rendered(cx: &mut TestAppContext) {
7006        init_test(cx);
7007
7008        let connection = StubAgentConnection::new();
7009
7010        let (conversation_view, cx) =
7011            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7012
7013        let message_editor = message_editor(&conversation_view, cx);
7014        message_editor.update_in(cx, |editor, window, cx| {
7015            editor.set_text("Some prompt", window, cx);
7016        });
7017        active_thread(&conversation_view, cx)
7018            .update_in(cx, |view, window, cx| view.send(window, cx));
7019
7020        let session_id = conversation_view.read_with(cx, |view, cx| {
7021            view.active_thread()
7022                .unwrap()
7023                .read(cx)
7024                .thread
7025                .read(cx)
7026                .session_id()
7027                .clone()
7028        });
7029
7030        cx.run_until_parked();
7031
7032        cx.update(|_, _cx| {
7033            connection.end_turn(session_id, acp::StopReason::MaxTokens);
7034        });
7035
7036        cx.run_until_parked();
7037
7038        conversation_view.read_with(cx, |conversation_view, cx| {
7039            let state = conversation_view.active_thread().unwrap();
7040            let error = &state.read(cx).thread_error;
7041            assert!(
7042                matches!(error, Some(ThreadError::MaxOutputTokens)),
7043                "Expected ThreadError::MaxOutputTokens, got: {:?}",
7044                error.is_some()
7045            );
7046        });
7047    }
7048
7049    fn create_test_acp_thread(
7050        parent_session_id: Option<acp::SessionId>,
7051        session_id: &str,
7052        connection: Rc<dyn AgentConnection>,
7053        project: Entity<Project>,
7054        cx: &mut App,
7055    ) -> Entity<AcpThread> {
7056        let action_log = cx.new(|_| ActionLog::new(project.clone()));
7057        cx.new(|cx| {
7058            AcpThread::new(
7059                parent_session_id,
7060                None,
7061                None,
7062                connection,
7063                project,
7064                action_log,
7065                acp::SessionId::new(session_id),
7066                watch::Receiver::constant(acp::PromptCapabilities::new()),
7067                cx,
7068            )
7069        })
7070    }
7071
7072    fn request_test_tool_authorization(
7073        thread: &Entity<AcpThread>,
7074        tool_call_id: &str,
7075        option_id: &str,
7076        cx: &mut TestAppContext,
7077    ) -> Task<acp_thread::RequestPermissionOutcome> {
7078        let tool_call_id = acp::ToolCallId::new(tool_call_id);
7079        let label = format!("Tool {tool_call_id}");
7080        let option_id = acp::PermissionOptionId::new(option_id);
7081        cx.update(|cx| {
7082            thread.update(cx, |thread, cx| {
7083                thread
7084                    .request_tool_call_authorization(
7085                        acp::ToolCall::new(tool_call_id, label)
7086                            .kind(acp::ToolKind::Edit)
7087                            .into(),
7088                        PermissionOptions::Flat(vec![acp::PermissionOption::new(
7089                            option_id,
7090                            "Allow",
7091                            acp::PermissionOptionKind::AllowOnce,
7092                        )]),
7093                        cx,
7094                    )
7095                    .unwrap()
7096            })
7097        })
7098    }
7099
7100    #[gpui::test]
7101    async fn test_conversation_multiple_tool_calls_fifo_ordering(cx: &mut TestAppContext) {
7102        init_test(cx);
7103
7104        let fs = FakeFs::new(cx.executor());
7105        let project = Project::test(fs, [], cx).await;
7106        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
7107
7108        let session_id = acp::SessionId::new("session-1");
7109        let (thread, conversation) = cx.update(|cx| {
7110            let thread =
7111                create_test_acp_thread(None, "session-1", connection.clone(), project.clone(), cx);
7112            let conversation = cx.new(|cx| {
7113                let mut conversation = Conversation::default();
7114                conversation.register_thread(thread.clone(), cx);
7115                conversation
7116            });
7117            (thread, conversation)
7118        });
7119
7120        let _task1 = request_test_tool_authorization(&thread, "tc-1", "allow-1", cx);
7121        let _task2 = request_test_tool_authorization(&thread, "tc-2", "allow-2", cx);
7122
7123        cx.read(|cx| {
7124            let (_, tool_call_id, _) = conversation
7125                .read(cx)
7126                .pending_tool_call(&session_id, cx)
7127                .expect("Expected a pending tool call");
7128            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-1"));
7129        });
7130
7131        cx.update(|cx| {
7132            conversation.update(cx, |conversation, cx| {
7133                conversation.authorize_tool_call(
7134                    session_id.clone(),
7135                    acp::ToolCallId::new("tc-1"),
7136                    SelectedPermissionOutcome::new(
7137                        acp::PermissionOptionId::new("allow-1"),
7138                        acp::PermissionOptionKind::AllowOnce,
7139                    ),
7140                    cx,
7141                );
7142            });
7143        });
7144
7145        cx.run_until_parked();
7146
7147        cx.read(|cx| {
7148            let (_, tool_call_id, _) = conversation
7149                .read(cx)
7150                .pending_tool_call(&session_id, cx)
7151                .expect("Expected tc-2 to be pending after tc-1 was authorized");
7152            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-2"));
7153        });
7154
7155        cx.update(|cx| {
7156            conversation.update(cx, |conversation, cx| {
7157                conversation.authorize_tool_call(
7158                    session_id.clone(),
7159                    acp::ToolCallId::new("tc-2"),
7160                    SelectedPermissionOutcome::new(
7161                        acp::PermissionOptionId::new("allow-2"),
7162                        acp::PermissionOptionKind::AllowOnce,
7163                    ),
7164                    cx,
7165                );
7166            });
7167        });
7168
7169        cx.run_until_parked();
7170
7171        cx.read(|cx| {
7172            assert!(
7173                conversation
7174                    .read(cx)
7175                    .pending_tool_call(&session_id, cx)
7176                    .is_none(),
7177                "Expected no pending tool calls after both were authorized"
7178            );
7179        });
7180    }
7181
7182    #[gpui::test]
7183    async fn test_conversation_subagent_scoped_pending_tool_call(cx: &mut TestAppContext) {
7184        init_test(cx);
7185
7186        let fs = FakeFs::new(cx.executor());
7187        let project = Project::test(fs, [], cx).await;
7188        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
7189
7190        let parent_session_id = acp::SessionId::new("parent");
7191        let subagent_session_id = acp::SessionId::new("subagent");
7192        let (parent_thread, subagent_thread, conversation) = cx.update(|cx| {
7193            let parent_thread =
7194                create_test_acp_thread(None, "parent", connection.clone(), project.clone(), cx);
7195            let subagent_thread = create_test_acp_thread(
7196                Some(acp::SessionId::new("parent")),
7197                "subagent",
7198                connection.clone(),
7199                project.clone(),
7200                cx,
7201            );
7202            let conversation = cx.new(|cx| {
7203                let mut conversation = Conversation::default();
7204                conversation.register_thread(parent_thread.clone(), cx);
7205                conversation.register_thread(subagent_thread.clone(), cx);
7206                conversation
7207            });
7208            (parent_thread, subagent_thread, conversation)
7209        });
7210
7211        let _parent_task =
7212            request_test_tool_authorization(&parent_thread, "parent-tc", "allow-parent", cx);
7213        let _subagent_task =
7214            request_test_tool_authorization(&subagent_thread, "subagent-tc", "allow-subagent", cx);
7215
7216        // Querying with the subagent's session ID returns only the
7217        // subagent's own tool call (subagent path is scoped to its session)
7218        cx.read(|cx| {
7219            let (returned_session_id, tool_call_id, _) = conversation
7220                .read(cx)
7221                .pending_tool_call(&subagent_session_id, cx)
7222                .expect("Expected subagent's pending tool call");
7223            assert_eq!(returned_session_id, subagent_session_id);
7224            assert_eq!(tool_call_id, acp::ToolCallId::new("subagent-tc"));
7225        });
7226
7227        // Querying with the parent's session ID returns the first pending
7228        // request in FIFO order across all sessions
7229        cx.read(|cx| {
7230            let (returned_session_id, tool_call_id, _) = conversation
7231                .read(cx)
7232                .pending_tool_call(&parent_session_id, cx)
7233                .expect("Expected a pending tool call from parent query");
7234            assert_eq!(returned_session_id, parent_session_id);
7235            assert_eq!(tool_call_id, acp::ToolCallId::new("parent-tc"));
7236        });
7237    }
7238
7239    #[gpui::test]
7240    async fn test_conversation_parent_pending_tool_call_returns_first_across_threads(
7241        cx: &mut TestAppContext,
7242    ) {
7243        init_test(cx);
7244
7245        let fs = FakeFs::new(cx.executor());
7246        let project = Project::test(fs, [], cx).await;
7247        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
7248
7249        let session_id_a = acp::SessionId::new("thread-a");
7250        let session_id_b = acp::SessionId::new("thread-b");
7251        let (thread_a, thread_b, conversation) = cx.update(|cx| {
7252            let thread_a =
7253                create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx);
7254            let thread_b =
7255                create_test_acp_thread(None, "thread-b", connection.clone(), project.clone(), cx);
7256            let conversation = cx.new(|cx| {
7257                let mut conversation = Conversation::default();
7258                conversation.register_thread(thread_a.clone(), cx);
7259                conversation.register_thread(thread_b.clone(), cx);
7260                conversation
7261            });
7262            (thread_a, thread_b, conversation)
7263        });
7264
7265        let _task_a = request_test_tool_authorization(&thread_a, "tc-a", "allow-a", cx);
7266        let _task_b = request_test_tool_authorization(&thread_b, "tc-b", "allow-b", cx);
7267
7268        // Both threads are non-subagent, so pending_tool_call always returns
7269        // the first entry from permission_requests (FIFO across all sessions)
7270        cx.read(|cx| {
7271            let (returned_session_id, tool_call_id, _) = conversation
7272                .read(cx)
7273                .pending_tool_call(&session_id_a, cx)
7274                .expect("Expected a pending tool call");
7275            assert_eq!(returned_session_id, session_id_a);
7276            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
7277        });
7278
7279        // Querying with thread-b also returns thread-a's tool call,
7280        // because non-subagent queries always use permission_requests.first()
7281        cx.read(|cx| {
7282            let (returned_session_id, tool_call_id, _) = conversation
7283                .read(cx)
7284                .pending_tool_call(&session_id_b, cx)
7285                .expect("Expected a pending tool call from thread-b query");
7286            assert_eq!(
7287                returned_session_id, session_id_a,
7288                "Non-subagent queries always return the first pending request in FIFO order"
7289            );
7290            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
7291        });
7292
7293        // After authorizing thread-a's tool call, thread-b's becomes first
7294        cx.update(|cx| {
7295            conversation.update(cx, |conversation, cx| {
7296                conversation.authorize_tool_call(
7297                    session_id_a.clone(),
7298                    acp::ToolCallId::new("tc-a"),
7299                    SelectedPermissionOutcome::new(
7300                        acp::PermissionOptionId::new("allow-a"),
7301                        acp::PermissionOptionKind::AllowOnce,
7302                    ),
7303                    cx,
7304                );
7305            });
7306        });
7307
7308        cx.run_until_parked();
7309
7310        cx.read(|cx| {
7311            let (returned_session_id, tool_call_id, _) = conversation
7312                .read(cx)
7313                .pending_tool_call(&session_id_b, cx)
7314                .expect("Expected thread-b's tool call after thread-a's was authorized");
7315            assert_eq!(returned_session_id, session_id_b);
7316            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b"));
7317        });
7318    }
7319
7320    #[gpui::test]
7321    async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) {
7322        init_test(cx);
7323
7324        let (conversation_view, cx) =
7325            setup_conversation_view(StubAgentServer::default_response(), cx).await;
7326
7327        // Add a plain-text message to the queue directly.
7328        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
7329            thread.add_to_queue(
7330                vec![acp::ContentBlock::Text(acp::TextContent::new(
7331                    "queued message".to_string(),
7332                ))],
7333                vec![],
7334                cx,
7335            );
7336            // Main editor must be empty for this path — it is by default, but
7337            // assert to make the precondition explicit.
7338            assert!(thread.message_editor.read(cx).is_empty(cx));
7339            thread.move_queued_message_to_main_editor(0, None, None, window, cx);
7340        });
7341
7342        cx.run_until_parked();
7343
7344        // Queue should now be empty.
7345        let queue_len = active_thread(&conversation_view, cx)
7346            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
7347        assert_eq!(queue_len, 0, "Queue should be empty after move");
7348
7349        // Main editor should contain the queued message text.
7350        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
7351        assert_eq!(
7352            text, "queued message",
7353            "Main editor should contain the moved queued message"
7354        );
7355    }
7356
7357    #[gpui::test]
7358    async fn test_move_queued_message_to_non_empty_main_editor(cx: &mut TestAppContext) {
7359        init_test(cx);
7360
7361        let (conversation_view, cx) =
7362            setup_conversation_view(StubAgentServer::default_response(), cx).await;
7363
7364        // Seed the main editor with existing content.
7365        message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| {
7366            editor.set_message(
7367                vec![acp::ContentBlock::Text(acp::TextContent::new(
7368                    "existing content".to_string(),
7369                ))],
7370                window,
7371                cx,
7372            );
7373        });
7374
7375        // Add a plain-text message to the queue.
7376        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
7377            thread.add_to_queue(
7378                vec![acp::ContentBlock::Text(acp::TextContent::new(
7379                    "queued message".to_string(),
7380                ))],
7381                vec![],
7382                cx,
7383            );
7384            thread.move_queued_message_to_main_editor(0, None, None, window, cx);
7385        });
7386
7387        cx.run_until_parked();
7388
7389        // Queue should now be empty.
7390        let queue_len = active_thread(&conversation_view, cx)
7391            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
7392        assert_eq!(queue_len, 0, "Queue should be empty after move");
7393
7394        // Main editor should contain existing content + separator + queued content.
7395        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
7396        assert_eq!(
7397            text, "existing content\n\nqueued message",
7398            "Main editor should have existing content and queued message separated by two newlines"
7399        );
7400    }
7401
7402    #[gpui::test]
7403    async fn test_close_all_sessions_skips_when_unsupported(cx: &mut TestAppContext) {
7404        init_test(cx);
7405
7406        let fs = FakeFs::new(cx.executor());
7407        let project = Project::test(fs, [], cx).await;
7408        let (multi_workspace, cx) =
7409            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7410        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7411
7412        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
7413        let connection_store =
7414            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
7415
7416        // StubAgentConnection defaults to supports_close_session() -> false
7417        let conversation_view = cx.update(|window, cx| {
7418            cx.new(|cx| {
7419                ConversationView::new(
7420                    Rc::new(StubAgentServer::default_response()),
7421                    connection_store,
7422                    Agent::Custom { id: "Test".into() },
7423                    None,
7424                    None,
7425                    None,
7426                    None,
7427                    None,
7428                    workspace.downgrade(),
7429                    project,
7430                    Some(thread_store),
7431                    None,
7432                    "agent_panel",
7433                    window,
7434                    cx,
7435                )
7436            })
7437        });
7438
7439        cx.run_until_parked();
7440
7441        conversation_view.read_with(cx, |view, _cx| {
7442            let connected = view.as_connected().expect("Should be connected");
7443            assert!(
7444                !connected.threads.is_empty(),
7445                "There should be at least one thread"
7446            );
7447            assert!(
7448                !connected.connection.supports_close_session(),
7449                "StubAgentConnection should not support close"
7450            );
7451        });
7452
7453        conversation_view
7454            .update(cx, |view, cx| {
7455                view.as_connected()
7456                    .expect("Should be connected")
7457                    .close_all_sessions(cx)
7458            })
7459            .await;
7460    }
7461
7462    #[gpui::test]
7463    async fn test_close_all_sessions_calls_close_when_supported(cx: &mut TestAppContext) {
7464        init_test(cx);
7465
7466        let (conversation_view, cx) =
7467            setup_conversation_view(StubAgentServer::new(CloseCapableConnection::new()), cx).await;
7468
7469        cx.run_until_parked();
7470
7471        let close_capable = conversation_view.read_with(cx, |view, _cx| {
7472            let connected = view.as_connected().expect("Should be connected");
7473            assert!(
7474                !connected.threads.is_empty(),
7475                "There should be at least one thread"
7476            );
7477            assert!(
7478                connected.connection.supports_close_session(),
7479                "CloseCapableConnection should support close"
7480            );
7481            connected
7482                .connection
7483                .clone()
7484                .into_any()
7485                .downcast::<CloseCapableConnection>()
7486                .expect("Should be CloseCapableConnection")
7487        });
7488
7489        conversation_view
7490            .update(cx, |view, cx| {
7491                view.as_connected()
7492                    .expect("Should be connected")
7493                    .close_all_sessions(cx)
7494            })
7495            .await;
7496
7497        let closed_count = close_capable.closed_sessions.lock().len();
7498        assert!(
7499            closed_count > 0,
7500            "close_session should have been called for each thread"
7501        );
7502    }
7503
7504    #[gpui::test]
7505    async fn test_close_session_returns_error_when_unsupported(cx: &mut TestAppContext) {
7506        init_test(cx);
7507
7508        let (conversation_view, cx) =
7509            setup_conversation_view(StubAgentServer::default_response(), cx).await;
7510
7511        cx.run_until_parked();
7512
7513        let result = conversation_view
7514            .update(cx, |view, cx| {
7515                let connected = view.as_connected().expect("Should be connected");
7516                assert!(
7517                    !connected.connection.supports_close_session(),
7518                    "StubAgentConnection should not support close"
7519                );
7520                let thread_view = connected
7521                    .threads
7522                    .values()
7523                    .next()
7524                    .expect("Should have at least one thread");
7525                let session_id = thread_view.read(cx).thread.read(cx).session_id().clone();
7526                connected.connection.clone().close_session(&session_id, cx)
7527            })
7528            .await;
7529
7530        assert!(
7531            result.is_err(),
7532            "close_session should return an error when close is not supported"
7533        );
7534        assert!(
7535            result.unwrap_err().to_string().contains("not supported"),
7536            "Error message should indicate that closing is not supported"
7537        );
7538    }
7539
7540    #[derive(Clone)]
7541    struct CloseCapableConnection {
7542        closed_sessions: Arc<Mutex<Vec<acp::SessionId>>>,
7543    }
7544
7545    impl CloseCapableConnection {
7546        fn new() -> Self {
7547            Self {
7548                closed_sessions: Arc::new(Mutex::new(Vec::new())),
7549            }
7550        }
7551    }
7552
7553    impl AgentConnection for CloseCapableConnection {
7554        fn agent_id(&self) -> AgentId {
7555            AgentId::new("close-capable")
7556        }
7557
7558        fn telemetry_id(&self) -> SharedString {
7559            "close-capable".into()
7560        }
7561
7562        fn new_session(
7563            self: Rc<Self>,
7564            project: Entity<Project>,
7565            work_dirs: PathList,
7566            cx: &mut gpui::App,
7567        ) -> Task<gpui::Result<Entity<AcpThread>>> {
7568            let action_log = cx.new(|_| ActionLog::new(project.clone()));
7569            let thread = cx.new(|cx| {
7570                AcpThread::new(
7571                    None,
7572                    Some("CloseCapableConnection".into()),
7573                    Some(work_dirs),
7574                    self,
7575                    project,
7576                    action_log,
7577                    acp::SessionId::new("close-capable-session"),
7578                    watch::Receiver::constant(
7579                        acp::PromptCapabilities::new()
7580                            .image(true)
7581                            .audio(true)
7582                            .embedded_context(true),
7583                    ),
7584                    cx,
7585                )
7586            });
7587            Task::ready(Ok(thread))
7588        }
7589
7590        fn supports_close_session(&self) -> bool {
7591            true
7592        }
7593
7594        fn close_session(
7595            self: Rc<Self>,
7596            session_id: &acp::SessionId,
7597            _cx: &mut App,
7598        ) -> Task<Result<()>> {
7599            self.closed_sessions.lock().push(session_id.clone());
7600            Task::ready(Ok(()))
7601        }
7602
7603        fn auth_methods(&self) -> &[acp::AuthMethod] {
7604            &[]
7605        }
7606
7607        fn authenticate(
7608            &self,
7609            _method_id: acp::AuthMethodId,
7610            _cx: &mut App,
7611        ) -> Task<gpui::Result<()>> {
7612            Task::ready(Ok(()))
7613        }
7614
7615        fn prompt(
7616            &self,
7617            _id: acp_thread::UserMessageId,
7618            _params: acp::PromptRequest,
7619            _cx: &mut App,
7620        ) -> Task<gpui::Result<acp::PromptResponse>> {
7621            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
7622        }
7623
7624        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
7625
7626        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
7627            self
7628        }
7629    }
7630}