conversation_view.rs

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