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, 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            // If the user manually refocuses the original window, dismiss the popup.
2660            self.notification_subscriptions
2661                .entry(screen_window)
2662                .or_insert_with(Vec::new)
2663                .push({
2664                    let pop_up_weak = pop_up.downgrade();
2665
2666                    cx.observe_window_activation(window, move |this, window, cx| {
2667                        if this.agent_status_visible(window, cx)
2668                            && let Some(pop_up) = pop_up_weak.upgrade()
2669                        {
2670                            pop_up.update(cx, |notification, cx| {
2671                                notification.dismiss(cx);
2672                            });
2673                        }
2674                    })
2675                });
2676        }
2677    }
2678
2679    fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
2680        for window in self.notifications.drain(..) {
2681            window
2682                .update(cx, |_, window, _| {
2683                    window.remove_window();
2684                })
2685                .ok();
2686
2687            self.notification_subscriptions.remove(&window);
2688        }
2689    }
2690
2691    fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
2692        if let Some(entry_view_state) = self
2693            .active_thread()
2694            .map(|active| active.read(cx).entry_view_state.clone())
2695        {
2696            entry_view_state.update(cx, |entry_view_state, cx| {
2697                entry_view_state.agent_ui_font_size_changed(cx);
2698            });
2699        }
2700    }
2701
2702    pub(crate) fn insert_dragged_files(
2703        &self,
2704        paths: Vec<project::ProjectPath>,
2705        added_worktrees: Vec<Entity<project::Worktree>>,
2706        window: &mut Window,
2707        cx: &mut Context<Self>,
2708    ) {
2709        if let Some(active_thread) = self.active_thread() {
2710            active_thread.update(cx, |thread, cx| {
2711                thread.message_editor.update(cx, |editor, cx| {
2712                    editor.insert_dragged_files(paths, added_worktrees, window, cx);
2713                    editor.focus_handle(cx).focus(window, cx);
2714                })
2715            });
2716        }
2717    }
2718
2719    /// Inserts the selected text into the message editor or the message being
2720    /// edited, if any.
2721    pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
2722        if let Some(active_thread) = self.active_thread() {
2723            active_thread.update(cx, |thread, cx| {
2724                thread.active_editor(cx).update(cx, |editor, cx| {
2725                    editor.insert_selections(window, cx);
2726                })
2727            });
2728        }
2729    }
2730
2731    fn current_model_name(&self, cx: &App) -> SharedString {
2732        // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
2733        // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI")
2734        // This provides better clarity about what refused the request
2735        if self.as_native_connection(cx).is_some() {
2736            self.root_thread_view()
2737                .and_then(|active| active.read(cx).model_selector.clone())
2738                .and_then(|selector| selector.read(cx).active_model(cx))
2739                .map(|model| model.name.clone())
2740                .unwrap_or_else(|| SharedString::from("The model"))
2741        } else {
2742            // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI")
2743            self.agent.agent_id().0
2744        }
2745    }
2746
2747    fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
2748        let message = message.into();
2749
2750        CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
2751    }
2752
2753    pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2754        let agent_id = self.agent.agent_id();
2755        if let Some(active) = self.root_thread_view() {
2756            active.update(cx, |active, cx| active.clear_thread_error(cx));
2757        }
2758        let this = cx.weak_entity();
2759        let Some(connection) = self.as_connected().map(|c| c.connection.clone()) else {
2760            debug_panic!("This should not be possible");
2761            return;
2762        };
2763        window.defer(cx, |window, cx| {
2764            Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx);
2765        })
2766    }
2767}
2768
2769fn loading_contents_spinner(size: IconSize) -> AnyElement {
2770    Icon::new(IconName::LoadCircle)
2771        .size(size)
2772        .color(Color::Accent)
2773        .with_rotate_animation(3)
2774        .into_any_element()
2775}
2776
2777fn placeholder_text(agent_name: &str, has_commands: bool) -> String {
2778    if agent_name == agent::ZED_AGENT_ID.as_ref() {
2779        format!("Message the {} — @ to include context", agent_name)
2780    } else if has_commands {
2781        format!(
2782            "Message {} — @ to include context, / for commands",
2783            agent_name
2784        )
2785    } else {
2786        format!("Message {} — @ to include context", agent_name)
2787    }
2788}
2789
2790impl Focusable for ConversationView {
2791    fn focus_handle(&self, cx: &App) -> FocusHandle {
2792        match self.active_thread() {
2793            Some(thread) => thread.read(cx).focus_handle(cx),
2794            None => self.focus_handle.clone(),
2795        }
2796    }
2797}
2798
2799#[cfg(any(test, feature = "test-support"))]
2800impl ConversationView {
2801    /// Expands a tool call so its content is visible.
2802    /// This is primarily useful for visual testing.
2803    pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context<Self>) {
2804        if let Some(active) = self.active_thread() {
2805            active.update(cx, |active, _cx| {
2806                active.expanded_tool_calls.insert(tool_call_id);
2807            });
2808            cx.notify();
2809        }
2810    }
2811
2812    #[cfg(any(test, feature = "test-support"))]
2813    pub fn set_updated_at(&mut self, updated_at: Instant, cx: &mut Context<Self>) {
2814        let Some(connected) = self.as_connected_mut() else {
2815            return;
2816        };
2817
2818        connected.conversation.update(cx, |conversation, _cx| {
2819            conversation.updated_at = Some(updated_at);
2820        });
2821    }
2822}
2823
2824impl Render for ConversationView {
2825    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2826        self.sync_queued_message_editors(window, cx);
2827
2828        v_flex()
2829            .track_focus(&self.focus_handle)
2830            .size_full()
2831            .bg(cx.theme().colors().panel_background)
2832            .child(match &self.server_state {
2833                ServerState::Loading { .. } => v_flex()
2834                    .flex_1()
2835                    .size_full()
2836                    .items_center()
2837                    .justify_center()
2838                    .child(
2839                        Label::new("Loading…").color(Color::Muted).with_animation(
2840                            "loading-agent-label",
2841                            Animation::new(Duration::from_secs(2))
2842                                .repeat()
2843                                .with_easing(pulsating_between(0.3, 0.7)),
2844                            |label, delta| label.alpha(delta),
2845                        ),
2846                    )
2847                    .into_any(),
2848                ServerState::LoadError { error: e, .. } => v_flex()
2849                    .flex_1()
2850                    .size_full()
2851                    .items_center()
2852                    .justify_end()
2853                    .child(self.render_load_error(e, window, cx))
2854                    .into_any(),
2855                ServerState::Connected(ConnectedServerState {
2856                    connection,
2857                    auth_state:
2858                        AuthState::Unauthenticated {
2859                            description,
2860                            configuration_view,
2861                            pending_auth_method,
2862                            _subscription,
2863                        },
2864                    ..
2865                }) => v_flex()
2866                    .flex_1()
2867                    .size_full()
2868                    .justify_end()
2869                    .child(self.render_auth_required_state(
2870                        connection,
2871                        description.as_ref(),
2872                        configuration_view.as_ref(),
2873                        pending_auth_method.as_ref(),
2874                        window,
2875                        cx,
2876                    ))
2877                    .into_any_element(),
2878                ServerState::Connected(connected) => {
2879                    if let Some(view) = connected.active_view() {
2880                        view.clone().into_any_element()
2881                    } else {
2882                        debug_panic!("This state should never be reached");
2883                        div().into_any_element()
2884                    }
2885                }
2886            })
2887    }
2888}
2889
2890fn plan_label_markdown_style(
2891    status: &acp::PlanEntryStatus,
2892    window: &Window,
2893    cx: &App,
2894) -> MarkdownStyle {
2895    let default_md_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
2896
2897    MarkdownStyle {
2898        base_text_style: TextStyle {
2899            color: cx.theme().colors().text_muted,
2900            strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
2901                Some(gpui::StrikethroughStyle {
2902                    thickness: px(1.),
2903                    color: Some(cx.theme().colors().text_muted.opacity(0.8)),
2904                })
2905            } else {
2906                None
2907            },
2908            ..default_md_style.base_text_style
2909        },
2910        ..default_md_style
2911    }
2912}
2913
2914#[cfg(test)]
2915pub(crate) mod tests {
2916    use acp_thread::StubAgentConnection;
2917    use action_log::ActionLog;
2918    use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
2919    use agent_servers::FakeAcpAgentServer;
2920    use editor::MultiBufferOffset;
2921    use fs::FakeFs;
2922    use gpui::{EventEmitter, TestAppContext, VisualTestContext};
2923    use parking_lot::Mutex;
2924    use project::Project;
2925    use serde_json::json;
2926    use settings::SettingsStore;
2927    use std::any::Any;
2928    use std::path::{Path, PathBuf};
2929    use std::rc::Rc;
2930    use std::sync::Arc;
2931    use workspace::{Item, MultiWorkspace};
2932
2933    use crate::agent_panel;
2934    use crate::thread_metadata_store::ThreadMetadataStore;
2935
2936    use super::*;
2937
2938    #[gpui::test]
2939    async fn test_drop(cx: &mut TestAppContext) {
2940        init_test(cx);
2941
2942        let (conversation_view, _cx) =
2943            setup_conversation_view(StubAgentServer::default_response(), cx).await;
2944        let weak_view = conversation_view.downgrade();
2945        drop(conversation_view);
2946        assert!(!weak_view.is_upgradable());
2947    }
2948
2949    #[gpui::test]
2950    async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) {
2951        init_test(cx);
2952
2953        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2954            panic!("expected prompt from external source to sanitize successfully");
2955        };
2956        let initial_content = AgentInitialContent::FromExternalSource(prompt);
2957
2958        let (conversation_view, cx) = setup_conversation_view_with_initial_content(
2959            StubAgentServer::default_response(),
2960            initial_content,
2961            cx,
2962        )
2963        .await;
2964
2965        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
2966            assert!(view.show_external_source_prompt_warning);
2967            assert_eq!(view.thread.read(cx).entries().len(), 0);
2968            assert_eq!(view.message_editor.read(cx).text(cx), "Write me a script");
2969        });
2970    }
2971
2972    #[gpui::test]
2973    async fn test_external_source_prompt_warning_clears_after_send(cx: &mut TestAppContext) {
2974        init_test(cx);
2975
2976        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2977            panic!("expected prompt from external source to sanitize successfully");
2978        };
2979        let initial_content = AgentInitialContent::FromExternalSource(prompt);
2980
2981        let (conversation_view, cx) = setup_conversation_view_with_initial_content(
2982            StubAgentServer::default_response(),
2983            initial_content,
2984            cx,
2985        )
2986        .await;
2987
2988        active_thread(&conversation_view, cx)
2989            .update_in(cx, |view, window, cx| view.send(window, cx));
2990        cx.run_until_parked();
2991
2992        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
2993            assert!(!view.show_external_source_prompt_warning);
2994            assert_eq!(view.message_editor.read(cx).text(cx), "");
2995            assert_eq!(view.thread.read(cx).entries().len(), 2);
2996        });
2997    }
2998
2999    #[gpui::test]
3000    async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
3001        init_test(cx);
3002
3003        let (conversation_view, cx) =
3004            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3005
3006        let message_editor = message_editor(&conversation_view, cx);
3007        message_editor.update_in(cx, |editor, window, cx| {
3008            editor.set_text("Hello", window, cx);
3009        });
3010
3011        cx.deactivate_window();
3012
3013        active_thread(&conversation_view, cx)
3014            .update_in(cx, |view, window, cx| view.send(window, cx));
3015
3016        cx.run_until_parked();
3017
3018        assert!(
3019            cx.windows()
3020                .iter()
3021                .any(|window| window.downcast::<AgentNotification>().is_some())
3022        );
3023    }
3024
3025    #[gpui::test]
3026    async fn test_notification_for_error(cx: &mut TestAppContext) {
3027        init_test(cx);
3028
3029        let server = FakeAcpAgentServer::new();
3030        let (conversation_view, cx) = setup_conversation_view(server.clone(), cx).await;
3031
3032        let message_editor = message_editor(&conversation_view, cx);
3033        message_editor.update_in(cx, |editor, window, cx| {
3034            editor.set_text("Hello", window, cx);
3035        });
3036
3037        cx.deactivate_window();
3038        server.fail_next_prompt();
3039
3040        active_thread(&conversation_view, cx)
3041            .update_in(cx, |view, window, cx| view.send(window, cx));
3042
3043        cx.run_until_parked();
3044
3045        assert!(
3046            cx.windows()
3047                .iter()
3048                .any(|window| window.downcast::<AgentNotification>().is_some())
3049        );
3050    }
3051
3052    #[gpui::test]
3053    async fn test_acp_server_exit_transitions_conversation_to_load_error_without_panic(
3054        cx: &mut TestAppContext,
3055    ) {
3056        init_test(cx);
3057
3058        let server = FakeAcpAgentServer::new();
3059        let close_session_count = server.close_session_count();
3060        let (conversation_view, cx) = setup_conversation_view(server.clone(), cx).await;
3061
3062        cx.run_until_parked();
3063
3064        server.simulate_server_exit();
3065        cx.run_until_parked();
3066
3067        conversation_view.read_with(cx, |view, _cx| {
3068            assert!(
3069                matches!(view.server_state, ServerState::LoadError { .. }),
3070                "Conversation should transition to LoadError when an ACP thread exits"
3071            );
3072        });
3073        assert_eq!(
3074            close_session_count.load(std::sync::atomic::Ordering::SeqCst),
3075            1,
3076            "ConversationView should close the ACP session after a thread exit"
3077        );
3078    }
3079
3080    #[gpui::test]
3081    async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
3082        init_test(cx);
3083
3084        let fs = FakeFs::new(cx.executor());
3085        let project = Project::test(fs, [], cx).await;
3086        let (multi_workspace, cx) =
3087            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3088        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3089
3090        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3091        let connection_store =
3092            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3093
3094        let conversation_view = cx.update(|window, cx| {
3095            cx.new(|cx| {
3096                ConversationView::new(
3097                    Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
3098                    connection_store,
3099                    Agent::Custom { id: "Test".into() },
3100                    Some(acp::SessionId::new("resume-session")),
3101                    None,
3102                    None,
3103                    None,
3104                    None,
3105                    workspace.downgrade(),
3106                    project,
3107                    Some(thread_store),
3108                    None,
3109                    "agent_panel",
3110                    window,
3111                    cx,
3112                )
3113            })
3114        });
3115
3116        cx.run_until_parked();
3117
3118        conversation_view.read_with(cx, |view, cx| {
3119            let state = view.active_thread().unwrap();
3120            assert!(state.read(cx).resumed_without_history);
3121            assert_eq!(state.read(cx).list_state.item_count(), 0);
3122        });
3123    }
3124
3125    #[derive(Clone)]
3126    struct RestoredAvailableCommandsConnection;
3127
3128    impl AgentConnection for RestoredAvailableCommandsConnection {
3129        fn agent_id(&self) -> AgentId {
3130            AgentId::new("restored-available-commands")
3131        }
3132
3133        fn telemetry_id(&self) -> SharedString {
3134            "restored-available-commands".into()
3135        }
3136
3137        fn new_session(
3138            self: Rc<Self>,
3139            project: Entity<Project>,
3140            _work_dirs: PathList,
3141            cx: &mut App,
3142        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3143            let thread = build_test_thread(
3144                self,
3145                project,
3146                "RestoredAvailableCommandsConnection",
3147                acp::SessionId::new("new-session"),
3148                cx,
3149            );
3150            Task::ready(Ok(thread))
3151        }
3152
3153        fn supports_load_session(&self) -> bool {
3154            true
3155        }
3156
3157        fn load_session(
3158            self: Rc<Self>,
3159            session_id: acp::SessionId,
3160            project: Entity<Project>,
3161            _work_dirs: PathList,
3162            _title: Option<SharedString>,
3163            cx: &mut App,
3164        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3165            let thread = build_test_thread(
3166                self,
3167                project,
3168                "RestoredAvailableCommandsConnection",
3169                session_id,
3170                cx,
3171            );
3172
3173            thread
3174                .update(cx, |thread, cx| {
3175                    thread.handle_session_update(
3176                        acp::SessionUpdate::AvailableCommandsUpdate(
3177                            acp::AvailableCommandsUpdate::new(vec![acp::AvailableCommand::new(
3178                                "help", "Get help",
3179                            )]),
3180                        ),
3181                        cx,
3182                    )
3183                })
3184                .expect("available commands update should succeed");
3185
3186            Task::ready(Ok(thread))
3187        }
3188
3189        fn auth_methods(&self) -> &[acp::AuthMethod] {
3190            &[]
3191        }
3192
3193        fn authenticate(
3194            &self,
3195            _method_id: acp::AuthMethodId,
3196            _cx: &mut App,
3197        ) -> Task<gpui::Result<()>> {
3198            Task::ready(Ok(()))
3199        }
3200
3201        fn prompt(
3202            &self,
3203            _id: acp_thread::UserMessageId,
3204            _params: acp::PromptRequest,
3205            _cx: &mut App,
3206        ) -> Task<gpui::Result<acp::PromptResponse>> {
3207            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
3208        }
3209
3210        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
3211
3212        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3213            self
3214        }
3215    }
3216
3217    #[gpui::test]
3218    async fn test_restored_threads_keep_available_commands(cx: &mut TestAppContext) {
3219        init_test(cx);
3220
3221        let fs = FakeFs::new(cx.executor());
3222        let project = Project::test(fs, [], cx).await;
3223        let (multi_workspace, cx) =
3224            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3225        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3226
3227        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3228        let connection_store =
3229            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3230
3231        let conversation_view = cx.update(|window, cx| {
3232            cx.new(|cx| {
3233                ConversationView::new(
3234                    Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
3235                    connection_store,
3236                    Agent::Custom { id: "Test".into() },
3237                    Some(acp::SessionId::new("restored-session")),
3238                    None,
3239                    None,
3240                    None,
3241                    None,
3242                    workspace.downgrade(),
3243                    project,
3244                    Some(thread_store),
3245                    None,
3246                    "agent_panel",
3247                    window,
3248                    cx,
3249                )
3250            })
3251        });
3252
3253        cx.run_until_parked();
3254
3255        let message_editor = message_editor(&conversation_view, cx);
3256        let editor =
3257            message_editor.update(cx, |message_editor, _cx| message_editor.editor().clone());
3258        let placeholder = editor.update(cx, |editor, cx| editor.placeholder_text(cx));
3259
3260        active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
3261            let available_commands = view
3262                .session_capabilities
3263                .read()
3264                .available_commands()
3265                .to_vec();
3266            assert_eq!(available_commands.len(), 1);
3267            assert_eq!(available_commands[0].name.as_str(), "help");
3268            assert_eq!(available_commands[0].description.as_str(), "Get help");
3269        });
3270
3271        assert_eq!(
3272            placeholder,
3273            Some("Message Test — @ to include context, / for commands".to_string())
3274        );
3275
3276        message_editor.update_in(cx, |editor, window, cx| {
3277            editor.set_text("/help", window, cx);
3278        });
3279
3280        let contents_result = message_editor
3281            .update(cx, |editor, cx| editor.contents(false, cx))
3282            .await;
3283
3284        assert!(contents_result.is_ok());
3285    }
3286
3287    #[gpui::test]
3288    async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) {
3289        init_test(cx);
3290
3291        let fs = FakeFs::new(cx.executor());
3292        fs.insert_tree(
3293            "/project",
3294            json!({
3295                "subdir": {
3296                    "file.txt": "hello"
3297                }
3298            }),
3299        )
3300        .await;
3301        let project = Project::test(fs, [Path::new("/project")], cx).await;
3302        let (multi_workspace, cx) =
3303            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3304        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3305
3306        let connection = CwdCapturingConnection::new();
3307        let captured_cwd = connection.captured_work_dirs.clone();
3308
3309        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3310        let connection_store =
3311            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3312
3313        let _conversation_view = cx.update(|window, cx| {
3314            cx.new(|cx| {
3315                ConversationView::new(
3316                    Rc::new(StubAgentServer::new(connection)),
3317                    connection_store,
3318                    Agent::Custom { id: "Test".into() },
3319                    Some(acp::SessionId::new("session-1")),
3320                    None,
3321                    Some(PathList::new(&[PathBuf::from("/project/subdir")])),
3322                    None,
3323                    None,
3324                    workspace.downgrade(),
3325                    project,
3326                    Some(thread_store),
3327                    None,
3328                    "agent_panel",
3329                    window,
3330                    cx,
3331                )
3332            })
3333        });
3334
3335        cx.run_until_parked();
3336
3337        assert_eq!(
3338            captured_cwd.lock().as_ref().unwrap(),
3339            &PathList::new(&[Path::new("/project/subdir")]),
3340            "Should use session cwd when it's inside the project"
3341        );
3342    }
3343
3344    #[gpui::test]
3345    async fn test_refusal_handling(cx: &mut TestAppContext) {
3346        init_test(cx);
3347
3348        let (conversation_view, cx) =
3349            setup_conversation_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
3350
3351        let message_editor = message_editor(&conversation_view, cx);
3352        message_editor.update_in(cx, |editor, window, cx| {
3353            editor.set_text("Do something harmful", window, cx);
3354        });
3355
3356        active_thread(&conversation_view, cx)
3357            .update_in(cx, |view, window, cx| view.send(window, cx));
3358
3359        cx.run_until_parked();
3360
3361        // Check that the refusal error is set
3362        conversation_view.read_with(cx, |thread_view, cx| {
3363            let state = thread_view.active_thread().unwrap();
3364            assert!(
3365                matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)),
3366                "Expected refusal error to be set"
3367            );
3368        });
3369    }
3370
3371    #[gpui::test]
3372    async fn test_connect_failure_transitions_to_load_error(cx: &mut TestAppContext) {
3373        init_test(cx);
3374
3375        let (conversation_view, cx) = setup_conversation_view(FailingAgentServer, cx).await;
3376
3377        conversation_view.read_with(cx, |view, cx| {
3378            let title = view.title(cx);
3379            assert_eq!(
3380                title.as_ref(),
3381                "Error Loading Codex CLI",
3382                "Tab title should show the agent name with an error prefix"
3383            );
3384            match &view.server_state {
3385                ServerState::LoadError {
3386                    error: LoadError::Other(msg),
3387                    ..
3388                } => {
3389                    assert!(
3390                        msg.contains("Invalid gzip header"),
3391                        "Error callout should contain the underlying extraction error, got: {msg}"
3392                    );
3393                }
3394                other => panic!(
3395                    "Expected LoadError::Other, got: {}",
3396                    match other {
3397                        ServerState::Loading { .. } => "Loading (stuck!)",
3398                        ServerState::LoadError { .. } => "LoadError (wrong variant)",
3399                        ServerState::Connected(_) => "Connected",
3400                    }
3401                ),
3402            }
3403        });
3404    }
3405
3406    #[gpui::test]
3407    async fn test_reset_preserves_session_id_after_load_error(cx: &mut TestAppContext) {
3408        use crate::thread_metadata_store::{ThreadId, ThreadMetadata};
3409        use chrono::Utc;
3410        use project::{AgentId as ProjectAgentId, WorktreePaths};
3411        use std::sync::atomic::Ordering;
3412
3413        init_test(cx);
3414
3415        let fs = FakeFs::new(cx.executor());
3416        let project = Project::test(fs, [], cx).await;
3417        let (multi_workspace, cx) =
3418            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3419        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3420
3421        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3422        let connection_store =
3423            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3424
3425        // Simulate a previous run that persisted metadata for this session.
3426        let resume_session_id = acp::SessionId::new("persistent-session");
3427        let stored_title: SharedString = "Persistent chat".into();
3428        cx.update(|_window, cx| {
3429            ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3430                store.save(
3431                    ThreadMetadata {
3432                        thread_id: ThreadId::new(),
3433                        session_id: Some(resume_session_id.clone()),
3434                        agent_id: ProjectAgentId::new("Flaky"),
3435                        title: Some(stored_title.clone()),
3436                        updated_at: Utc::now(),
3437                        created_at: Some(Utc::now()),
3438                        interacted_at: None,
3439                        worktree_paths: WorktreePaths::from_folder_paths(&PathList::default()),
3440                        remote_connection: None,
3441                        archived: false,
3442                    },
3443                    cx,
3444                );
3445            });
3446        });
3447
3448        let connection = StubAgentConnection::new().with_supports_load_session(true);
3449        let (server, fail) = FlakyAgentServer::new(connection);
3450
3451        let conversation_view = cx.update(|window, cx| {
3452            cx.new(|cx| {
3453                ConversationView::new(
3454                    Rc::new(server),
3455                    connection_store,
3456                    Agent::Custom { id: "Flaky".into() },
3457                    Some(resume_session_id.clone()),
3458                    None,
3459                    None,
3460                    None,
3461                    None,
3462                    workspace.downgrade(),
3463                    project.clone(),
3464                    Some(thread_store),
3465                    None,
3466                    "agent_panel",
3467                    window,
3468                    cx,
3469                )
3470            })
3471        });
3472        cx.run_until_parked();
3473
3474        // The first connect() fails, so we land in LoadError.
3475        conversation_view.read_with(cx, |view, _cx| {
3476            assert!(
3477                matches!(view.server_state, ServerState::LoadError { .. }),
3478                "expected LoadError after failed initial connect"
3479            );
3480            assert_eq!(
3481                view.root_session_id.as_ref(),
3482                Some(&resume_session_id),
3483                "root_session_id should still hold the original id while in LoadError"
3484            );
3485        });
3486
3487        // Now let the agent come online and emit AgentServersUpdated. This is
3488        // the moment the bug would have stomped on root_session_id.
3489        fail.store(false, Ordering::SeqCst);
3490        project.update(cx, |project, cx| {
3491            project
3492                .agent_server_store()
3493                .update(cx, |_store, cx| cx.emit(project::AgentServersUpdated));
3494        });
3495        cx.run_until_parked();
3496
3497        // The retry should have resumed the ORIGINAL session, not created a
3498        // brand-new one.
3499        conversation_view.read_with(cx, |view, cx| {
3500            let connected = view
3501                .as_connected()
3502                .expect("should be Connected after flaky server comes online");
3503            let active_id = connected
3504                .active_id
3505                .as_ref()
3506                .expect("Connected state should have an active_id");
3507            assert_eq!(
3508                active_id, &resume_session_id,
3509                "reset() must resume the original session id, not call new_session()"
3510            );
3511            let active_thread = view
3512                .active_thread()
3513                .expect("should have an active thread view");
3514            let thread_session = active_thread.read(cx).thread.read(cx).session_id().clone();
3515            assert_eq!(
3516                thread_session, resume_session_id,
3517                "the live AcpThread should hold the resumed session id"
3518            );
3519        });
3520    }
3521
3522    #[gpui::test]
3523    async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) {
3524        init_test(cx);
3525
3526        let connection = AuthGatedAgentConnection::new();
3527        let (conversation_view, cx) =
3528            setup_conversation_view(StubAgentServer::new(connection), cx).await;
3529
3530        // When new_session returns AuthRequired, the server should transition
3531        // to Connected + Unauthenticated rather than getting stuck in Loading.
3532        conversation_view.read_with(cx, |view, _cx| {
3533            let connected = view
3534                .as_connected()
3535                .expect("Should be in Connected state even though auth is required");
3536            assert!(
3537                !connected.auth_state.is_ok(),
3538                "Auth state should be Unauthenticated"
3539            );
3540            assert!(
3541                connected.active_id.is_none(),
3542                "There should be no active thread since no session was created"
3543            );
3544            assert!(
3545                connected.threads.is_empty(),
3546                "There should be no threads since no session was created"
3547            );
3548        });
3549
3550        conversation_view.read_with(cx, |view, _cx| {
3551            assert!(
3552                view.active_thread().is_none(),
3553                "active_thread() should be None when unauthenticated without a session"
3554            );
3555        });
3556
3557        // Authenticate using the real authenticate flow on ConnectionView.
3558        // This calls connection.authenticate(), which flips the internal flag,
3559        // then on success triggers reset() -> new_session() which now succeeds.
3560        conversation_view.update_in(cx, |view, window, cx| {
3561            view.authenticate(
3562                acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID),
3563                window,
3564                cx,
3565            );
3566        });
3567        cx.run_until_parked();
3568
3569        // After auth, the server should have an active thread in the Ok state.
3570        conversation_view.read_with(cx, |view, cx| {
3571            let connected = view
3572                .as_connected()
3573                .expect("Should still be in Connected state after auth");
3574            assert!(connected.auth_state.is_ok(), "Auth state should be Ok");
3575            assert!(
3576                connected.active_id.is_some(),
3577                "There should be an active thread after successful auth"
3578            );
3579            assert_eq!(
3580                connected.threads.len(),
3581                1,
3582                "There should be exactly one thread"
3583            );
3584
3585            let active = view
3586                .active_thread()
3587                .expect("active_thread() should return the new thread");
3588            assert!(
3589                active.read(cx).thread_error.is_none(),
3590                "The new thread should have no errors"
3591            );
3592        });
3593    }
3594
3595    #[gpui::test]
3596    async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3597        init_test(cx);
3598
3599        let tool_call_id = acp::ToolCallId::new("1");
3600        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label")
3601            .kind(acp::ToolKind::Edit)
3602            .content(vec!["hi".into()]);
3603        let connection =
3604            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
3605                tool_call_id,
3606                PermissionOptions::Flat(vec![acp::PermissionOption::new(
3607                    "1",
3608                    "Allow",
3609                    acp::PermissionOptionKind::AllowOnce,
3610                )]),
3611            )]));
3612
3613        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
3614
3615        let (conversation_view, cx) =
3616            setup_conversation_view(StubAgentServer::new(connection), cx).await;
3617
3618        let message_editor = message_editor(&conversation_view, cx);
3619        message_editor.update_in(cx, |editor, window, cx| {
3620            editor.set_text("Hello", window, cx);
3621        });
3622
3623        cx.deactivate_window();
3624
3625        active_thread(&conversation_view, cx)
3626            .update_in(cx, |view, window, cx| view.send(window, cx));
3627
3628        cx.run_until_parked();
3629
3630        assert!(
3631            cx.windows()
3632                .iter()
3633                .any(|window| window.downcast::<AgentNotification>().is_some())
3634        );
3635    }
3636
3637    #[gpui::test]
3638    async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) {
3639        init_test(cx);
3640
3641        let (conversation_view, cx) =
3642            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3643
3644        add_to_workspace(conversation_view.clone(), cx);
3645
3646        let message_editor = message_editor(&conversation_view, cx);
3647
3648        message_editor.update_in(cx, |editor, window, cx| {
3649            editor.set_text("Hello", window, cx);
3650        });
3651
3652        // Window is active (don't deactivate), but panel will be hidden
3653        // Note: In the test environment, the panel is not actually added to the dock,
3654        // so is_agent_panel_hidden will return true
3655
3656        active_thread(&conversation_view, cx)
3657            .update_in(cx, |view, window, cx| view.send(window, cx));
3658
3659        cx.run_until_parked();
3660
3661        // Should show notification because window is active but panel is hidden
3662        assert!(
3663            cx.windows()
3664                .iter()
3665                .any(|window| window.downcast::<AgentNotification>().is_some()),
3666            "Expected notification when panel is hidden"
3667        );
3668    }
3669
3670    #[gpui::test]
3671    async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) {
3672        init_test(cx);
3673
3674        let (conversation_view, cx) =
3675            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3676
3677        let message_editor = message_editor(&conversation_view, cx);
3678        message_editor.update_in(cx, |editor, window, cx| {
3679            editor.set_text("Hello", window, cx);
3680        });
3681
3682        // Deactivate window - should show notification regardless of setting
3683        cx.deactivate_window();
3684
3685        active_thread(&conversation_view, cx)
3686            .update_in(cx, |view, window, cx| view.send(window, cx));
3687
3688        cx.run_until_parked();
3689
3690        // Should still show notification when window is inactive (existing behavior)
3691        assert!(
3692            cx.windows()
3693                .iter()
3694                .any(|window| window.downcast::<AgentNotification>().is_some()),
3695            "Expected notification when window is inactive"
3696        );
3697    }
3698
3699    #[gpui::test]
3700    async fn test_notification_when_different_conversation_is_active_in_visible_panel(
3701        cx: &mut TestAppContext,
3702    ) {
3703        init_test(cx);
3704
3705        let fs = FakeFs::new(cx.executor());
3706
3707        cx.update(|cx| {
3708            cx.update_flags(true, vec!["agent-v2".to_string()]);
3709            agent::ThreadStore::init_global(cx);
3710            language_model::LanguageModelRegistry::test(cx);
3711            <dyn Fs>::set_global(fs.clone(), cx);
3712        });
3713
3714        let project = Project::test(fs, [], cx).await;
3715        let multi_workspace_handle =
3716            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3717
3718        let workspace = multi_workspace_handle
3719            .read_with(cx, |mw, _cx| mw.workspace().clone())
3720            .unwrap();
3721
3722        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3723
3724        let panel = workspace.update_in(cx, |workspace, window, cx| {
3725            let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx));
3726            workspace.add_panel(panel.clone(), window, cx);
3727            workspace.focus_panel::<crate::AgentPanel>(window, cx);
3728            panel
3729        });
3730
3731        cx.run_until_parked();
3732
3733        panel.update_in(cx, |panel, window, cx| {
3734            panel.open_external_thread_with_server(
3735                Rc::new(StubAgentServer::default_response()),
3736                window,
3737                cx,
3738            );
3739        });
3740
3741        cx.run_until_parked();
3742
3743        panel.read_with(cx, |panel, cx| {
3744            assert!(crate::AgentPanel::is_visible(&workspace, cx));
3745            assert!(panel.active_conversation_view().is_some());
3746        });
3747
3748        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3749        let connection_store =
3750            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3751
3752        let conversation_view = cx.update(|window, cx| {
3753            cx.new(|cx| {
3754                ConversationView::new(
3755                    Rc::new(StubAgentServer::default_response()),
3756                    connection_store,
3757                    Agent::Custom { id: "Test".into() },
3758                    None,
3759                    None,
3760                    None,
3761                    None,
3762                    None,
3763                    workspace.downgrade(),
3764                    project.clone(),
3765                    Some(thread_store),
3766                    None,
3767                    "agent_panel",
3768                    window,
3769                    cx,
3770                )
3771            })
3772        });
3773
3774        cx.run_until_parked();
3775
3776        panel.read_with(cx, |panel, _cx| {
3777            assert_ne!(
3778                panel
3779                    .active_conversation_view()
3780                    .map(|view| view.entity_id()),
3781                Some(conversation_view.entity_id()),
3782                "The visible panel should still be showing a different conversation"
3783            );
3784        });
3785
3786        let message_editor = message_editor(&conversation_view, cx);
3787        message_editor.update_in(cx, |editor, window, cx| {
3788            editor.set_text("Hello", window, cx);
3789        });
3790
3791        active_thread(&conversation_view, cx)
3792            .update_in(cx, |view, window, cx| view.send(window, cx));
3793
3794        cx.run_until_parked();
3795
3796        assert!(
3797            cx.windows()
3798                .iter()
3799                .any(|window| window.downcast::<AgentNotification>().is_some()),
3800            "Expected notification when a different conversation is active in the visible panel"
3801        );
3802    }
3803
3804    #[gpui::test]
3805    async fn test_no_notification_when_sidebar_open_but_different_thread_focused(
3806        cx: &mut TestAppContext,
3807    ) {
3808        init_test(cx);
3809
3810        let fs = FakeFs::new(cx.executor());
3811
3812        cx.update(|cx| {
3813            cx.update_flags(true, vec!["agent-v2".to_string()]);
3814            agent::ThreadStore::init_global(cx);
3815            language_model::LanguageModelRegistry::test(cx);
3816            <dyn Fs>::set_global(fs.clone(), cx);
3817        });
3818
3819        let project = Project::test(fs, [], cx).await;
3820        let multi_workspace_handle =
3821            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3822
3823        let workspace = multi_workspace_handle
3824            .read_with(cx, |mw, _cx| mw.workspace().clone())
3825            .unwrap();
3826
3827        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3828
3829        // Open the sidebar so that sidebar_open() returns true.
3830        multi_workspace_handle
3831            .update(cx, |mw, _window, cx| {
3832                mw.open_sidebar(cx);
3833            })
3834            .unwrap();
3835
3836        cx.run_until_parked();
3837
3838        assert!(
3839            multi_workspace_handle
3840                .read_with(cx, |mw, _cx| mw.sidebar_open())
3841                .unwrap(),
3842            "Sidebar should be open"
3843        );
3844
3845        // Create a conversation view that is NOT the active one in the panel.
3846        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3847        let connection_store =
3848            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3849
3850        let conversation_view = cx.update(|window, cx| {
3851            cx.new(|cx| {
3852                ConversationView::new(
3853                    Rc::new(StubAgentServer::default_response()),
3854                    connection_store,
3855                    Agent::Custom { id: "Test".into() },
3856                    None,
3857                    None,
3858                    None,
3859                    None,
3860                    None,
3861                    workspace.downgrade(),
3862                    project.clone(),
3863                    Some(thread_store),
3864                    None,
3865                    "agent_panel",
3866                    window,
3867                    cx,
3868                )
3869            })
3870        });
3871
3872        cx.run_until_parked();
3873
3874        let message_editor = message_editor(&conversation_view, cx);
3875        message_editor.update_in(cx, |editor, window, cx| {
3876            editor.set_text("Hello", window, cx);
3877        });
3878
3879        active_thread(&conversation_view, cx)
3880            .update_in(cx, |view, window, cx| view.send(window, cx));
3881
3882        cx.run_until_parked();
3883
3884        assert!(
3885            !cx.windows()
3886                .iter()
3887                .any(|window| window.downcast::<AgentNotification>().is_some()),
3888            "Expected no notification when the sidebar is open, even if focused on another thread"
3889        );
3890    }
3891
3892    #[gpui::test]
3893    async fn test_notification_when_workspace_is_background_in_multi_workspace(
3894        cx: &mut TestAppContext,
3895    ) {
3896        init_test(cx);
3897
3898        // Enable multi-workspace feature flag and init globals needed by AgentPanel
3899        let fs = FakeFs::new(cx.executor());
3900
3901        cx.update(|cx| {
3902            agent::ThreadStore::init_global(cx);
3903            language_model::LanguageModelRegistry::test(cx);
3904            <dyn Fs>::set_global(fs.clone(), cx);
3905        });
3906
3907        let project1 = Project::test(fs.clone(), [], cx).await;
3908
3909        // Create a MultiWorkspace window with one workspace
3910        let multi_workspace_handle =
3911            cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
3912
3913        // Get workspace 1 (the initial workspace)
3914        let workspace1 = multi_workspace_handle
3915            .read_with(cx, |mw, _cx| mw.workspace().clone())
3916            .unwrap();
3917
3918        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3919
3920        let panel = workspace1.update_in(cx, |workspace, window, cx| {
3921            let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx));
3922            workspace.add_panel(panel.clone(), window, cx);
3923
3924            // Open the dock and activate the agent panel so it's visible
3925            workspace.focus_panel::<crate::AgentPanel>(window, cx);
3926            panel
3927        });
3928
3929        cx.run_until_parked();
3930
3931        panel.update_in(cx, |panel, window, cx| {
3932            panel.open_external_thread_with_server(
3933                Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
3934                window,
3935                cx,
3936            );
3937        });
3938
3939        cx.run_until_parked();
3940
3941        cx.read(|cx| {
3942            assert!(
3943                crate::AgentPanel::is_visible(&workspace1, cx),
3944                "AgentPanel should be visible in workspace1's dock"
3945            );
3946        });
3947
3948        // Set up thread view in workspace 1
3949        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3950        let connection_store =
3951            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
3952
3953        let conversation_view = cx.update(|window, cx| {
3954            cx.new(|cx| {
3955                ConversationView::new(
3956                    Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
3957                    connection_store,
3958                    Agent::Custom { id: "Test".into() },
3959                    None,
3960                    None,
3961                    None,
3962                    None,
3963                    None,
3964                    workspace1.downgrade(),
3965                    project1.clone(),
3966                    Some(thread_store),
3967                    None,
3968                    "agent_panel",
3969                    window,
3970                    cx,
3971                )
3972            })
3973        });
3974        cx.run_until_parked();
3975
3976        let root_session_id = conversation_view
3977            .read_with(cx, |view, cx| {
3978                view.root_thread_view()
3979                    .map(|thread| thread.read(cx).thread.read(cx).session_id().clone())
3980            })
3981            .expect("Conversation view should have a root thread");
3982
3983        let message_editor = message_editor(&conversation_view, cx);
3984        message_editor.update_in(cx, |editor, window, cx| {
3985            editor.set_text("Hello", window, cx);
3986        });
3987
3988        // Create a second workspace and switch to it.
3989        // This makes workspace1 the "background" workspace.
3990        let project2 = Project::test(fs, [], cx).await;
3991        multi_workspace_handle
3992            .update(cx, |mw, window, cx| {
3993                mw.test_add_workspace(project2, window, cx);
3994            })
3995            .unwrap();
3996
3997        cx.run_until_parked();
3998
3999        // Verify workspace1 is no longer the active workspace
4000        multi_workspace_handle
4001            .read_with(cx, |mw, _cx| {
4002                assert_ne!(mw.workspace(), &workspace1);
4003            })
4004            .unwrap();
4005
4006        // Window is active, agent panel is visible in workspace1, but workspace1
4007        // is in the background. The notification should show because the user
4008        // can't actually see the agent panel.
4009        active_thread(&conversation_view, cx)
4010            .update_in(cx, |view, window, cx| view.send(window, cx));
4011
4012        cx.run_until_parked();
4013
4014        assert!(
4015            cx.windows()
4016                .iter()
4017                .any(|window| window.downcast::<AgentNotification>().is_some()),
4018            "Expected notification when workspace is in background within MultiWorkspace"
4019        );
4020
4021        // Also verify: clicking "View Panel" should switch to workspace1.
4022        cx.windows()
4023            .iter()
4024            .find_map(|window| window.downcast::<AgentNotification>())
4025            .unwrap()
4026            .update(cx, |window, _, cx| window.accept(cx))
4027            .unwrap();
4028
4029        cx.run_until_parked();
4030
4031        multi_workspace_handle
4032            .read_with(cx, |mw, _cx| {
4033                assert_eq!(
4034                    mw.workspace(),
4035                    &workspace1,
4036                    "Expected workspace1 to become the active workspace after accepting notification"
4037                );
4038            })
4039            .unwrap();
4040
4041        panel.read_with(cx, |panel, cx| {
4042            let active_session_id = panel
4043                .active_agent_thread(cx)
4044                .map(|thread| thread.read(cx).session_id().clone());
4045            assert_eq!(
4046                active_session_id,
4047                Some(root_session_id),
4048                "Expected accepting the notification to load the notified thread in AgentPanel"
4049            );
4050        });
4051    }
4052
4053    #[gpui::test]
4054    async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
4055        init_test(cx);
4056
4057        // Set notify_when_agent_waiting to Never
4058        cx.update(|cx| {
4059            AgentSettings::override_global(
4060                AgentSettings {
4061                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
4062                    ..AgentSettings::get_global(cx).clone()
4063                },
4064                cx,
4065            );
4066        });
4067
4068        let (conversation_view, cx) =
4069            setup_conversation_view(StubAgentServer::default_response(), cx).await;
4070
4071        let message_editor = message_editor(&conversation_view, cx);
4072        message_editor.update_in(cx, |editor, window, cx| {
4073            editor.set_text("Hello", window, cx);
4074        });
4075
4076        // Window is active
4077
4078        active_thread(&conversation_view, cx)
4079            .update_in(cx, |view, window, cx| view.send(window, cx));
4080
4081        cx.run_until_parked();
4082
4083        // Should NOT show notification because notify_when_agent_waiting is Never
4084        assert!(
4085            !cx.windows()
4086                .iter()
4087                .any(|window| window.downcast::<AgentNotification>().is_some()),
4088            "Expected no notification when notify_when_agent_waiting is Never"
4089        );
4090    }
4091
4092    #[gpui::test]
4093    async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
4094        init_test(cx);
4095
4096        let (conversation_view, cx) =
4097            setup_conversation_view(StubAgentServer::default_response(), cx).await;
4098
4099        let weak_view = conversation_view.downgrade();
4100
4101        let message_editor = message_editor(&conversation_view, cx);
4102        message_editor.update_in(cx, |editor, window, cx| {
4103            editor.set_text("Hello", window, cx);
4104        });
4105
4106        cx.deactivate_window();
4107
4108        active_thread(&conversation_view, cx)
4109            .update_in(cx, |view, window, cx| view.send(window, cx));
4110
4111        cx.run_until_parked();
4112
4113        // Verify notification is shown
4114        assert!(
4115            cx.windows()
4116                .iter()
4117                .any(|window| window.downcast::<AgentNotification>().is_some()),
4118            "Expected notification to be shown"
4119        );
4120
4121        // Drop the thread view (simulating navigation to a new thread)
4122        drop(conversation_view);
4123        drop(message_editor);
4124        // Trigger an update to flush effects, which will call release_dropped_entities
4125        cx.update(|_window, _cx| {});
4126        cx.run_until_parked();
4127
4128        // Verify the entity was actually released
4129        assert!(
4130            !weak_view.is_upgradable(),
4131            "Thread view entity should be released after dropping"
4132        );
4133
4134        // The notification should be automatically closed via on_release
4135        assert!(
4136            !cx.windows()
4137                .iter()
4138                .any(|window| window.downcast::<AgentNotification>().is_some()),
4139            "Notification should be closed when thread view is dropped"
4140        );
4141    }
4142
4143    async fn setup_conversation_view(
4144        agent: impl AgentServer + 'static,
4145        cx: &mut TestAppContext,
4146    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
4147        setup_conversation_view_with_initial_content_opt(agent, None, cx).await
4148    }
4149
4150    async fn setup_conversation_view_with_initial_content(
4151        agent: impl AgentServer + 'static,
4152        initial_content: AgentInitialContent,
4153        cx: &mut TestAppContext,
4154    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
4155        setup_conversation_view_with_initial_content_opt(agent, Some(initial_content), cx).await
4156    }
4157
4158    async fn setup_conversation_view_with_initial_content_opt(
4159        agent: impl AgentServer + 'static,
4160        initial_content: Option<AgentInitialContent>,
4161        cx: &mut TestAppContext,
4162    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
4163        let fs = FakeFs::new(cx.executor());
4164        let project = Project::test(fs, [], cx).await;
4165        let (multi_workspace, cx) =
4166            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4167        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4168
4169        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4170        let connection_store =
4171            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4172
4173        let agent_key = Agent::Custom { id: "Test".into() };
4174
4175        let conversation_view = cx.update(|window, cx| {
4176            cx.new(|cx| {
4177                ConversationView::new(
4178                    Rc::new(agent),
4179                    connection_store.clone(),
4180                    agent_key.clone(),
4181                    None,
4182                    None,
4183                    None,
4184                    None,
4185                    initial_content,
4186                    workspace.downgrade(),
4187                    project,
4188                    Some(thread_store),
4189                    None,
4190                    "agent_panel",
4191                    window,
4192                    cx,
4193                )
4194            })
4195        });
4196        cx.run_until_parked();
4197
4198        (conversation_view, cx)
4199    }
4200
4201    fn add_to_workspace(conversation_view: Entity<ConversationView>, cx: &mut VisualTestContext) {
4202        let workspace =
4203            conversation_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
4204
4205        workspace
4206            .update_in(cx, |workspace, window, cx| {
4207                workspace.add_item_to_active_pane(
4208                    Box::new(cx.new(|_| ThreadViewItem(conversation_view.clone()))),
4209                    None,
4210                    true,
4211                    window,
4212                    cx,
4213                );
4214            })
4215            .unwrap();
4216    }
4217
4218    struct ThreadViewItem(Entity<ConversationView>);
4219
4220    impl Item for ThreadViewItem {
4221        type Event = ();
4222
4223        fn include_in_nav_history() -> bool {
4224            false
4225        }
4226
4227        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
4228            "Test".into()
4229        }
4230    }
4231
4232    impl EventEmitter<()> for ThreadViewItem {}
4233
4234    impl Focusable for ThreadViewItem {
4235        fn focus_handle(&self, cx: &App) -> FocusHandle {
4236            self.0.read(cx).focus_handle(cx)
4237        }
4238    }
4239
4240    impl Render for ThreadViewItem {
4241        fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4242            // Render the title editor in the element tree too. In the real app
4243            // it is part of the agent panel
4244            let title_editor = self
4245                .0
4246                .read(cx)
4247                .active_thread()
4248                .map(|t| t.read(cx).title_editor.clone());
4249
4250            v_flex().children(title_editor).child(self.0.clone())
4251        }
4252    }
4253
4254    pub(crate) struct StubAgentServer<C> {
4255        connection: C,
4256    }
4257
4258    impl<C> StubAgentServer<C> {
4259        pub(crate) fn new(connection: C) -> Self {
4260            Self { connection }
4261        }
4262    }
4263
4264    impl StubAgentServer<StubAgentConnection> {
4265        pub(crate) fn default_response() -> Self {
4266            let conn = StubAgentConnection::new();
4267            conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4268                acp::ContentChunk::new("Default response".into()),
4269            )]);
4270            Self::new(conn)
4271        }
4272    }
4273
4274    impl<C> AgentServer for StubAgentServer<C>
4275    where
4276        C: 'static + AgentConnection + Send + Clone,
4277    {
4278        fn logo(&self) -> ui::IconName {
4279            ui::IconName::ZedAgent
4280        }
4281
4282        fn agent_id(&self) -> AgentId {
4283            "Test".into()
4284        }
4285
4286        fn connect(
4287            &self,
4288            _delegate: AgentServerDelegate,
4289            _project: Entity<Project>,
4290            _cx: &mut App,
4291        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
4292            Task::ready(Ok(Rc::new(self.connection.clone())))
4293        }
4294
4295        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4296            self
4297        }
4298    }
4299
4300    struct FailingAgentServer;
4301
4302    impl AgentServer for FailingAgentServer {
4303        fn logo(&self) -> ui::IconName {
4304            ui::IconName::AiOpenAi
4305        }
4306
4307        fn agent_id(&self) -> AgentId {
4308            AgentId::new("Codex CLI")
4309        }
4310
4311        fn connect(
4312            &self,
4313            _delegate: AgentServerDelegate,
4314            _project: Entity<Project>,
4315            _cx: &mut App,
4316        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
4317            Task::ready(Err(anyhow!(
4318                "extracting downloaded asset for \
4319                 https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\
4320                 codex-acp-0.9.4-aarch64-pc-windows-msvc.zip: \
4321                 failed to iterate over archive: Invalid gzip header"
4322            )))
4323        }
4324
4325        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4326            self
4327        }
4328    }
4329
4330    /// Agent server whose `connect()` fails while `fail` is `true` and
4331    /// returns the wrapped connection otherwise. Used to simulate the
4332    /// race where an external agent isn't yet registered at startup.
4333    pub(crate) struct FlakyAgentServer {
4334        connection: StubAgentConnection,
4335        fail: Arc<std::sync::atomic::AtomicBool>,
4336    }
4337
4338    impl FlakyAgentServer {
4339        pub(crate) fn new(
4340            connection: StubAgentConnection,
4341        ) -> (Self, Arc<std::sync::atomic::AtomicBool>) {
4342            let fail = Arc::new(std::sync::atomic::AtomicBool::new(true));
4343            (
4344                Self {
4345                    connection,
4346                    fail: fail.clone(),
4347                },
4348                fail,
4349            )
4350        }
4351    }
4352
4353    impl AgentServer for FlakyAgentServer {
4354        fn logo(&self) -> ui::IconName {
4355            ui::IconName::ZedAgent
4356        }
4357
4358        fn agent_id(&self) -> AgentId {
4359            "Flaky".into()
4360        }
4361
4362        fn connect(
4363            &self,
4364            _delegate: AgentServerDelegate,
4365            _project: Entity<Project>,
4366            _cx: &mut App,
4367        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
4368            if self.fail.load(std::sync::atomic::Ordering::SeqCst) {
4369                Task::ready(Err(anyhow!(
4370                    "Custom agent server `Flaky` is not registered"
4371                )))
4372            } else {
4373                Task::ready(Ok(Rc::new(self.connection.clone())))
4374            }
4375        }
4376
4377        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4378            self
4379        }
4380    }
4381
4382    fn build_test_thread(
4383        connection: Rc<dyn AgentConnection>,
4384        project: Entity<Project>,
4385        name: &'static str,
4386        session_id: acp::SessionId,
4387        cx: &mut App,
4388    ) -> Entity<AcpThread> {
4389        let action_log = cx.new(|_| ActionLog::new(project.clone()));
4390        cx.new(|cx| {
4391            AcpThread::new(
4392                None,
4393                Some(name.into()),
4394                None,
4395                connection,
4396                project,
4397                action_log,
4398                session_id,
4399                watch::Receiver::constant(
4400                    acp::PromptCapabilities::new()
4401                        .image(true)
4402                        .audio(true)
4403                        .embedded_context(true),
4404                ),
4405                cx,
4406            )
4407        })
4408    }
4409
4410    #[derive(Clone)]
4411    struct ResumeOnlyAgentConnection;
4412
4413    impl AgentConnection for ResumeOnlyAgentConnection {
4414        fn agent_id(&self) -> AgentId {
4415            AgentId::new("resume-only")
4416        }
4417
4418        fn telemetry_id(&self) -> SharedString {
4419            "resume-only".into()
4420        }
4421
4422        fn new_session(
4423            self: Rc<Self>,
4424            project: Entity<Project>,
4425            _work_dirs: PathList,
4426            cx: &mut gpui::App,
4427        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4428            let thread = build_test_thread(
4429                self,
4430                project,
4431                "ResumeOnlyAgentConnection",
4432                acp::SessionId::new("new-session"),
4433                cx,
4434            );
4435            Task::ready(Ok(thread))
4436        }
4437
4438        fn supports_resume_session(&self) -> bool {
4439            true
4440        }
4441
4442        fn resume_session(
4443            self: Rc<Self>,
4444            session_id: acp::SessionId,
4445            project: Entity<Project>,
4446            _work_dirs: PathList,
4447            _title: Option<SharedString>,
4448            cx: &mut App,
4449        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4450            let thread =
4451                build_test_thread(self, project, "ResumeOnlyAgentConnection", session_id, cx);
4452            Task::ready(Ok(thread))
4453        }
4454
4455        fn auth_methods(&self) -> &[acp::AuthMethod] {
4456            &[]
4457        }
4458
4459        fn authenticate(
4460            &self,
4461            _method_id: acp::AuthMethodId,
4462            _cx: &mut App,
4463        ) -> Task<gpui::Result<()>> {
4464            Task::ready(Ok(()))
4465        }
4466
4467        fn prompt(
4468            &self,
4469            _id: acp_thread::UserMessageId,
4470            _params: acp::PromptRequest,
4471            _cx: &mut App,
4472        ) -> Task<gpui::Result<acp::PromptResponse>> {
4473            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4474        }
4475
4476        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4477
4478        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4479            self
4480        }
4481    }
4482
4483    /// Simulates an agent that requires authentication before a session can be
4484    /// created. `new_session` returns `AuthRequired` until `authenticate` is
4485    /// called with the correct method, after which sessions are created normally.
4486    #[derive(Clone)]
4487    struct AuthGatedAgentConnection {
4488        authenticated: Arc<Mutex<bool>>,
4489        auth_method: acp::AuthMethod,
4490    }
4491
4492    impl AuthGatedAgentConnection {
4493        const AUTH_METHOD_ID: &str = "test-login";
4494
4495        fn new() -> Self {
4496            Self {
4497                authenticated: Arc::new(Mutex::new(false)),
4498                auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
4499                    Self::AUTH_METHOD_ID,
4500                    "Test Login",
4501                )),
4502            }
4503        }
4504    }
4505
4506    impl AgentConnection for AuthGatedAgentConnection {
4507        fn agent_id(&self) -> AgentId {
4508            AgentId::new("auth-gated")
4509        }
4510
4511        fn telemetry_id(&self) -> SharedString {
4512            "auth-gated".into()
4513        }
4514
4515        fn new_session(
4516            self: Rc<Self>,
4517            project: Entity<Project>,
4518            work_dirs: PathList,
4519            cx: &mut gpui::App,
4520        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4521            if !*self.authenticated.lock() {
4522                return Task::ready(Err(acp_thread::AuthRequired::new()
4523                    .with_description("Sign in to continue".to_string())
4524                    .into()));
4525            }
4526
4527            let session_id = acp::SessionId::new("auth-gated-session");
4528            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4529            Task::ready(Ok(cx.new(|cx| {
4530                AcpThread::new(
4531                    None,
4532                    None,
4533                    Some(work_dirs),
4534                    self,
4535                    project,
4536                    action_log,
4537                    session_id,
4538                    watch::Receiver::constant(
4539                        acp::PromptCapabilities::new()
4540                            .image(true)
4541                            .audio(true)
4542                            .embedded_context(true),
4543                    ),
4544                    cx,
4545                )
4546            })))
4547        }
4548
4549        fn auth_methods(&self) -> &[acp::AuthMethod] {
4550            std::slice::from_ref(&self.auth_method)
4551        }
4552
4553        fn authenticate(
4554            &self,
4555            method_id: acp::AuthMethodId,
4556            _cx: &mut App,
4557        ) -> Task<gpui::Result<()>> {
4558            if &method_id == self.auth_method.id() {
4559                *self.authenticated.lock() = true;
4560                Task::ready(Ok(()))
4561            } else {
4562                Task::ready(Err(anyhow::anyhow!("Unknown auth method")))
4563            }
4564        }
4565
4566        fn prompt(
4567            &self,
4568            _id: acp_thread::UserMessageId,
4569            _params: acp::PromptRequest,
4570            _cx: &mut App,
4571        ) -> Task<gpui::Result<acp::PromptResponse>> {
4572            unimplemented!()
4573        }
4574
4575        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4576            unimplemented!()
4577        }
4578
4579        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4580            self
4581        }
4582    }
4583
4584    /// Simulates a model which always returns a refusal response
4585    #[derive(Clone)]
4586    struct RefusalAgentConnection;
4587
4588    impl AgentConnection for RefusalAgentConnection {
4589        fn agent_id(&self) -> AgentId {
4590            AgentId::new("refusal")
4591        }
4592
4593        fn telemetry_id(&self) -> SharedString {
4594            "refusal".into()
4595        }
4596
4597        fn new_session(
4598            self: Rc<Self>,
4599            project: Entity<Project>,
4600            work_dirs: PathList,
4601            cx: &mut gpui::App,
4602        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4603            Task::ready(Ok(cx.new(|cx| {
4604                let action_log = cx.new(|_| ActionLog::new(project.clone()));
4605                AcpThread::new(
4606                    None,
4607                    None,
4608                    Some(work_dirs),
4609                    self,
4610                    project,
4611                    action_log,
4612                    acp::SessionId::new("test"),
4613                    watch::Receiver::constant(
4614                        acp::PromptCapabilities::new()
4615                            .image(true)
4616                            .audio(true)
4617                            .embedded_context(true),
4618                    ),
4619                    cx,
4620                )
4621            })))
4622        }
4623
4624        fn auth_methods(&self) -> &[acp::AuthMethod] {
4625            &[]
4626        }
4627
4628        fn authenticate(
4629            &self,
4630            _method_id: acp::AuthMethodId,
4631            _cx: &mut App,
4632        ) -> Task<gpui::Result<()>> {
4633            unimplemented!()
4634        }
4635
4636        fn prompt(
4637            &self,
4638            _id: acp_thread::UserMessageId,
4639            _params: acp::PromptRequest,
4640            _cx: &mut App,
4641        ) -> Task<gpui::Result<acp::PromptResponse>> {
4642            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal)))
4643        }
4644
4645        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4646            unimplemented!()
4647        }
4648
4649        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4650            self
4651        }
4652    }
4653
4654    #[derive(Clone)]
4655    struct CwdCapturingConnection {
4656        captured_work_dirs: Arc<Mutex<Option<PathList>>>,
4657    }
4658
4659    impl CwdCapturingConnection {
4660        fn new() -> Self {
4661            Self {
4662                captured_work_dirs: Arc::new(Mutex::new(None)),
4663            }
4664        }
4665    }
4666
4667    impl AgentConnection for CwdCapturingConnection {
4668        fn agent_id(&self) -> AgentId {
4669            AgentId::new("cwd-capturing")
4670        }
4671
4672        fn telemetry_id(&self) -> SharedString {
4673            "cwd-capturing".into()
4674        }
4675
4676        fn new_session(
4677            self: Rc<Self>,
4678            project: Entity<Project>,
4679            work_dirs: PathList,
4680            cx: &mut gpui::App,
4681        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4682            *self.captured_work_dirs.lock() = Some(work_dirs.clone());
4683            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4684            let thread = cx.new(|cx| {
4685                AcpThread::new(
4686                    None,
4687                    None,
4688                    Some(work_dirs),
4689                    self.clone(),
4690                    project,
4691                    action_log,
4692                    acp::SessionId::new("new-session"),
4693                    watch::Receiver::constant(
4694                        acp::PromptCapabilities::new()
4695                            .image(true)
4696                            .audio(true)
4697                            .embedded_context(true),
4698                    ),
4699                    cx,
4700                )
4701            });
4702            Task::ready(Ok(thread))
4703        }
4704
4705        fn supports_load_session(&self) -> bool {
4706            true
4707        }
4708
4709        fn load_session(
4710            self: Rc<Self>,
4711            session_id: acp::SessionId,
4712            project: Entity<Project>,
4713            work_dirs: PathList,
4714            _title: Option<SharedString>,
4715            cx: &mut App,
4716        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4717            *self.captured_work_dirs.lock() = Some(work_dirs.clone());
4718            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4719            let thread = cx.new(|cx| {
4720                AcpThread::new(
4721                    None,
4722                    None,
4723                    Some(work_dirs),
4724                    self.clone(),
4725                    project,
4726                    action_log,
4727                    session_id,
4728                    watch::Receiver::constant(
4729                        acp::PromptCapabilities::new()
4730                            .image(true)
4731                            .audio(true)
4732                            .embedded_context(true),
4733                    ),
4734                    cx,
4735                )
4736            });
4737            Task::ready(Ok(thread))
4738        }
4739
4740        fn auth_methods(&self) -> &[acp::AuthMethod] {
4741            &[]
4742        }
4743
4744        fn authenticate(
4745            &self,
4746            _method_id: acp::AuthMethodId,
4747            _cx: &mut App,
4748        ) -> Task<gpui::Result<()>> {
4749            Task::ready(Ok(()))
4750        }
4751
4752        fn prompt(
4753            &self,
4754            _id: acp_thread::UserMessageId,
4755            _params: acp::PromptRequest,
4756            _cx: &mut App,
4757        ) -> Task<gpui::Result<acp::PromptResponse>> {
4758            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4759        }
4760
4761        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4762
4763        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4764            self
4765        }
4766    }
4767
4768    pub(crate) fn init_test(cx: &mut TestAppContext) {
4769        cx.update(|cx| {
4770            let settings_store = SettingsStore::test(cx);
4771            cx.set_global(settings_store);
4772            ThreadMetadataStore::init_global(cx);
4773            theme_settings::init(theme::LoadThemes::JustBase, cx);
4774            editor::init(cx);
4775            agent_panel::init(cx);
4776            release_channel::init(semver::Version::new(0, 0, 0), cx);
4777            prompt_store::init(cx)
4778        });
4779    }
4780
4781    fn active_thread(
4782        conversation_view: &Entity<ConversationView>,
4783        cx: &TestAppContext,
4784    ) -> Entity<ThreadView> {
4785        cx.read(|cx| {
4786            conversation_view
4787                .read(cx)
4788                .active_thread()
4789                .expect("No active thread")
4790                .clone()
4791        })
4792    }
4793
4794    fn message_editor(
4795        conversation_view: &Entity<ConversationView>,
4796        cx: &TestAppContext,
4797    ) -> Entity<MessageEditor> {
4798        let thread = active_thread(conversation_view, cx);
4799        cx.read(|cx| thread.read(cx).message_editor.clone())
4800    }
4801
4802    #[gpui::test]
4803    async fn test_rewind_views(cx: &mut TestAppContext) {
4804        init_test(cx);
4805
4806        let fs = FakeFs::new(cx.executor());
4807        fs.insert_tree(
4808            "/project",
4809            json!({
4810                "test1.txt": "old content 1",
4811                "test2.txt": "old content 2"
4812            }),
4813        )
4814        .await;
4815        let project = Project::test(fs, [Path::new("/project")], cx).await;
4816        let (multi_workspace, cx) =
4817            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4818        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4819
4820        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4821        let connection_store =
4822            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4823
4824        let connection = Rc::new(StubAgentConnection::new());
4825        let conversation_view = cx.update(|window, cx| {
4826            cx.new(|cx| {
4827                ConversationView::new(
4828                    Rc::new(StubAgentServer::new(connection.as_ref().clone())),
4829                    connection_store,
4830                    Agent::Custom { id: "Test".into() },
4831                    None,
4832                    None,
4833                    None,
4834                    None,
4835                    None,
4836                    workspace.downgrade(),
4837                    project.clone(),
4838                    Some(thread_store.clone()),
4839                    None,
4840                    "agent_panel",
4841                    window,
4842                    cx,
4843                )
4844            })
4845        });
4846
4847        cx.run_until_parked();
4848
4849        let thread = conversation_view
4850            .read_with(cx, |view, cx| {
4851                view.active_thread().map(|r| r.read(cx).thread.clone())
4852            })
4853            .unwrap();
4854
4855        // First user message
4856        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4857            acp::ToolCall::new("tool1", "Edit file 1")
4858                .kind(acp::ToolKind::Edit)
4859                .status(acp::ToolCallStatus::Completed)
4860                .content(vec![acp::ToolCallContent::Diff(
4861                    acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"),
4862                )]),
4863        )]);
4864
4865        thread
4866            .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
4867            .await
4868            .unwrap();
4869        cx.run_until_parked();
4870
4871        thread.read_with(cx, |thread, _cx| {
4872            assert_eq!(thread.entries().len(), 2);
4873        });
4874
4875        conversation_view.read_with(cx, |view, cx| {
4876            let entry_view_state = view
4877                .active_thread()
4878                .map(|active| active.read(cx).entry_view_state.clone())
4879                .unwrap();
4880            entry_view_state.read_with(cx, |entry_view_state, _| {
4881                assert!(
4882                    entry_view_state
4883                        .entry(0)
4884                        .unwrap()
4885                        .message_editor()
4886                        .is_some()
4887                );
4888                assert!(entry_view_state.entry(1).unwrap().has_content());
4889            });
4890        });
4891
4892        // Second user message
4893        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4894            acp::ToolCall::new("tool2", "Edit file 2")
4895                .kind(acp::ToolKind::Edit)
4896                .status(acp::ToolCallStatus::Completed)
4897                .content(vec![acp::ToolCallContent::Diff(
4898                    acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"),
4899                )]),
4900        )]);
4901
4902        thread
4903            .update(cx, |thread, cx| thread.send_raw("Another one", cx))
4904            .await
4905            .unwrap();
4906        cx.run_until_parked();
4907
4908        let second_user_message_id = thread.read_with(cx, |thread, _| {
4909            assert_eq!(thread.entries().len(), 4);
4910            let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
4911                panic!();
4912            };
4913            user_message.id.clone().unwrap()
4914        });
4915
4916        conversation_view.read_with(cx, |view, cx| {
4917            let entry_view_state = view
4918                .active_thread()
4919                .unwrap()
4920                .read(cx)
4921                .entry_view_state
4922                .clone();
4923            entry_view_state.read_with(cx, |entry_view_state, _| {
4924                assert!(
4925                    entry_view_state
4926                        .entry(0)
4927                        .unwrap()
4928                        .message_editor()
4929                        .is_some()
4930                );
4931                assert!(entry_view_state.entry(1).unwrap().has_content());
4932                assert!(
4933                    entry_view_state
4934                        .entry(2)
4935                        .unwrap()
4936                        .message_editor()
4937                        .is_some()
4938                );
4939                assert!(entry_view_state.entry(3).unwrap().has_content());
4940            });
4941        });
4942
4943        // Rewind to first message
4944        thread
4945            .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
4946            .await
4947            .unwrap();
4948
4949        cx.run_until_parked();
4950
4951        thread.read_with(cx, |thread, _| {
4952            assert_eq!(thread.entries().len(), 2);
4953        });
4954
4955        conversation_view.read_with(cx, |view, cx| {
4956            let active = view.active_thread().unwrap();
4957            active
4958                .read(cx)
4959                .entry_view_state
4960                .read_with(cx, |entry_view_state, _| {
4961                    assert!(
4962                        entry_view_state
4963                            .entry(0)
4964                            .unwrap()
4965                            .message_editor()
4966                            .is_some()
4967                    );
4968                    assert!(entry_view_state.entry(1).unwrap().has_content());
4969
4970                    // Old views should be dropped
4971                    assert!(entry_view_state.entry(2).is_none());
4972                    assert!(entry_view_state.entry(3).is_none());
4973                });
4974        });
4975    }
4976
4977    #[gpui::test]
4978    async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
4979        init_test(cx);
4980
4981        let connection = StubAgentConnection::new();
4982
4983        // Each user prompt will result in a user message entry plus an agent message entry.
4984        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4985            acp::ContentChunk::new("Response 1".into()),
4986        )]);
4987
4988        let (conversation_view, cx) =
4989            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4990
4991        let thread = conversation_view
4992            .read_with(cx, |view, cx| {
4993                view.active_thread().map(|r| r.read(cx).thread.clone())
4994            })
4995            .unwrap();
4996
4997        thread
4998            .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
4999            .await
5000            .unwrap();
5001        cx.run_until_parked();
5002
5003        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5004            acp::ContentChunk::new("Response 2".into()),
5005        )]);
5006
5007        thread
5008            .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
5009            .await
5010            .unwrap();
5011        cx.run_until_parked();
5012
5013        // Move somewhere else first so we're not trivially already on the last user prompt.
5014        active_thread(&conversation_view, cx).update(cx, |view, cx| {
5015            view.scroll_to_top(cx);
5016        });
5017        cx.run_until_parked();
5018
5019        active_thread(&conversation_view, cx).update(cx, |view, cx| {
5020            view.scroll_to_most_recent_user_prompt(cx);
5021            let scroll_top = view.list_state.logical_scroll_top();
5022            // Entries layout is: [User1, Assistant1, User2, Assistant2]
5023            assert_eq!(scroll_top.item_ix, 2);
5024        });
5025    }
5026
5027    #[gpui::test]
5028    async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
5029        cx: &mut TestAppContext,
5030    ) {
5031        init_test(cx);
5032
5033        let (conversation_view, cx) =
5034            setup_conversation_view(StubAgentServer::default_response(), cx).await;
5035
5036        // With no entries, scrolling should be a no-op and must not panic.
5037        active_thread(&conversation_view, cx).update(cx, |view, cx| {
5038            view.scroll_to_most_recent_user_prompt(cx);
5039            let scroll_top = view.list_state.logical_scroll_top();
5040            assert_eq!(scroll_top.item_ix, 0);
5041        });
5042    }
5043
5044    #[gpui::test]
5045    async fn test_message_editing_cancel(cx: &mut TestAppContext) {
5046        init_test(cx);
5047
5048        let connection = StubAgentConnection::new();
5049
5050        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5051            acp::ContentChunk::new("Response".into()),
5052        )]);
5053
5054        let (conversation_view, cx) =
5055            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5056        add_to_workspace(conversation_view.clone(), cx);
5057
5058        let message_editor = message_editor(&conversation_view, cx);
5059        message_editor.update_in(cx, |editor, window, cx| {
5060            editor.set_text("Original message to edit", window, cx);
5061        });
5062        active_thread(&conversation_view, cx)
5063            .update_in(cx, |view, window, cx| view.send(window, cx));
5064
5065        cx.run_until_parked();
5066
5067        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
5068            assert_eq!(
5069                view.active_thread()
5070                    .and_then(|active| active.read(cx).editing_message),
5071                None
5072            );
5073
5074            view.active_thread()
5075                .map(|active| &active.read(cx).entry_view_state)
5076                .as_ref()
5077                .unwrap()
5078                .read(cx)
5079                .entry(0)
5080                .unwrap()
5081                .message_editor()
5082                .unwrap()
5083                .clone()
5084        });
5085
5086        // Focus
5087        cx.focus(&user_message_editor);
5088        conversation_view.read_with(cx, |view, cx| {
5089            assert_eq!(
5090                view.active_thread()
5091                    .and_then(|active| active.read(cx).editing_message),
5092                Some(0)
5093            );
5094        });
5095
5096        // Edit
5097        user_message_editor.update_in(cx, |editor, window, cx| {
5098            editor.set_text("Edited message content", window, cx);
5099        });
5100
5101        // Cancel
5102        user_message_editor.update_in(cx, |_editor, window, cx| {
5103            window.dispatch_action(Box::new(editor::actions::Cancel), cx);
5104        });
5105
5106        conversation_view.read_with(cx, |view, cx| {
5107            assert_eq!(
5108                view.active_thread()
5109                    .and_then(|active| active.read(cx).editing_message),
5110                None
5111            );
5112        });
5113
5114        user_message_editor.read_with(cx, |editor, cx| {
5115            assert_eq!(editor.text(cx), "Original message to edit");
5116        });
5117    }
5118
5119    #[gpui::test]
5120    async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
5121        init_test(cx);
5122
5123        let connection = StubAgentConnection::new();
5124
5125        let (conversation_view, cx) =
5126            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5127        add_to_workspace(conversation_view.clone(), cx);
5128
5129        let message_editor = message_editor(&conversation_view, cx);
5130        message_editor.update_in(cx, |editor, window, cx| {
5131            editor.set_text("", window, cx);
5132        });
5133
5134        let thread = cx.read(|cx| {
5135            conversation_view
5136                .read(cx)
5137                .active_thread()
5138                .unwrap()
5139                .read(cx)
5140                .thread
5141                .clone()
5142        });
5143        let entries_before = cx.read(|cx| thread.read(cx).entries().len());
5144
5145        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
5146            view.send(window, cx);
5147        });
5148        cx.run_until_parked();
5149
5150        let entries_after = cx.read(|cx| thread.read(cx).entries().len());
5151        assert_eq!(
5152            entries_before, entries_after,
5153            "No message should be sent when editor is empty"
5154        );
5155    }
5156
5157    #[gpui::test]
5158    async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
5159        init_test(cx);
5160
5161        let connection = StubAgentConnection::new();
5162
5163        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5164            acp::ContentChunk::new("Response".into()),
5165        )]);
5166
5167        let (conversation_view, cx) =
5168            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5169        add_to_workspace(conversation_view.clone(), cx);
5170
5171        let message_editor = message_editor(&conversation_view, cx);
5172        message_editor.update_in(cx, |editor, window, cx| {
5173            editor.set_text("Original message to edit", window, cx);
5174        });
5175        active_thread(&conversation_view, cx)
5176            .update_in(cx, |view, window, cx| view.send(window, cx));
5177
5178        cx.run_until_parked();
5179
5180        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
5181            assert_eq!(
5182                view.active_thread()
5183                    .and_then(|active| active.read(cx).editing_message),
5184                None
5185            );
5186            assert_eq!(
5187                view.active_thread()
5188                    .unwrap()
5189                    .read(cx)
5190                    .thread
5191                    .read(cx)
5192                    .entries()
5193                    .len(),
5194                2
5195            );
5196
5197            view.active_thread()
5198                .map(|active| &active.read(cx).entry_view_state)
5199                .as_ref()
5200                .unwrap()
5201                .read(cx)
5202                .entry(0)
5203                .unwrap()
5204                .message_editor()
5205                .unwrap()
5206                .clone()
5207        });
5208
5209        // Focus
5210        cx.focus(&user_message_editor);
5211
5212        // Edit
5213        user_message_editor.update_in(cx, |editor, window, cx| {
5214            editor.set_text("Edited message content", window, cx);
5215        });
5216
5217        // Send
5218        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5219            acp::ContentChunk::new("New Response".into()),
5220        )]);
5221
5222        user_message_editor.update_in(cx, |_editor, window, cx| {
5223            window.dispatch_action(Box::new(Chat), cx);
5224        });
5225
5226        cx.run_until_parked();
5227
5228        conversation_view.read_with(cx, |view, cx| {
5229            assert_eq!(
5230                view.active_thread()
5231                    .and_then(|active| active.read(cx).editing_message),
5232                None
5233            );
5234
5235            let entries = view
5236                .active_thread()
5237                .unwrap()
5238                .read(cx)
5239                .thread
5240                .read(cx)
5241                .entries();
5242            assert_eq!(entries.len(), 2);
5243            assert_eq!(
5244                entries[0].to_markdown(cx),
5245                "## User\n\nEdited message content\n\n"
5246            );
5247            assert_eq!(
5248                entries[1].to_markdown(cx),
5249                "## Assistant\n\nNew Response\n\n"
5250            );
5251
5252            let entry_view_state = view
5253                .active_thread()
5254                .map(|active| &active.read(cx).entry_view_state)
5255                .unwrap();
5256            let new_editor = entry_view_state.read_with(cx, |state, _cx| {
5257                assert!(!state.entry(1).unwrap().has_content());
5258                state.entry(0).unwrap().message_editor().unwrap().clone()
5259            });
5260
5261            assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
5262        })
5263    }
5264
5265    #[gpui::test]
5266    async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
5267        init_test(cx);
5268
5269        let connection = StubAgentConnection::new();
5270
5271        let (conversation_view, cx) =
5272            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5273        add_to_workspace(conversation_view.clone(), cx);
5274
5275        let message_editor = message_editor(&conversation_view, cx);
5276        message_editor.update_in(cx, |editor, window, cx| {
5277            editor.set_text("Original message to edit", window, cx);
5278        });
5279        active_thread(&conversation_view, cx)
5280            .update_in(cx, |view, window, cx| view.send(window, cx));
5281
5282        cx.run_until_parked();
5283
5284        let (user_message_editor, session_id) = conversation_view.read_with(cx, |view, cx| {
5285            let thread = view.active_thread().unwrap().read(cx).thread.read(cx);
5286            assert_eq!(thread.entries().len(), 1);
5287
5288            let editor = view
5289                .active_thread()
5290                .map(|active| &active.read(cx).entry_view_state)
5291                .as_ref()
5292                .unwrap()
5293                .read(cx)
5294                .entry(0)
5295                .unwrap()
5296                .message_editor()
5297                .unwrap()
5298                .clone();
5299
5300            (editor, thread.session_id().clone())
5301        });
5302
5303        // Focus
5304        cx.focus(&user_message_editor);
5305
5306        conversation_view.read_with(cx, |view, cx| {
5307            assert_eq!(
5308                view.active_thread()
5309                    .and_then(|active| active.read(cx).editing_message),
5310                Some(0)
5311            );
5312        });
5313
5314        // Edit
5315        user_message_editor.update_in(cx, |editor, window, cx| {
5316            editor.set_text("Edited message content", window, cx);
5317        });
5318
5319        conversation_view.read_with(cx, |view, cx| {
5320            assert_eq!(
5321                view.active_thread()
5322                    .and_then(|active| active.read(cx).editing_message),
5323                Some(0)
5324            );
5325        });
5326
5327        // Finish streaming response
5328        cx.update(|_, cx| {
5329            connection.send_update(
5330                session_id.clone(),
5331                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())),
5332                cx,
5333            );
5334            connection.end_turn(session_id, acp::StopReason::EndTurn);
5335        });
5336
5337        conversation_view.read_with(cx, |view, cx| {
5338            assert_eq!(
5339                view.active_thread()
5340                    .and_then(|active| active.read(cx).editing_message),
5341                Some(0)
5342            );
5343        });
5344
5345        cx.run_until_parked();
5346
5347        // Should still be editing
5348        cx.update(|window, cx| {
5349            assert!(user_message_editor.focus_handle(cx).is_focused(window));
5350            assert_eq!(
5351                conversation_view
5352                    .read(cx)
5353                    .active_thread()
5354                    .and_then(|active| active.read(cx).editing_message),
5355                Some(0)
5356            );
5357            assert_eq!(
5358                user_message_editor.read(cx).text(cx),
5359                "Edited message content"
5360            );
5361        });
5362    }
5363
5364    #[gpui::test]
5365    async fn test_stale_stop_does_not_disable_follow_tail_during_regenerate(
5366        cx: &mut TestAppContext,
5367    ) {
5368        init_test(cx);
5369
5370        let connection = StubAgentConnection::new();
5371
5372        let (conversation_view, cx) =
5373            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5374        add_to_workspace(conversation_view.clone(), cx);
5375
5376        let message_editor = message_editor(&conversation_view, cx);
5377        message_editor.update_in(cx, |editor, window, cx| {
5378            editor.set_text("Original message to edit", window, cx);
5379        });
5380        active_thread(&conversation_view, cx)
5381            .update_in(cx, |view, window, cx| view.send(window, cx));
5382
5383        cx.run_until_parked();
5384
5385        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
5386            view.active_thread()
5387                .map(|active| &active.read(cx).entry_view_state)
5388                .as_ref()
5389                .unwrap()
5390                .read(cx)
5391                .entry(0)
5392                .unwrap()
5393                .message_editor()
5394                .unwrap()
5395                .clone()
5396        });
5397
5398        cx.focus(&user_message_editor);
5399        user_message_editor.update_in(cx, |editor, window, cx| {
5400            editor.set_text("Edited message content", window, cx);
5401        });
5402
5403        user_message_editor.update_in(cx, |_editor, window, cx| {
5404            window.dispatch_action(Box::new(Chat), cx);
5405        });
5406
5407        cx.run_until_parked();
5408
5409        conversation_view.read_with(cx, |view, cx| {
5410            let active = view.active_thread().unwrap();
5411            let active = active.read(cx);
5412
5413            assert_eq!(active.thread.read(cx).status(), ThreadStatus::Generating);
5414            assert!(
5415                active.list_state.is_following_tail(),
5416                "stale stop events from the cancelled turn must not disable follow-tail for the new turn"
5417            );
5418        });
5419    }
5420
5421    struct GeneratingThreadSetup {
5422        conversation_view: Entity<ConversationView>,
5423        thread: Entity<AcpThread>,
5424        message_editor: Entity<MessageEditor>,
5425    }
5426
5427    async fn setup_generating_thread(
5428        cx: &mut TestAppContext,
5429    ) -> (GeneratingThreadSetup, &mut VisualTestContext) {
5430        let connection = StubAgentConnection::new();
5431
5432        let (conversation_view, cx) =
5433            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5434        add_to_workspace(conversation_view.clone(), cx);
5435
5436        let message_editor = message_editor(&conversation_view, cx);
5437        message_editor.update_in(cx, |editor, window, cx| {
5438            editor.set_text("Hello", window, cx);
5439        });
5440        active_thread(&conversation_view, cx)
5441            .update_in(cx, |view, window, cx| view.send(window, cx));
5442
5443        let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
5444            let thread = view
5445                .active_thread()
5446                .as_ref()
5447                .unwrap()
5448                .read(cx)
5449                .thread
5450                .clone();
5451            (thread.clone(), thread.read(cx).session_id().clone())
5452        });
5453
5454        cx.run_until_parked();
5455
5456        cx.update(|_, cx| {
5457            connection.send_update(
5458                session_id.clone(),
5459                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5460                    "Response chunk".into(),
5461                )),
5462                cx,
5463            );
5464        });
5465
5466        cx.run_until_parked();
5467
5468        thread.read_with(cx, |thread, _cx| {
5469            assert_eq!(thread.status(), ThreadStatus::Generating);
5470        });
5471
5472        (
5473            GeneratingThreadSetup {
5474                conversation_view,
5475                thread,
5476                message_editor,
5477            },
5478            cx,
5479        )
5480    }
5481
5482    #[gpui::test]
5483    async fn test_escape_cancels_generation_from_conversation_focus(cx: &mut TestAppContext) {
5484        init_test(cx);
5485
5486        let (setup, cx) = setup_generating_thread(cx).await;
5487
5488        let focus_handle = setup
5489            .conversation_view
5490            .read_with(cx, |view, cx| view.focus_handle(cx));
5491        cx.update(|window, cx| {
5492            window.focus(&focus_handle, cx);
5493        });
5494
5495        setup.conversation_view.update_in(cx, |_, window, cx| {
5496            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
5497        });
5498
5499        cx.run_until_parked();
5500
5501        setup.thread.read_with(cx, |thread, _cx| {
5502            assert_eq!(thread.status(), ThreadStatus::Idle);
5503        });
5504    }
5505
5506    #[gpui::test]
5507    async fn test_escape_cancels_generation_from_editor_focus(cx: &mut TestAppContext) {
5508        init_test(cx);
5509
5510        let (setup, cx) = setup_generating_thread(cx).await;
5511
5512        let editor_focus_handle = setup
5513            .message_editor
5514            .read_with(cx, |editor, cx| editor.focus_handle(cx));
5515        cx.update(|window, cx| {
5516            window.focus(&editor_focus_handle, cx);
5517        });
5518
5519        setup.message_editor.update_in(cx, |_, window, cx| {
5520            window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx);
5521        });
5522
5523        cx.run_until_parked();
5524
5525        setup.thread.read_with(cx, |thread, _cx| {
5526            assert_eq!(thread.status(), ThreadStatus::Idle);
5527        });
5528    }
5529
5530    #[gpui::test]
5531    async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) {
5532        init_test(cx);
5533
5534        let (conversation_view, cx) =
5535            setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
5536        add_to_workspace(conversation_view.clone(), cx);
5537
5538        let thread = conversation_view.read_with(cx, |view, cx| {
5539            view.active_thread().unwrap().read(cx).thread.clone()
5540        });
5541
5542        thread.read_with(cx, |thread, _cx| {
5543            assert_eq!(thread.status(), ThreadStatus::Idle);
5544        });
5545
5546        let focus_handle = conversation_view.read_with(cx, |view, _cx| view.focus_handle.clone());
5547        cx.update(|window, cx| {
5548            window.focus(&focus_handle, cx);
5549        });
5550
5551        conversation_view.update_in(cx, |_, window, cx| {
5552            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
5553        });
5554
5555        cx.run_until_parked();
5556
5557        thread.read_with(cx, |thread, _cx| {
5558            assert_eq!(thread.status(), ThreadStatus::Idle);
5559        });
5560    }
5561
5562    #[gpui::test]
5563    async fn test_interrupt(cx: &mut TestAppContext) {
5564        init_test(cx);
5565
5566        let connection = StubAgentConnection::new();
5567
5568        let (conversation_view, cx) =
5569            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5570        add_to_workspace(conversation_view.clone(), cx);
5571
5572        let message_editor = message_editor(&conversation_view, cx);
5573        message_editor.update_in(cx, |editor, window, cx| {
5574            editor.set_text("Message 1", window, cx);
5575        });
5576        active_thread(&conversation_view, cx)
5577            .update_in(cx, |view, window, cx| view.send(window, cx));
5578
5579        let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
5580            let thread = view.active_thread().unwrap().read(cx).thread.clone();
5581
5582            (thread.clone(), thread.read(cx).session_id().clone())
5583        });
5584
5585        cx.run_until_parked();
5586
5587        cx.update(|_, cx| {
5588            connection.send_update(
5589                session_id.clone(),
5590                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5591                    "Message 1 resp".into(),
5592                )),
5593                cx,
5594            );
5595        });
5596
5597        cx.run_until_parked();
5598
5599        thread.read_with(cx, |thread, cx| {
5600            assert_eq!(
5601                thread.to_markdown(cx),
5602                indoc::indoc! {"
5603                        ## User
5604
5605                        Message 1
5606
5607                        ## Assistant
5608
5609                        Message 1 resp
5610
5611                    "}
5612            )
5613        });
5614
5615        message_editor.update_in(cx, |editor, window, cx| {
5616            editor.set_text("Message 2", window, cx);
5617        });
5618        active_thread(&conversation_view, cx)
5619            .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx));
5620
5621        cx.update(|_, cx| {
5622            // Simulate a response sent after beginning to cancel
5623            connection.send_update(
5624                session_id.clone(),
5625                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())),
5626                cx,
5627            );
5628        });
5629
5630        cx.run_until_parked();
5631
5632        // Last Message 1 response should appear before Message 2
5633        thread.read_with(cx, |thread, cx| {
5634            assert_eq!(
5635                thread.to_markdown(cx),
5636                indoc::indoc! {"
5637                        ## User
5638
5639                        Message 1
5640
5641                        ## Assistant
5642
5643                        Message 1 response
5644
5645                        ## User
5646
5647                        Message 2
5648
5649                    "}
5650            )
5651        });
5652
5653        cx.update(|_, cx| {
5654            connection.send_update(
5655                session_id.clone(),
5656                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5657                    "Message 2 response".into(),
5658                )),
5659                cx,
5660            );
5661            connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5662        });
5663
5664        cx.run_until_parked();
5665
5666        thread.read_with(cx, |thread, cx| {
5667            assert_eq!(
5668                thread.to_markdown(cx),
5669                indoc::indoc! {"
5670                        ## User
5671
5672                        Message 1
5673
5674                        ## Assistant
5675
5676                        Message 1 response
5677
5678                        ## User
5679
5680                        Message 2
5681
5682                        ## Assistant
5683
5684                        Message 2 response
5685
5686                    "}
5687            )
5688        });
5689    }
5690
5691    #[gpui::test]
5692    async fn test_message_editing_insert_selections(cx: &mut TestAppContext) {
5693        init_test(cx);
5694
5695        let connection = StubAgentConnection::new();
5696        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5697            acp::ContentChunk::new("Response".into()),
5698        )]);
5699
5700        let (conversation_view, cx) =
5701            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5702        add_to_workspace(conversation_view.clone(), cx);
5703
5704        let message_editor = message_editor(&conversation_view, cx);
5705        message_editor.update_in(cx, |editor, window, cx| {
5706            editor.set_text("Original message to edit", window, cx)
5707        });
5708        active_thread(&conversation_view, cx)
5709            .update_in(cx, |view, window, cx| view.send(window, cx));
5710        cx.run_until_parked();
5711
5712        let user_message_editor = conversation_view.read_with(cx, |conversation_view, cx| {
5713            conversation_view
5714                .active_thread()
5715                .map(|active| &active.read(cx).entry_view_state)
5716                .as_ref()
5717                .unwrap()
5718                .read(cx)
5719                .entry(0)
5720                .expect("Should have at least one entry")
5721                .message_editor()
5722                .expect("Should have message editor")
5723                .clone()
5724        });
5725
5726        cx.focus(&user_message_editor);
5727        conversation_view.read_with(cx, |view, cx| {
5728            assert_eq!(
5729                view.active_thread()
5730                    .and_then(|active| active.read(cx).editing_message),
5731                Some(0)
5732            );
5733        });
5734
5735        // Ensure to edit the focused message before proceeding otherwise, since
5736        // its content is not different from what was sent, focus will be lost.
5737        user_message_editor.update_in(cx, |editor, window, cx| {
5738            editor.set_text("Original message to edit with ", window, cx)
5739        });
5740
5741        // Create a simple buffer with some text so we can create a selection
5742        // that will then be added to the message being edited.
5743        let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
5744            (
5745                conversation_view.workspace.clone(),
5746                conversation_view.project.clone(),
5747            )
5748        });
5749        let buffer = project.update(cx, |project, cx| {
5750            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5751        });
5752
5753        workspace
5754            .update_in(cx, |workspace, window, cx| {
5755                let editor = cx.new(|cx| {
5756                    let mut editor =
5757                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5758
5759                    editor.change_selections(Default::default(), window, cx, |selections| {
5760                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5761                    });
5762
5763                    editor
5764                });
5765                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5766            })
5767            .unwrap();
5768
5769        conversation_view.update_in(cx, |view, window, cx| {
5770            assert_eq!(
5771                view.active_thread()
5772                    .and_then(|active| active.read(cx).editing_message),
5773                Some(0)
5774            );
5775            view.insert_selections(window, cx);
5776        });
5777
5778        user_message_editor.read_with(cx, |editor, cx| {
5779            let text = editor.editor().read(cx).text(cx);
5780            let expected_text = String::from("Original message to edit with selection ");
5781
5782            assert_eq!(text, expected_text);
5783        });
5784    }
5785
5786    #[gpui::test]
5787    async fn test_insert_selections(cx: &mut TestAppContext) {
5788        init_test(cx);
5789
5790        let connection = StubAgentConnection::new();
5791        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5792            acp::ContentChunk::new("Response".into()),
5793        )]);
5794
5795        let (conversation_view, cx) =
5796            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5797        add_to_workspace(conversation_view.clone(), cx);
5798
5799        let message_editor = message_editor(&conversation_view, cx);
5800        message_editor.update_in(cx, |editor, window, cx| {
5801            editor.set_text("Can you review this snippet ", window, cx)
5802        });
5803
5804        // Create a simple buffer with some text so we can create a selection
5805        // that will then be added to the message being edited.
5806        let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
5807            (
5808                conversation_view.workspace.clone(),
5809                conversation_view.project.clone(),
5810            )
5811        });
5812        let buffer = project.update(cx, |project, cx| {
5813            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5814        });
5815
5816        workspace
5817            .update_in(cx, |workspace, window, cx| {
5818                let editor = cx.new(|cx| {
5819                    let mut editor =
5820                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5821
5822                    editor.change_selections(Default::default(), window, cx, |selections| {
5823                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5824                    });
5825
5826                    editor
5827                });
5828                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5829            })
5830            .unwrap();
5831
5832        conversation_view.update_in(cx, |view, window, cx| {
5833            assert_eq!(
5834                view.active_thread()
5835                    .and_then(|active| active.read(cx).editing_message),
5836                None
5837            );
5838            view.insert_selections(window, cx);
5839        });
5840
5841        message_editor.read_with(cx, |editor, cx| {
5842            let text = editor.text(cx);
5843            let expected_txt = String::from("Can you review this snippet selection ");
5844
5845            assert_eq!(text, expected_txt);
5846        })
5847    }
5848
5849    #[gpui::test]
5850    async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
5851        init_test(cx);
5852
5853        let tool_call_id = acp::ToolCallId::new("terminal-1");
5854        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`")
5855            .kind(acp::ToolKind::Edit);
5856
5857        let permission_options = ToolPermissionContext::new(
5858            TerminalTool::NAME,
5859            vec!["cargo build --release".to_string()],
5860        )
5861        .build_permission_options();
5862
5863        let connection =
5864            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5865                tool_call_id.clone(),
5866                permission_options,
5867            )]));
5868
5869        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5870
5871        let (conversation_view, cx) =
5872            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5873
5874        // Disable notifications to avoid popup windows
5875        cx.update(|_window, cx| {
5876            AgentSettings::override_global(
5877                AgentSettings {
5878                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5879                    ..AgentSettings::get_global(cx).clone()
5880                },
5881                cx,
5882            );
5883        });
5884
5885        let message_editor = message_editor(&conversation_view, cx);
5886        message_editor.update_in(cx, |editor, window, cx| {
5887            editor.set_text("Run cargo build", window, cx);
5888        });
5889
5890        active_thread(&conversation_view, cx)
5891            .update_in(cx, |view, window, cx| view.send(window, cx));
5892
5893        cx.run_until_parked();
5894
5895        // Verify the tool call is in WaitingForConfirmation state with the expected options
5896        conversation_view.read_with(cx, |conversation_view, cx| {
5897            let thread = conversation_view
5898                .active_thread()
5899                .expect("Thread should exist")
5900                .read(cx)
5901                .thread
5902                .clone();
5903            let thread = thread.read(cx);
5904
5905            let tool_call = thread.entries().iter().find_map(|entry| {
5906                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5907                    Some(call)
5908                } else {
5909                    None
5910                }
5911            });
5912
5913            assert!(tool_call.is_some(), "Expected a tool call entry");
5914            let tool_call = tool_call.unwrap();
5915
5916            // Verify it's waiting for confirmation
5917            assert!(
5918                matches!(
5919                    tool_call.status,
5920                    acp_thread::ToolCallStatus::WaitingForConfirmation { .. }
5921                ),
5922                "Expected WaitingForConfirmation status, got {:?}",
5923                tool_call.status
5924            );
5925
5926            // Verify the options count (granularity options only, no separate Deny option)
5927            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5928                &tool_call.status
5929            {
5930                let PermissionOptions::Dropdown(choices) = options else {
5931                    panic!("Expected dropdown permission options");
5932                };
5933
5934                assert_eq!(
5935                    choices.len(),
5936                    3,
5937                    "Expected 3 permission options (granularity only)"
5938                );
5939
5940                // Verify specific button labels (now using neutral names)
5941                let labels: Vec<&str> = choices
5942                    .iter()
5943                    .map(|choice| choice.allow.name.as_ref())
5944                    .collect();
5945                assert!(
5946                    labels.contains(&"Always for terminal"),
5947                    "Missing 'Always for terminal' option"
5948                );
5949                assert!(
5950                    labels.contains(&"Always for `cargo build` commands"),
5951                    "Missing pattern option"
5952                );
5953                assert!(
5954                    labels.contains(&"Only this time"),
5955                    "Missing 'Only this time' option"
5956                );
5957            }
5958        });
5959    }
5960
5961    #[gpui::test]
5962    async fn test_tool_permission_buttons_edit_file_with_path_pattern(cx: &mut TestAppContext) {
5963        init_test(cx);
5964
5965        let tool_call_id = acp::ToolCallId::new("edit-file-1");
5966        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`")
5967            .kind(acp::ToolKind::Edit);
5968
5969        let permission_options =
5970            ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()])
5971                .build_permission_options();
5972
5973        let connection =
5974            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5975                tool_call_id.clone(),
5976                permission_options,
5977            )]));
5978
5979        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5980
5981        let (conversation_view, cx) =
5982            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5983
5984        // Disable notifications
5985        cx.update(|_window, cx| {
5986            AgentSettings::override_global(
5987                AgentSettings {
5988                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5989                    ..AgentSettings::get_global(cx).clone()
5990                },
5991                cx,
5992            );
5993        });
5994
5995        let message_editor = message_editor(&conversation_view, cx);
5996        message_editor.update_in(cx, |editor, window, cx| {
5997            editor.set_text("Edit the main file", window, cx);
5998        });
5999
6000        active_thread(&conversation_view, cx)
6001            .update_in(cx, |view, window, cx| view.send(window, cx));
6002
6003        cx.run_until_parked();
6004
6005        // Verify the options
6006        conversation_view.read_with(cx, |conversation_view, cx| {
6007            let thread = conversation_view
6008                .active_thread()
6009                .expect("Thread should exist")
6010                .read(cx)
6011                .thread
6012                .clone();
6013            let thread = thread.read(cx);
6014
6015            let tool_call = thread.entries().iter().find_map(|entry| {
6016                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
6017                    Some(call)
6018                } else {
6019                    None
6020                }
6021            });
6022
6023            assert!(tool_call.is_some(), "Expected a tool call entry");
6024            let tool_call = tool_call.unwrap();
6025
6026            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
6027                &tool_call.status
6028            {
6029                let PermissionOptions::Dropdown(choices) = options else {
6030                    panic!("Expected dropdown permission options");
6031                };
6032
6033                let labels: Vec<&str> = choices
6034                    .iter()
6035                    .map(|choice| choice.allow.name.as_ref())
6036                    .collect();
6037                assert!(
6038                    labels.contains(&"Always for edit file"),
6039                    "Missing 'Always for edit file' option"
6040                );
6041                assert!(
6042                    labels.contains(&"Always for `src/`"),
6043                    "Missing path pattern option"
6044                );
6045            } else {
6046                panic!("Expected WaitingForConfirmation status");
6047            }
6048        });
6049    }
6050
6051    #[gpui::test]
6052    async fn test_tool_permission_buttons_fetch_with_domain_pattern(cx: &mut TestAppContext) {
6053        init_test(cx);
6054
6055        let tool_call_id = acp::ToolCallId::new("fetch-1");
6056        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Fetch `https://docs.rs/gpui`")
6057            .kind(acp::ToolKind::Fetch);
6058
6059        let permission_options =
6060            ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()])
6061                .build_permission_options();
6062
6063        let connection =
6064            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6065                tool_call_id.clone(),
6066                permission_options,
6067            )]));
6068
6069        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6070
6071        let (conversation_view, cx) =
6072            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6073
6074        // Disable notifications
6075        cx.update(|_window, cx| {
6076            AgentSettings::override_global(
6077                AgentSettings {
6078                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6079                    ..AgentSettings::get_global(cx).clone()
6080                },
6081                cx,
6082            );
6083        });
6084
6085        let message_editor = message_editor(&conversation_view, cx);
6086        message_editor.update_in(cx, |editor, window, cx| {
6087            editor.set_text("Fetch the docs", window, cx);
6088        });
6089
6090        active_thread(&conversation_view, cx)
6091            .update_in(cx, |view, window, cx| view.send(window, cx));
6092
6093        cx.run_until_parked();
6094
6095        // Verify the options
6096        conversation_view.read_with(cx, |conversation_view, cx| {
6097            let thread = conversation_view
6098                .active_thread()
6099                .expect("Thread should exist")
6100                .read(cx)
6101                .thread
6102                .clone();
6103            let thread = thread.read(cx);
6104
6105            let tool_call = thread.entries().iter().find_map(|entry| {
6106                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
6107                    Some(call)
6108                } else {
6109                    None
6110                }
6111            });
6112
6113            assert!(tool_call.is_some(), "Expected a tool call entry");
6114            let tool_call = tool_call.unwrap();
6115
6116            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
6117                &tool_call.status
6118            {
6119                let PermissionOptions::Dropdown(choices) = options else {
6120                    panic!("Expected dropdown permission options");
6121                };
6122
6123                let labels: Vec<&str> = choices
6124                    .iter()
6125                    .map(|choice| choice.allow.name.as_ref())
6126                    .collect();
6127                assert!(
6128                    labels.contains(&"Always for fetch"),
6129                    "Missing 'Always for fetch' option"
6130                );
6131                assert!(
6132                    labels.contains(&"Always for `docs.rs`"),
6133                    "Missing domain pattern option"
6134                );
6135            } else {
6136                panic!("Expected WaitingForConfirmation status");
6137            }
6138        });
6139    }
6140
6141    #[gpui::test]
6142    async fn test_tool_permission_buttons_without_pattern(cx: &mut TestAppContext) {
6143        init_test(cx);
6144
6145        let tool_call_id = acp::ToolCallId::new("terminal-no-pattern-1");
6146        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `./deploy.sh --production`")
6147            .kind(acp::ToolKind::Edit);
6148
6149        // No pattern button since ./deploy.sh doesn't match the alphanumeric pattern
6150        let permission_options = ToolPermissionContext::new(
6151            TerminalTool::NAME,
6152            vec!["./deploy.sh --production".to_string()],
6153        )
6154        .build_permission_options();
6155
6156        let connection =
6157            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6158                tool_call_id.clone(),
6159                permission_options,
6160            )]));
6161
6162        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6163
6164        let (conversation_view, cx) =
6165            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6166
6167        // Disable notifications
6168        cx.update(|_window, cx| {
6169            AgentSettings::override_global(
6170                AgentSettings {
6171                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6172                    ..AgentSettings::get_global(cx).clone()
6173                },
6174                cx,
6175            );
6176        });
6177
6178        let message_editor = message_editor(&conversation_view, cx);
6179        message_editor.update_in(cx, |editor, window, cx| {
6180            editor.set_text("Run the deploy script", window, cx);
6181        });
6182
6183        active_thread(&conversation_view, cx)
6184            .update_in(cx, |view, window, cx| view.send(window, cx));
6185
6186        cx.run_until_parked();
6187
6188        // Verify only 2 options (no pattern button when command doesn't match pattern)
6189        conversation_view.read_with(cx, |conversation_view, cx| {
6190            let thread = conversation_view
6191                .active_thread()
6192                .expect("Thread should exist")
6193                .read(cx)
6194                .thread
6195                .clone();
6196            let thread = thread.read(cx);
6197
6198            let tool_call = thread.entries().iter().find_map(|entry| {
6199                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
6200                    Some(call)
6201                } else {
6202                    None
6203                }
6204            });
6205
6206            assert!(tool_call.is_some(), "Expected a tool call entry");
6207            let tool_call = tool_call.unwrap();
6208
6209            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
6210                &tool_call.status
6211            {
6212                let PermissionOptions::Dropdown(choices) = options else {
6213                    panic!("Expected dropdown permission options");
6214                };
6215
6216                assert_eq!(
6217                    choices.len(),
6218                    2,
6219                    "Expected 2 permission options (no pattern option)"
6220                );
6221
6222                let labels: Vec<&str> = choices
6223                    .iter()
6224                    .map(|choice| choice.allow.name.as_ref())
6225                    .collect();
6226                assert!(
6227                    labels.contains(&"Always for terminal"),
6228                    "Missing 'Always for terminal' option"
6229                );
6230                assert!(
6231                    labels.contains(&"Only this time"),
6232                    "Missing 'Only this time' option"
6233                );
6234                // Should NOT contain a pattern option
6235                assert!(
6236                    !labels.iter().any(|l| l.contains("commands")),
6237                    "Should not have pattern option"
6238                );
6239            } else {
6240                panic!("Expected WaitingForConfirmation status");
6241            }
6242        });
6243    }
6244
6245    #[gpui::test]
6246    async fn test_authorize_tool_call_action_triggers_authorization(cx: &mut TestAppContext) {
6247        init_test(cx);
6248
6249        let tool_call_id = acp::ToolCallId::new("action-test-1");
6250        let tool_call =
6251            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit);
6252
6253        let permission_options =
6254            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo test".to_string()])
6255                .build_permission_options();
6256
6257        let connection =
6258            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6259                tool_call_id.clone(),
6260                permission_options,
6261            )]));
6262
6263        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6264
6265        let (conversation_view, cx) =
6266            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6267        add_to_workspace(conversation_view.clone(), cx);
6268
6269        cx.update(|_window, cx| {
6270            AgentSettings::override_global(
6271                AgentSettings {
6272                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6273                    ..AgentSettings::get_global(cx).clone()
6274                },
6275                cx,
6276            );
6277        });
6278
6279        let message_editor = message_editor(&conversation_view, cx);
6280        message_editor.update_in(cx, |editor, window, cx| {
6281            editor.set_text("Run tests", window, cx);
6282        });
6283
6284        active_thread(&conversation_view, cx)
6285            .update_in(cx, |view, window, cx| view.send(window, cx));
6286
6287        cx.run_until_parked();
6288
6289        // Verify tool call is waiting for confirmation
6290        conversation_view.read_with(cx, |conversation_view, cx| {
6291            let tool_call = conversation_view.pending_tool_call(cx);
6292            assert!(
6293                tool_call.is_some(),
6294                "Expected a tool call waiting for confirmation"
6295            );
6296        });
6297
6298        // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection)
6299        conversation_view.update_in(cx, |_, window, cx| {
6300            window.dispatch_action(
6301                crate::AuthorizeToolCall {
6302                    tool_call_id: "action-test-1".to_string(),
6303                    option_id: "allow".to_string(),
6304                    option_kind: "AllowOnce".to_string(),
6305                }
6306                .boxed_clone(),
6307                cx,
6308            );
6309        });
6310
6311        cx.run_until_parked();
6312
6313        // Verify tool call is no longer waiting for confirmation (was authorized)
6314        conversation_view.read_with(cx, |conversation_view, cx| {
6315            let tool_call = conversation_view.pending_tool_call(cx);
6316            assert!(
6317                tool_call.is_none(),
6318                "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action"
6319            );
6320        });
6321    }
6322
6323    #[gpui::test]
6324    async fn test_authorize_tool_call_action_with_pattern_option(cx: &mut TestAppContext) {
6325        init_test(cx);
6326
6327        let tool_call_id = acp::ToolCallId::new("pattern-action-test-1");
6328        let tool_call =
6329            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
6330
6331        let permission_options =
6332            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
6333                .build_permission_options();
6334
6335        let connection =
6336            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6337                tool_call_id.clone(),
6338                permission_options.clone(),
6339            )]));
6340
6341        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6342
6343        let (conversation_view, cx) =
6344            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6345        add_to_workspace(conversation_view.clone(), cx);
6346
6347        cx.update(|_window, cx| {
6348            AgentSettings::override_global(
6349                AgentSettings {
6350                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6351                    ..AgentSettings::get_global(cx).clone()
6352                },
6353                cx,
6354            );
6355        });
6356
6357        let message_editor = message_editor(&conversation_view, cx);
6358        message_editor.update_in(cx, |editor, window, cx| {
6359            editor.set_text("Install dependencies", window, cx);
6360        });
6361
6362        active_thread(&conversation_view, cx)
6363            .update_in(cx, |view, window, cx| view.send(window, cx));
6364
6365        cx.run_until_parked();
6366
6367        // Find the pattern option ID (the choice with non-empty sub_patterns)
6368        let pattern_option = match &permission_options {
6369            PermissionOptions::Dropdown(choices) => choices
6370                .iter()
6371                .find(|choice| !choice.sub_patterns.is_empty())
6372                .map(|choice| &choice.allow)
6373                .expect("Should have a pattern option for npm command"),
6374            _ => panic!("Expected dropdown permission options"),
6375        };
6376
6377        // Dispatch action with the pattern option (simulating "Always allow `npm` commands")
6378        conversation_view.update_in(cx, |_, window, cx| {
6379            window.dispatch_action(
6380                crate::AuthorizeToolCall {
6381                    tool_call_id: "pattern-action-test-1".to_string(),
6382                    option_id: pattern_option.option_id.0.to_string(),
6383                    option_kind: "AllowAlways".to_string(),
6384                }
6385                .boxed_clone(),
6386                cx,
6387            );
6388        });
6389
6390        cx.run_until_parked();
6391
6392        // Verify tool call was authorized
6393        conversation_view.read_with(cx, |conversation_view, cx| {
6394            let tool_call = conversation_view.pending_tool_call(cx);
6395            assert!(
6396                tool_call.is_none(),
6397                "Tool call should be authorized after selecting pattern option"
6398            );
6399        });
6400    }
6401
6402    #[gpui::test]
6403    async fn test_granularity_selection_updates_state(cx: &mut TestAppContext) {
6404        init_test(cx);
6405
6406        let tool_call_id = acp::ToolCallId::new("granularity-test-1");
6407        let tool_call =
6408            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit);
6409
6410        let permission_options =
6411            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
6412                .build_permission_options();
6413
6414        let connection =
6415            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6416                tool_call_id.clone(),
6417                permission_options.clone(),
6418            )]));
6419
6420        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6421
6422        let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
6423        add_to_workspace(thread_view.clone(), cx);
6424
6425        cx.update(|_window, cx| {
6426            AgentSettings::override_global(
6427                AgentSettings {
6428                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6429                    ..AgentSettings::get_global(cx).clone()
6430                },
6431                cx,
6432            );
6433        });
6434
6435        let message_editor = message_editor(&thread_view, cx);
6436        message_editor.update_in(cx, |editor, window, cx| {
6437            editor.set_text("Build the project", window, cx);
6438        });
6439
6440        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
6441
6442        cx.run_until_parked();
6443
6444        // Verify default granularity is the last option (index 2 = "Only this time")
6445        thread_view.read_with(cx, |thread_view, cx| {
6446            let state = thread_view.active_thread().unwrap();
6447            let selected = state.read(cx).permission_selections.get(&tool_call_id);
6448            assert!(
6449                selected.is_none(),
6450                "Should have no selection initially (defaults to last)"
6451            );
6452        });
6453
6454        // Select the first option (index 0 = "Always for terminal")
6455        thread_view.update_in(cx, |_, window, cx| {
6456            window.dispatch_action(
6457                crate::SelectPermissionGranularity {
6458                    tool_call_id: "granularity-test-1".to_string(),
6459                    index: 0,
6460                }
6461                .boxed_clone(),
6462                cx,
6463            );
6464        });
6465
6466        cx.run_until_parked();
6467
6468        // Verify the selection was updated
6469        thread_view.read_with(cx, |thread_view, cx| {
6470            let state = thread_view.active_thread().unwrap();
6471            let selected = state.read(cx).permission_selections.get(&tool_call_id);
6472            assert_eq!(
6473                selected.and_then(|s| s.choice_index()),
6474                Some(0),
6475                "Should have selected index 0"
6476            );
6477        });
6478    }
6479
6480    #[gpui::test]
6481    async fn test_allow_button_uses_selected_granularity(cx: &mut TestAppContext) {
6482        init_test(cx);
6483
6484        let tool_call_id = acp::ToolCallId::new("allow-granularity-test-1");
6485        let tool_call =
6486            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
6487
6488        let permission_options =
6489            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
6490                .build_permission_options();
6491
6492        // Verify we have the expected options
6493        let PermissionOptions::Dropdown(choices) = &permission_options else {
6494            panic!("Expected dropdown permission options");
6495        };
6496
6497        assert_eq!(choices.len(), 3);
6498        assert!(
6499            choices[0]
6500                .allow
6501                .option_id
6502                .0
6503                .contains("always_allow:terminal")
6504        );
6505        assert!(
6506            choices[1]
6507                .allow
6508                .option_id
6509                .0
6510                .contains("always_allow:terminal")
6511        );
6512        assert!(!choices[1].sub_patterns.is_empty());
6513        assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
6514
6515        let connection =
6516            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6517                tool_call_id.clone(),
6518                permission_options.clone(),
6519            )]));
6520
6521        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6522
6523        let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
6524        add_to_workspace(thread_view.clone(), cx);
6525
6526        cx.update(|_window, cx| {
6527            AgentSettings::override_global(
6528                AgentSettings {
6529                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6530                    ..AgentSettings::get_global(cx).clone()
6531                },
6532                cx,
6533            );
6534        });
6535
6536        let message_editor = message_editor(&thread_view, cx);
6537        message_editor.update_in(cx, |editor, window, cx| {
6538            editor.set_text("Install dependencies", window, cx);
6539        });
6540
6541        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
6542
6543        cx.run_until_parked();
6544
6545        // Select the pattern option (index 1 = "Always for `npm` commands")
6546        thread_view.update_in(cx, |_, window, cx| {
6547            window.dispatch_action(
6548                crate::SelectPermissionGranularity {
6549                    tool_call_id: "allow-granularity-test-1".to_string(),
6550                    index: 1,
6551                }
6552                .boxed_clone(),
6553                cx,
6554            );
6555        });
6556
6557        cx.run_until_parked();
6558
6559        // Simulate clicking the Allow button by dispatching AllowOnce action
6560        // which should use the selected granularity
6561        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
6562            view.allow_once(&AllowOnce, window, cx)
6563        });
6564
6565        cx.run_until_parked();
6566
6567        // Verify tool call was authorized
6568        thread_view.read_with(cx, |thread_view, cx| {
6569            let tool_call = thread_view.pending_tool_call(cx);
6570            assert!(
6571                tool_call.is_none(),
6572                "Tool call should be authorized after Allow with pattern granularity"
6573            );
6574        });
6575    }
6576
6577    #[gpui::test]
6578    async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) {
6579        init_test(cx);
6580
6581        let tool_call_id = acp::ToolCallId::new("deny-granularity-test-1");
6582        let tool_call =
6583            acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit);
6584
6585        let permission_options =
6586            ToolPermissionContext::new(TerminalTool::NAME, vec!["git push".to_string()])
6587                .build_permission_options();
6588
6589        let connection =
6590            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6591                tool_call_id.clone(),
6592                permission_options.clone(),
6593            )]));
6594
6595        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6596
6597        let (conversation_view, cx) =
6598            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6599        add_to_workspace(conversation_view.clone(), cx);
6600
6601        cx.update(|_window, cx| {
6602            AgentSettings::override_global(
6603                AgentSettings {
6604                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6605                    ..AgentSettings::get_global(cx).clone()
6606                },
6607                cx,
6608            );
6609        });
6610
6611        let message_editor = message_editor(&conversation_view, cx);
6612        message_editor.update_in(cx, |editor, window, cx| {
6613            editor.set_text("Push changes", window, cx);
6614        });
6615
6616        active_thread(&conversation_view, cx)
6617            .update_in(cx, |view, window, cx| view.send(window, cx));
6618
6619        cx.run_until_parked();
6620
6621        // Use default granularity (last option = "Only this time")
6622        // Simulate clicking the Deny button
6623        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
6624            view.reject_once(&RejectOnce, window, cx)
6625        });
6626
6627        cx.run_until_parked();
6628
6629        // Verify tool call was rejected (no longer waiting for confirmation)
6630        conversation_view.read_with(cx, |conversation_view, cx| {
6631            let tool_call = conversation_view.pending_tool_call(cx);
6632            assert!(
6633                tool_call.is_none(),
6634                "Tool call should be rejected after Deny"
6635            );
6636        });
6637    }
6638
6639    #[gpui::test]
6640    async fn test_option_id_transformation_for_allow() {
6641        let permission_options = ToolPermissionContext::new(
6642            TerminalTool::NAME,
6643            vec!["cargo build --release".to_string()],
6644        )
6645        .build_permission_options();
6646
6647        let PermissionOptions::Dropdown(choices) = permission_options else {
6648            panic!("Expected dropdown permission options");
6649        };
6650
6651        let allow_ids: Vec<String> = choices
6652            .iter()
6653            .map(|choice| choice.allow.option_id.0.to_string())
6654            .collect();
6655
6656        assert!(allow_ids.contains(&"allow".to_string()));
6657        assert_eq!(
6658            allow_ids
6659                .iter()
6660                .filter(|id| *id == "always_allow:terminal")
6661                .count(),
6662            2,
6663            "Expected two always_allow:terminal IDs (one whole-tool, one pattern with sub_patterns)"
6664        );
6665    }
6666
6667    #[gpui::test]
6668    async fn test_option_id_transformation_for_deny() {
6669        let permission_options = ToolPermissionContext::new(
6670            TerminalTool::NAME,
6671            vec!["cargo build --release".to_string()],
6672        )
6673        .build_permission_options();
6674
6675        let PermissionOptions::Dropdown(choices) = permission_options else {
6676            panic!("Expected dropdown permission options");
6677        };
6678
6679        let deny_ids: Vec<String> = choices
6680            .iter()
6681            .map(|choice| choice.deny.option_id.0.to_string())
6682            .collect();
6683
6684        assert!(deny_ids.contains(&"deny".to_string()));
6685        assert_eq!(
6686            deny_ids
6687                .iter()
6688                .filter(|id| *id == "always_deny:terminal")
6689                .count(),
6690            2,
6691            "Expected two always_deny:terminal IDs (one whole-tool, one pattern with sub_patterns)"
6692        );
6693    }
6694
6695    fn flat_allow_deny_options() -> PermissionOptions {
6696        PermissionOptions::Flat(vec![
6697            acp::PermissionOption::new(
6698                acp::PermissionOptionId::new("allow"),
6699                "Yes",
6700                acp::PermissionOptionKind::AllowOnce,
6701            ),
6702            acp::PermissionOption::new(
6703                acp::PermissionOptionId::new("deny"),
6704                "No",
6705                acp::PermissionOptionKind::RejectOnce,
6706            ),
6707        ])
6708    }
6709
6710    #[test]
6711    fn resolve_outcome_from_selection_flat_allow_picks_allow_once() {
6712        let options = flat_allow_deny_options();
6713
6714        let outcome = super::resolve_outcome_from_selection(&options, None, true).unwrap();
6715
6716        assert_eq!(outcome.option_id.0.as_ref(), "allow");
6717        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
6718    }
6719
6720    #[test]
6721    fn resolve_outcome_from_selection_flat_deny_picks_reject_once() {
6722        let options = flat_allow_deny_options();
6723
6724        let outcome = super::resolve_outcome_from_selection(&options, None, false).unwrap();
6725
6726        assert_eq!(outcome.option_id.0.as_ref(), "deny");
6727        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::RejectOnce);
6728    }
6729
6730    #[test]
6731    fn resolve_outcome_from_selection_flat_ignores_selection() {
6732        let options = flat_allow_deny_options();
6733        // Flat options never consult the granularity choice, even if one is set.
6734        let selection = thread_view::PermissionSelection::Choice(42);
6735
6736        let outcome =
6737            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
6738
6739        assert_eq!(outcome.option_id.0.as_ref(), "allow");
6740    }
6741
6742    #[test]
6743    fn resolve_outcome_from_selection_dropdown_defaults_to_last_choice_when_no_selection() {
6744        let options =
6745            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
6746                .build_permission_options();
6747
6748        let outcome = super::resolve_outcome_from_selection(&options, None, true).unwrap();
6749
6750        // Last choice is "Only this time" → option_id "allow".
6751        assert_eq!(outcome.option_id.0.as_ref(), "allow");
6752        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
6753    }
6754
6755    #[test]
6756    fn resolve_outcome_from_selection_dropdown_uses_selected_choice() {
6757        let options =
6758            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
6759                .build_permission_options();
6760        let selection = thread_view::PermissionSelection::Choice(0);
6761
6762        let outcome =
6763            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
6764
6765        // Choice 0 = "Always for terminal".
6766        assert!(outcome.option_id.0.contains("always_allow:terminal"));
6767        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowAlways);
6768    }
6769
6770    #[test]
6771    fn resolve_outcome_from_selection_dropdown_out_of_range_falls_back_to_last() {
6772        let options =
6773            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
6774                .build_permission_options();
6775        let selection = thread_view::PermissionSelection::Choice(999);
6776
6777        let outcome =
6778            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
6779
6780        // choices.get(999) is None, falls back to choices.last() → "Only this time".
6781        assert_eq!(outcome.option_id.0.as_ref(), "allow");
6782    }
6783
6784    #[test]
6785    fn resolve_outcome_from_selection_pattern_mode_with_empty_checked_falls_back_to_last_choice() {
6786        // Pipeline commands produce `DropdownWithPatterns`, which is required for
6787        // `SelectedPatterns` to be meaningful.
6788        let options = ToolPermissionContext::new(
6789            TerminalTool::NAME,
6790            vec!["cargo test 2>&1 | tail".to_string()],
6791        )
6792        .build_permission_options();
6793        assert!(matches!(
6794            options,
6795            PermissionOptions::DropdownWithPatterns { .. }
6796        ));
6797        // Pattern mode with zero checked patterns: `build_outcome_for_checked_patterns`
6798        // returns None, so we fall through to `choice_index()` (which is None for
6799        // `SelectedPatterns`) and default to `choices.last()`.
6800        let selection = thread_view::PermissionSelection::SelectedPatterns(vec![]);
6801
6802        let outcome =
6803            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
6804
6805        assert_eq!(outcome.option_id.0.as_ref(), "allow");
6806        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
6807    }
6808
6809    #[test]
6810    fn resolve_outcome_from_selection_pattern_mode_with_checked_uses_always_with_params() {
6811        let options = ToolPermissionContext::new(
6812            TerminalTool::NAME,
6813            vec!["cargo test 2>&1 | tail".to_string()],
6814        )
6815        .build_permission_options();
6816        assert!(matches!(
6817            options,
6818            PermissionOptions::DropdownWithPatterns { .. }
6819        ));
6820        let selection = thread_view::PermissionSelection::SelectedPatterns(vec![0]);
6821
6822        let outcome =
6823            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
6824
6825        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowAlways);
6826        assert!(
6827            outcome.params.is_some(),
6828            "checked patterns should attach terminal params"
6829        );
6830    }
6831
6832    #[gpui::test]
6833    async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) {
6834        init_test(cx);
6835
6836        let (conversation_view, cx) =
6837            setup_conversation_view(StubAgentServer::default_response(), cx).await;
6838        add_to_workspace(conversation_view.clone(), cx);
6839
6840        let active = active_thread(&conversation_view, cx);
6841        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6842        let thread = cx.read(|cx| active.read(cx).thread.clone());
6843
6844        title_editor.read_with(cx, |editor, cx| {
6845            assert!(!editor.read_only(cx));
6846        });
6847
6848        cx.focus(&conversation_view);
6849        cx.focus(&title_editor);
6850
6851        cx.dispatch_action(editor::actions::DeleteLine);
6852        cx.simulate_input("My Custom Title");
6853
6854        cx.run_until_parked();
6855
6856        title_editor.read_with(cx, |editor, cx| {
6857            assert_eq!(editor.text(cx), "My Custom Title");
6858        });
6859        thread.read_with(cx, |thread, _cx| {
6860            assert_eq!(thread.title(), Some("My Custom Title".into()));
6861        });
6862    }
6863
6864    #[gpui::test]
6865    async fn test_title_editor_is_read_only_when_set_title_unsupported(cx: &mut TestAppContext) {
6866        init_test(cx);
6867
6868        let (conversation_view, cx) =
6869            setup_conversation_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await;
6870
6871        let active = active_thread(&conversation_view, cx);
6872        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6873
6874        title_editor.read_with(cx, |editor, cx| {
6875            assert!(
6876                editor.read_only(cx),
6877                "Title editor should be read-only when the connection does not support set_title"
6878            );
6879        });
6880    }
6881
6882    #[gpui::test]
6883    async fn test_max_tokens_error_is_rendered(cx: &mut TestAppContext) {
6884        init_test(cx);
6885
6886        let connection = StubAgentConnection::new();
6887
6888        let (conversation_view, cx) =
6889            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
6890
6891        let message_editor = message_editor(&conversation_view, cx);
6892        message_editor.update_in(cx, |editor, window, cx| {
6893            editor.set_text("Some prompt", window, cx);
6894        });
6895        active_thread(&conversation_view, cx)
6896            .update_in(cx, |view, window, cx| view.send(window, cx));
6897
6898        let session_id = conversation_view.read_with(cx, |view, cx| {
6899            view.active_thread()
6900                .unwrap()
6901                .read(cx)
6902                .thread
6903                .read(cx)
6904                .session_id()
6905                .clone()
6906        });
6907
6908        cx.run_until_parked();
6909
6910        cx.update(|_, _cx| {
6911            connection.end_turn(session_id, acp::StopReason::MaxTokens);
6912        });
6913
6914        cx.run_until_parked();
6915
6916        conversation_view.read_with(cx, |conversation_view, cx| {
6917            let state = conversation_view.active_thread().unwrap();
6918            let error = &state.read(cx).thread_error;
6919            assert!(
6920                matches!(error, Some(ThreadError::MaxOutputTokens)),
6921                "Expected ThreadError::MaxOutputTokens, got: {:?}",
6922                error.is_some()
6923            );
6924        });
6925    }
6926
6927    fn create_test_acp_thread(
6928        parent_session_id: Option<acp::SessionId>,
6929        session_id: &str,
6930        connection: Rc<dyn AgentConnection>,
6931        project: Entity<Project>,
6932        cx: &mut App,
6933    ) -> Entity<AcpThread> {
6934        let action_log = cx.new(|_| ActionLog::new(project.clone()));
6935        cx.new(|cx| {
6936            AcpThread::new(
6937                parent_session_id,
6938                None,
6939                None,
6940                connection,
6941                project,
6942                action_log,
6943                acp::SessionId::new(session_id),
6944                watch::Receiver::constant(acp::PromptCapabilities::new()),
6945                cx,
6946            )
6947        })
6948    }
6949
6950    fn request_test_tool_authorization(
6951        thread: &Entity<AcpThread>,
6952        tool_call_id: &str,
6953        option_id: &str,
6954        cx: &mut TestAppContext,
6955    ) -> Task<acp_thread::RequestPermissionOutcome> {
6956        let tool_call_id = acp::ToolCallId::new(tool_call_id);
6957        let label = format!("Tool {tool_call_id}");
6958        let option_id = acp::PermissionOptionId::new(option_id);
6959        cx.update(|cx| {
6960            thread.update(cx, |thread, cx| {
6961                thread
6962                    .request_tool_call_authorization(
6963                        acp::ToolCall::new(tool_call_id, label)
6964                            .kind(acp::ToolKind::Edit)
6965                            .into(),
6966                        PermissionOptions::Flat(vec![acp::PermissionOption::new(
6967                            option_id,
6968                            "Allow",
6969                            acp::PermissionOptionKind::AllowOnce,
6970                        )]),
6971                        cx,
6972                    )
6973                    .unwrap()
6974            })
6975        })
6976    }
6977
6978    #[gpui::test]
6979    async fn test_conversation_multiple_tool_calls_fifo_ordering(cx: &mut TestAppContext) {
6980        init_test(cx);
6981
6982        let fs = FakeFs::new(cx.executor());
6983        let project = Project::test(fs, [], cx).await;
6984        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6985
6986        let session_id = acp::SessionId::new("session-1");
6987        let (thread, conversation) = cx.update(|cx| {
6988            let thread =
6989                create_test_acp_thread(None, "session-1", connection.clone(), project.clone(), cx);
6990            let conversation = cx.new(|cx| {
6991                let mut conversation = Conversation::default();
6992                conversation.register_thread(thread.clone(), cx);
6993                conversation
6994            });
6995            (thread, conversation)
6996        });
6997
6998        let _task1 = request_test_tool_authorization(&thread, "tc-1", "allow-1", cx);
6999        let _task2 = request_test_tool_authorization(&thread, "tc-2", "allow-2", cx);
7000
7001        cx.read(|cx| {
7002            let (_, tool_call_id, _) = conversation
7003                .read(cx)
7004                .pending_tool_call(&session_id, cx)
7005                .expect("Expected a pending tool call");
7006            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-1"));
7007        });
7008
7009        cx.update(|cx| {
7010            conversation.update(cx, |conversation, cx| {
7011                conversation.authorize_tool_call(
7012                    session_id.clone(),
7013                    acp::ToolCallId::new("tc-1"),
7014                    SelectedPermissionOutcome::new(
7015                        acp::PermissionOptionId::new("allow-1"),
7016                        acp::PermissionOptionKind::AllowOnce,
7017                    ),
7018                    cx,
7019                );
7020            });
7021        });
7022
7023        cx.run_until_parked();
7024
7025        cx.read(|cx| {
7026            let (_, tool_call_id, _) = conversation
7027                .read(cx)
7028                .pending_tool_call(&session_id, cx)
7029                .expect("Expected tc-2 to be pending after tc-1 was authorized");
7030            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-2"));
7031        });
7032
7033        cx.update(|cx| {
7034            conversation.update(cx, |conversation, cx| {
7035                conversation.authorize_tool_call(
7036                    session_id.clone(),
7037                    acp::ToolCallId::new("tc-2"),
7038                    SelectedPermissionOutcome::new(
7039                        acp::PermissionOptionId::new("allow-2"),
7040                        acp::PermissionOptionKind::AllowOnce,
7041                    ),
7042                    cx,
7043                );
7044            });
7045        });
7046
7047        cx.run_until_parked();
7048
7049        cx.read(|cx| {
7050            assert!(
7051                conversation
7052                    .read(cx)
7053                    .pending_tool_call(&session_id, cx)
7054                    .is_none(),
7055                "Expected no pending tool calls after both were authorized"
7056            );
7057        });
7058    }
7059
7060    #[gpui::test]
7061    async fn test_conversation_subagent_scoped_pending_tool_call(cx: &mut TestAppContext) {
7062        init_test(cx);
7063
7064        let fs = FakeFs::new(cx.executor());
7065        let project = Project::test(fs, [], cx).await;
7066        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
7067
7068        let parent_session_id = acp::SessionId::new("parent");
7069        let subagent_session_id = acp::SessionId::new("subagent");
7070        let (parent_thread, subagent_thread, conversation) = cx.update(|cx| {
7071            let parent_thread =
7072                create_test_acp_thread(None, "parent", connection.clone(), project.clone(), cx);
7073            let subagent_thread = create_test_acp_thread(
7074                Some(acp::SessionId::new("parent")),
7075                "subagent",
7076                connection.clone(),
7077                project.clone(),
7078                cx,
7079            );
7080            let conversation = cx.new(|cx| {
7081                let mut conversation = Conversation::default();
7082                conversation.register_thread(parent_thread.clone(), cx);
7083                conversation.register_thread(subagent_thread.clone(), cx);
7084                conversation
7085            });
7086            (parent_thread, subagent_thread, conversation)
7087        });
7088
7089        let _parent_task =
7090            request_test_tool_authorization(&parent_thread, "parent-tc", "allow-parent", cx);
7091        let _subagent_task =
7092            request_test_tool_authorization(&subagent_thread, "subagent-tc", "allow-subagent", cx);
7093
7094        // Querying with the subagent's session ID returns only the
7095        // subagent's own tool call (subagent path is scoped to its session)
7096        cx.read(|cx| {
7097            let (returned_session_id, tool_call_id, _) = conversation
7098                .read(cx)
7099                .pending_tool_call(&subagent_session_id, cx)
7100                .expect("Expected subagent's pending tool call");
7101            assert_eq!(returned_session_id, subagent_session_id);
7102            assert_eq!(tool_call_id, acp::ToolCallId::new("subagent-tc"));
7103        });
7104
7105        // Querying with the parent's session ID returns the first pending
7106        // request in FIFO order across all sessions
7107        cx.read(|cx| {
7108            let (returned_session_id, tool_call_id, _) = conversation
7109                .read(cx)
7110                .pending_tool_call(&parent_session_id, cx)
7111                .expect("Expected a pending tool call from parent query");
7112            assert_eq!(returned_session_id, parent_session_id);
7113            assert_eq!(tool_call_id, acp::ToolCallId::new("parent-tc"));
7114        });
7115    }
7116
7117    #[gpui::test]
7118    async fn test_conversation_parent_pending_tool_call_returns_first_across_threads(
7119        cx: &mut TestAppContext,
7120    ) {
7121        init_test(cx);
7122
7123        let fs = FakeFs::new(cx.executor());
7124        let project = Project::test(fs, [], cx).await;
7125        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
7126
7127        let session_id_a = acp::SessionId::new("thread-a");
7128        let session_id_b = acp::SessionId::new("thread-b");
7129        let (thread_a, thread_b, conversation) = cx.update(|cx| {
7130            let thread_a =
7131                create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx);
7132            let thread_b =
7133                create_test_acp_thread(None, "thread-b", connection.clone(), project.clone(), cx);
7134            let conversation = cx.new(|cx| {
7135                let mut conversation = Conversation::default();
7136                conversation.register_thread(thread_a.clone(), cx);
7137                conversation.register_thread(thread_b.clone(), cx);
7138                conversation
7139            });
7140            (thread_a, thread_b, conversation)
7141        });
7142
7143        let _task_a = request_test_tool_authorization(&thread_a, "tc-a", "allow-a", cx);
7144        let _task_b = request_test_tool_authorization(&thread_b, "tc-b", "allow-b", cx);
7145
7146        // Both threads are non-subagent, so pending_tool_call always returns
7147        // the first entry from permission_requests (FIFO across all sessions)
7148        cx.read(|cx| {
7149            let (returned_session_id, tool_call_id, _) = conversation
7150                .read(cx)
7151                .pending_tool_call(&session_id_a, cx)
7152                .expect("Expected a pending tool call");
7153            assert_eq!(returned_session_id, session_id_a);
7154            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
7155        });
7156
7157        // Querying with thread-b also returns thread-a's tool call,
7158        // because non-subagent queries always use permission_requests.first()
7159        cx.read(|cx| {
7160            let (returned_session_id, tool_call_id, _) = conversation
7161                .read(cx)
7162                .pending_tool_call(&session_id_b, cx)
7163                .expect("Expected a pending tool call from thread-b query");
7164            assert_eq!(
7165                returned_session_id, session_id_a,
7166                "Non-subagent queries always return the first pending request in FIFO order"
7167            );
7168            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
7169        });
7170
7171        // After authorizing thread-a's tool call, thread-b's becomes first
7172        cx.update(|cx| {
7173            conversation.update(cx, |conversation, cx| {
7174                conversation.authorize_tool_call(
7175                    session_id_a.clone(),
7176                    acp::ToolCallId::new("tc-a"),
7177                    SelectedPermissionOutcome::new(
7178                        acp::PermissionOptionId::new("allow-a"),
7179                        acp::PermissionOptionKind::AllowOnce,
7180                    ),
7181                    cx,
7182                );
7183            });
7184        });
7185
7186        cx.run_until_parked();
7187
7188        cx.read(|cx| {
7189            let (returned_session_id, tool_call_id, _) = conversation
7190                .read(cx)
7191                .pending_tool_call(&session_id_b, cx)
7192                .expect("Expected thread-b's tool call after thread-a's was authorized");
7193            assert_eq!(returned_session_id, session_id_b);
7194            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b"));
7195        });
7196    }
7197
7198    #[gpui::test]
7199    async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) {
7200        init_test(cx);
7201
7202        let (conversation_view, cx) =
7203            setup_conversation_view(StubAgentServer::default_response(), cx).await;
7204
7205        // Add a plain-text message to the queue directly.
7206        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
7207            thread.add_to_queue(
7208                vec![acp::ContentBlock::Text(acp::TextContent::new(
7209                    "queued message".to_string(),
7210                ))],
7211                vec![],
7212                cx,
7213            );
7214            // Main editor must be empty for this path — it is by default, but
7215            // assert to make the precondition explicit.
7216            assert!(thread.message_editor.read(cx).is_empty(cx));
7217            thread.move_queued_message_to_main_editor(0, None, None, window, cx);
7218        });
7219
7220        cx.run_until_parked();
7221
7222        // Queue should now be empty.
7223        let queue_len = active_thread(&conversation_view, cx)
7224            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
7225        assert_eq!(queue_len, 0, "Queue should be empty after move");
7226
7227        // Main editor should contain the queued message text.
7228        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
7229        assert_eq!(
7230            text, "queued message",
7231            "Main editor should contain the moved queued message"
7232        );
7233    }
7234
7235    #[gpui::test]
7236    async fn test_move_queued_message_to_non_empty_main_editor(cx: &mut TestAppContext) {
7237        init_test(cx);
7238
7239        let (conversation_view, cx) =
7240            setup_conversation_view(StubAgentServer::default_response(), cx).await;
7241
7242        // Seed the main editor with existing content.
7243        message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| {
7244            editor.set_message(
7245                vec![acp::ContentBlock::Text(acp::TextContent::new(
7246                    "existing content".to_string(),
7247                ))],
7248                window,
7249                cx,
7250            );
7251        });
7252
7253        // Add a plain-text message to the queue.
7254        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
7255            thread.add_to_queue(
7256                vec![acp::ContentBlock::Text(acp::TextContent::new(
7257                    "queued message".to_string(),
7258                ))],
7259                vec![],
7260                cx,
7261            );
7262            thread.move_queued_message_to_main_editor(0, None, None, window, cx);
7263        });
7264
7265        cx.run_until_parked();
7266
7267        // Queue should now be empty.
7268        let queue_len = active_thread(&conversation_view, cx)
7269            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
7270        assert_eq!(queue_len, 0, "Queue should be empty after move");
7271
7272        // Main editor should contain existing content + separator + queued content.
7273        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
7274        assert_eq!(
7275            text, "existing content\n\nqueued message",
7276            "Main editor should have existing content and queued message separated by two newlines"
7277        );
7278    }
7279
7280    #[gpui::test]
7281    async fn test_close_all_sessions_skips_when_unsupported(cx: &mut TestAppContext) {
7282        init_test(cx);
7283
7284        let fs = FakeFs::new(cx.executor());
7285        let project = Project::test(fs, [], cx).await;
7286        let (multi_workspace, cx) =
7287            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7288        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7289
7290        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
7291        let connection_store =
7292            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
7293
7294        // StubAgentConnection defaults to supports_close_session() -> false
7295        let conversation_view = cx.update(|window, cx| {
7296            cx.new(|cx| {
7297                ConversationView::new(
7298                    Rc::new(StubAgentServer::default_response()),
7299                    connection_store,
7300                    Agent::Custom { id: "Test".into() },
7301                    None,
7302                    None,
7303                    None,
7304                    None,
7305                    None,
7306                    workspace.downgrade(),
7307                    project,
7308                    Some(thread_store),
7309                    None,
7310                    "agent_panel",
7311                    window,
7312                    cx,
7313                )
7314            })
7315        });
7316
7317        cx.run_until_parked();
7318
7319        conversation_view.read_with(cx, |view, _cx| {
7320            let connected = view.as_connected().expect("Should be connected");
7321            assert!(
7322                !connected.threads.is_empty(),
7323                "There should be at least one thread"
7324            );
7325            assert!(
7326                !connected.connection.supports_close_session(),
7327                "StubAgentConnection should not support close"
7328            );
7329        });
7330
7331        conversation_view
7332            .update(cx, |view, cx| {
7333                view.as_connected()
7334                    .expect("Should be connected")
7335                    .close_all_sessions(cx)
7336            })
7337            .await;
7338    }
7339
7340    #[gpui::test]
7341    async fn test_close_all_sessions_calls_close_when_supported(cx: &mut TestAppContext) {
7342        init_test(cx);
7343
7344        let (conversation_view, cx) =
7345            setup_conversation_view(StubAgentServer::new(CloseCapableConnection::new()), cx).await;
7346
7347        cx.run_until_parked();
7348
7349        let close_capable = conversation_view.read_with(cx, |view, _cx| {
7350            let connected = view.as_connected().expect("Should be connected");
7351            assert!(
7352                !connected.threads.is_empty(),
7353                "There should be at least one thread"
7354            );
7355            assert!(
7356                connected.connection.supports_close_session(),
7357                "CloseCapableConnection should support close"
7358            );
7359            connected
7360                .connection
7361                .clone()
7362                .into_any()
7363                .downcast::<CloseCapableConnection>()
7364                .expect("Should be CloseCapableConnection")
7365        });
7366
7367        conversation_view
7368            .update(cx, |view, cx| {
7369                view.as_connected()
7370                    .expect("Should be connected")
7371                    .close_all_sessions(cx)
7372            })
7373            .await;
7374
7375        let closed_count = close_capable.closed_sessions.lock().len();
7376        assert!(
7377            closed_count > 0,
7378            "close_session should have been called for each thread"
7379        );
7380    }
7381
7382    #[gpui::test]
7383    async fn test_close_session_returns_error_when_unsupported(cx: &mut TestAppContext) {
7384        init_test(cx);
7385
7386        let (conversation_view, cx) =
7387            setup_conversation_view(StubAgentServer::default_response(), cx).await;
7388
7389        cx.run_until_parked();
7390
7391        let result = conversation_view
7392            .update(cx, |view, cx| {
7393                let connected = view.as_connected().expect("Should be connected");
7394                assert!(
7395                    !connected.connection.supports_close_session(),
7396                    "StubAgentConnection should not support close"
7397                );
7398                let thread_view = connected
7399                    .threads
7400                    .values()
7401                    .next()
7402                    .expect("Should have at least one thread");
7403                let session_id = thread_view.read(cx).thread.read(cx).session_id().clone();
7404                connected.connection.clone().close_session(&session_id, cx)
7405            })
7406            .await;
7407
7408        assert!(
7409            result.is_err(),
7410            "close_session should return an error when close is not supported"
7411        );
7412        assert!(
7413            result.unwrap_err().to_string().contains("not supported"),
7414            "Error message should indicate that closing is not supported"
7415        );
7416    }
7417
7418    #[derive(Clone)]
7419    struct CloseCapableConnection {
7420        closed_sessions: Arc<Mutex<Vec<acp::SessionId>>>,
7421    }
7422
7423    impl CloseCapableConnection {
7424        fn new() -> Self {
7425            Self {
7426                closed_sessions: Arc::new(Mutex::new(Vec::new())),
7427            }
7428        }
7429    }
7430
7431    impl AgentConnection for CloseCapableConnection {
7432        fn agent_id(&self) -> AgentId {
7433            AgentId::new("close-capable")
7434        }
7435
7436        fn telemetry_id(&self) -> SharedString {
7437            "close-capable".into()
7438        }
7439
7440        fn new_session(
7441            self: Rc<Self>,
7442            project: Entity<Project>,
7443            work_dirs: PathList,
7444            cx: &mut gpui::App,
7445        ) -> Task<gpui::Result<Entity<AcpThread>>> {
7446            let action_log = cx.new(|_| ActionLog::new(project.clone()));
7447            let thread = cx.new(|cx| {
7448                AcpThread::new(
7449                    None,
7450                    Some("CloseCapableConnection".into()),
7451                    Some(work_dirs),
7452                    self,
7453                    project,
7454                    action_log,
7455                    acp::SessionId::new("close-capable-session"),
7456                    watch::Receiver::constant(
7457                        acp::PromptCapabilities::new()
7458                            .image(true)
7459                            .audio(true)
7460                            .embedded_context(true),
7461                    ),
7462                    cx,
7463                )
7464            });
7465            Task::ready(Ok(thread))
7466        }
7467
7468        fn supports_close_session(&self) -> bool {
7469            true
7470        }
7471
7472        fn close_session(
7473            self: Rc<Self>,
7474            session_id: &acp::SessionId,
7475            _cx: &mut App,
7476        ) -> Task<Result<()>> {
7477            self.closed_sessions.lock().push(session_id.clone());
7478            Task::ready(Ok(()))
7479        }
7480
7481        fn auth_methods(&self) -> &[acp::AuthMethod] {
7482            &[]
7483        }
7484
7485        fn authenticate(
7486            &self,
7487            _method_id: acp::AuthMethodId,
7488            _cx: &mut App,
7489        ) -> Task<gpui::Result<()>> {
7490            Task::ready(Ok(()))
7491        }
7492
7493        fn prompt(
7494            &self,
7495            _id: acp_thread::UserMessageId,
7496            _params: acp::PromptRequest,
7497            _cx: &mut App,
7498        ) -> Task<gpui::Result<acp::PromptResponse>> {
7499            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
7500        }
7501
7502        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
7503
7504        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
7505            self
7506        }
7507    }
7508}