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