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