conversation_view.rs

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