connection_view.rs

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