conversation_view.rs

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