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