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::dock::Panel;
2784    use workspace::{Item, MultiWorkspace};
2785
2786    use crate::agent_panel;
2787
2788    use super::*;
2789
2790    #[gpui::test]
2791    async fn test_drop(cx: &mut TestAppContext) {
2792        init_test(cx);
2793
2794        let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
2795        let weak_view = thread_view.downgrade();
2796        drop(thread_view);
2797        assert!(!weak_view.is_upgradable());
2798    }
2799
2800    #[gpui::test]
2801    async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) {
2802        init_test(cx);
2803
2804        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2805            panic!("expected prompt from external source to sanitize successfully");
2806        };
2807        let initial_content = AgentInitialContent::FromExternalSource(prompt);
2808
2809        let (thread_view, cx) = setup_thread_view_with_initial_content(
2810            StubAgentServer::default_response(),
2811            initial_content,
2812            cx,
2813        )
2814        .await;
2815
2816        active_thread(&thread_view, cx).read_with(cx, |view, cx| {
2817            assert!(view.show_external_source_prompt_warning);
2818            assert_eq!(view.thread.read(cx).entries().len(), 0);
2819            assert_eq!(view.message_editor.read(cx).text(cx), "Write me a script");
2820        });
2821    }
2822
2823    #[gpui::test]
2824    async fn test_external_source_prompt_warning_clears_after_send(cx: &mut TestAppContext) {
2825        init_test(cx);
2826
2827        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2828            panic!("expected prompt from external source to sanitize successfully");
2829        };
2830        let initial_content = AgentInitialContent::FromExternalSource(prompt);
2831
2832        let (thread_view, cx) = setup_thread_view_with_initial_content(
2833            StubAgentServer::default_response(),
2834            initial_content,
2835            cx,
2836        )
2837        .await;
2838
2839        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
2840        cx.run_until_parked();
2841
2842        active_thread(&thread_view, cx).read_with(cx, |view, cx| {
2843            assert!(!view.show_external_source_prompt_warning);
2844            assert_eq!(view.message_editor.read(cx).text(cx), "");
2845            assert_eq!(view.thread.read(cx).entries().len(), 2);
2846        });
2847    }
2848
2849    #[gpui::test]
2850    async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
2851        init_test(cx);
2852
2853        let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
2854
2855        let message_editor = message_editor(&thread_view, cx);
2856        message_editor.update_in(cx, |editor, window, cx| {
2857            editor.set_text("Hello", window, cx);
2858        });
2859
2860        cx.deactivate_window();
2861
2862        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
2863
2864        cx.run_until_parked();
2865
2866        assert!(
2867            cx.windows()
2868                .iter()
2869                .any(|window| window.downcast::<AgentNotification>().is_some())
2870        );
2871    }
2872
2873    #[gpui::test]
2874    async fn test_notification_for_error(cx: &mut TestAppContext) {
2875        init_test(cx);
2876
2877        let (thread_view, cx) =
2878            setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
2879
2880        let message_editor = message_editor(&thread_view, cx);
2881        message_editor.update_in(cx, |editor, window, cx| {
2882            editor.set_text("Hello", window, cx);
2883        });
2884
2885        cx.deactivate_window();
2886
2887        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
2888
2889        cx.run_until_parked();
2890
2891        assert!(
2892            cx.windows()
2893                .iter()
2894                .any(|window| window.downcast::<AgentNotification>().is_some())
2895        );
2896    }
2897
2898    #[gpui::test]
2899    async fn test_recent_history_refreshes_when_history_cache_updated(cx: &mut TestAppContext) {
2900        init_test(cx);
2901
2902        let session_a = AgentSessionInfo::new(SessionId::new("session-a"));
2903        let session_b = AgentSessionInfo::new(SessionId::new("session-b"));
2904
2905        let fs = FakeFs::new(cx.executor());
2906        let project = Project::test(fs, [], cx).await;
2907        let (multi_workspace, cx) =
2908            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2909        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2910
2911        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
2912        // Create history without an initial session list - it will be set after connection
2913        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
2914
2915        let thread_view = cx.update(|window, cx| {
2916            cx.new(|cx| {
2917                ConnectionView::new(
2918                    Rc::new(StubAgentServer::default_response()),
2919                    None,
2920                    None,
2921                    None,
2922                    None,
2923                    workspace.downgrade(),
2924                    project,
2925                    Some(thread_store),
2926                    None,
2927                    history.clone(),
2928                    window,
2929                    cx,
2930                )
2931            })
2932        });
2933
2934        // Wait for connection to establish
2935        cx.run_until_parked();
2936
2937        // Initially empty because StubAgentConnection.session_list() returns None
2938        active_thread(&thread_view, cx).read_with(cx, |view, _cx| {
2939            assert_eq!(view.recent_history_entries.len(), 0);
2940        });
2941
2942        // Now set the session list - this simulates external agents providing their history
2943        let list_a: Rc<dyn AgentSessionList> =
2944            Rc::new(StubSessionList::new(vec![session_a.clone()]));
2945        history.update(cx, |history, cx| {
2946            history.set_session_list(Some(list_a), cx);
2947        });
2948        cx.run_until_parked();
2949
2950        active_thread(&thread_view, cx).read_with(cx, |view, _cx| {
2951            assert_eq!(view.recent_history_entries.len(), 1);
2952            assert_eq!(
2953                view.recent_history_entries[0].session_id,
2954                session_a.session_id
2955            );
2956        });
2957
2958        // Update to a different session list
2959        let list_b: Rc<dyn AgentSessionList> =
2960            Rc::new(StubSessionList::new(vec![session_b.clone()]));
2961        history.update(cx, |history, cx| {
2962            history.set_session_list(Some(list_b), cx);
2963        });
2964        cx.run_until_parked();
2965
2966        active_thread(&thread_view, cx).read_with(cx, |view, _cx| {
2967            assert_eq!(view.recent_history_entries.len(), 1);
2968            assert_eq!(
2969                view.recent_history_entries[0].session_id,
2970                session_b.session_id
2971            );
2972        });
2973    }
2974
2975    #[gpui::test]
2976    async fn test_new_thread_creation_triggers_session_list_refresh(cx: &mut TestAppContext) {
2977        init_test(cx);
2978
2979        let session = AgentSessionInfo::new(SessionId::new("history-session"));
2980        let (thread_view, history, cx) = setup_thread_view_with_history(
2981            StubAgentServer::new(SessionHistoryConnection::new(vec![session.clone()])),
2982            cx,
2983        )
2984        .await;
2985
2986        history.read_with(cx, |history, _cx| {
2987            assert!(
2988                history.has_session_list(),
2989                "session list should be attached after thread creation"
2990            );
2991        });
2992
2993        active_thread(&thread_view, cx).read_with(cx, |view, _cx| {
2994            assert_eq!(view.recent_history_entries.len(), 1);
2995            assert_eq!(
2996                view.recent_history_entries[0].session_id,
2997                session.session_id
2998            );
2999        });
3000    }
3001
3002    #[gpui::test]
3003    async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
3004        init_test(cx);
3005
3006        let fs = FakeFs::new(cx.executor());
3007        let project = Project::test(fs, [], cx).await;
3008        let (multi_workspace, cx) =
3009            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3010        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3011
3012        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3013        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
3014
3015        let thread_view = cx.update(|window, cx| {
3016            cx.new(|cx| {
3017                ConnectionView::new(
3018                    Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
3019                    Some(SessionId::new("resume-session")),
3020                    None,
3021                    None,
3022                    None,
3023                    workspace.downgrade(),
3024                    project,
3025                    Some(thread_store),
3026                    None,
3027                    history,
3028                    window,
3029                    cx,
3030                )
3031            })
3032        });
3033
3034        cx.run_until_parked();
3035
3036        thread_view.read_with(cx, |view, cx| {
3037            let state = view.active_thread().unwrap();
3038            assert!(state.read(cx).resumed_without_history);
3039            assert_eq!(state.read(cx).list_state.item_count(), 0);
3040        });
3041    }
3042
3043    #[gpui::test]
3044    async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) {
3045        init_test(cx);
3046
3047        let fs = FakeFs::new(cx.executor());
3048        fs.insert_tree(
3049            "/project",
3050            json!({
3051                "subdir": {
3052                    "file.txt": "hello"
3053                }
3054            }),
3055        )
3056        .await;
3057        let project = Project::test(fs, [Path::new("/project")], cx).await;
3058        let (multi_workspace, cx) =
3059            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3060        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3061
3062        let connection = CwdCapturingConnection::new();
3063        let captured_cwd = connection.captured_cwd.clone();
3064
3065        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3066        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
3067
3068        let _thread_view = cx.update(|window, cx| {
3069            cx.new(|cx| {
3070                ConnectionView::new(
3071                    Rc::new(StubAgentServer::new(connection)),
3072                    Some(SessionId::new("session-1")),
3073                    Some(PathBuf::from("/project/subdir")),
3074                    None,
3075                    None,
3076                    workspace.downgrade(),
3077                    project,
3078                    Some(thread_store),
3079                    None,
3080                    history,
3081                    window,
3082                    cx,
3083                )
3084            })
3085        });
3086
3087        cx.run_until_parked();
3088
3089        assert_eq!(
3090            captured_cwd.lock().as_deref(),
3091            Some(Path::new("/project/subdir")),
3092            "Should use session cwd when it's inside the project"
3093        );
3094    }
3095
3096    #[gpui::test]
3097    async fn test_resume_thread_uses_fallback_cwd_when_outside_project(cx: &mut TestAppContext) {
3098        init_test(cx);
3099
3100        let fs = FakeFs::new(cx.executor());
3101        fs.insert_tree(
3102            "/project",
3103            json!({
3104                "file.txt": "hello"
3105            }),
3106        )
3107        .await;
3108        let project = Project::test(fs, [Path::new("/project")], cx).await;
3109        let (multi_workspace, cx) =
3110            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3111        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3112
3113        let connection = CwdCapturingConnection::new();
3114        let captured_cwd = connection.captured_cwd.clone();
3115
3116        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3117        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
3118
3119        let _thread_view = cx.update(|window, cx| {
3120            cx.new(|cx| {
3121                ConnectionView::new(
3122                    Rc::new(StubAgentServer::new(connection)),
3123                    Some(SessionId::new("session-1")),
3124                    Some(PathBuf::from("/some/other/path")),
3125                    None,
3126                    None,
3127                    workspace.downgrade(),
3128                    project,
3129                    Some(thread_store),
3130                    None,
3131                    history,
3132                    window,
3133                    cx,
3134                )
3135            })
3136        });
3137
3138        cx.run_until_parked();
3139
3140        assert_eq!(
3141            captured_cwd.lock().as_deref(),
3142            Some(Path::new("/project")),
3143            "Should use fallback project cwd when session cwd is outside the project"
3144        );
3145    }
3146
3147    #[gpui::test]
3148    async fn test_resume_thread_rejects_unnormalized_cwd_outside_project(cx: &mut TestAppContext) {
3149        init_test(cx);
3150
3151        let fs = FakeFs::new(cx.executor());
3152        fs.insert_tree(
3153            "/project",
3154            json!({
3155                "file.txt": "hello"
3156            }),
3157        )
3158        .await;
3159        let project = Project::test(fs, [Path::new("/project")], cx).await;
3160        let (multi_workspace, cx) =
3161            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3162        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3163
3164        let connection = CwdCapturingConnection::new();
3165        let captured_cwd = connection.captured_cwd.clone();
3166
3167        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3168        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
3169
3170        let _thread_view = cx.update(|window, cx| {
3171            cx.new(|cx| {
3172                ConnectionView::new(
3173                    Rc::new(StubAgentServer::new(connection)),
3174                    Some(SessionId::new("session-1")),
3175                    Some(PathBuf::from("/project/../outside")),
3176                    None,
3177                    None,
3178                    workspace.downgrade(),
3179                    project,
3180                    Some(thread_store),
3181                    None,
3182                    history,
3183                    window,
3184                    cx,
3185                )
3186            })
3187        });
3188
3189        cx.run_until_parked();
3190
3191        assert_eq!(
3192            captured_cwd.lock().as_deref(),
3193            Some(Path::new("/project")),
3194            "Should reject unnormalized cwd that resolves outside the project and use fallback cwd"
3195        );
3196    }
3197
3198    #[gpui::test]
3199    async fn test_refusal_handling(cx: &mut TestAppContext) {
3200        init_test(cx);
3201
3202        let (thread_view, cx) =
3203            setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
3204
3205        let message_editor = message_editor(&thread_view, cx);
3206        message_editor.update_in(cx, |editor, window, cx| {
3207            editor.set_text("Do something harmful", window, cx);
3208        });
3209
3210        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3211
3212        cx.run_until_parked();
3213
3214        // Check that the refusal error is set
3215        thread_view.read_with(cx, |thread_view, cx| {
3216            let state = thread_view.active_thread().unwrap();
3217            assert!(
3218                matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)),
3219                "Expected refusal error to be set"
3220            );
3221        });
3222    }
3223
3224    #[gpui::test]
3225    async fn test_connect_failure_transitions_to_load_error(cx: &mut TestAppContext) {
3226        init_test(cx);
3227
3228        let (thread_view, cx) = setup_thread_view(FailingAgentServer, cx).await;
3229
3230        thread_view.read_with(cx, |view, cx| {
3231            let title = view.title(cx);
3232            assert_eq!(
3233                title.as_ref(),
3234                "Error Loading Codex CLI",
3235                "Tab title should show the agent name with an error prefix"
3236            );
3237            match &view.server_state {
3238                ServerState::LoadError {
3239                    error: LoadError::Other(msg),
3240                    ..
3241                } => {
3242                    assert!(
3243                        msg.contains("Invalid gzip header"),
3244                        "Error callout should contain the underlying extraction error, got: {msg}"
3245                    );
3246                }
3247                other => panic!(
3248                    "Expected LoadError::Other, got: {}",
3249                    match other {
3250                        ServerState::Loading(_) => "Loading (stuck!)",
3251                        ServerState::LoadError { .. } => "LoadError (wrong variant)",
3252                        ServerState::Connected(_) => "Connected",
3253                    }
3254                ),
3255            }
3256        });
3257    }
3258
3259    #[gpui::test]
3260    async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) {
3261        init_test(cx);
3262
3263        let connection = AuthGatedAgentConnection::new();
3264        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
3265
3266        // When new_session returns AuthRequired, the server should transition
3267        // to Connected + Unauthenticated rather than getting stuck in Loading.
3268        thread_view.read_with(cx, |view, _cx| {
3269            let connected = view
3270                .as_connected()
3271                .expect("Should be in Connected state even though auth is required");
3272            assert!(
3273                !connected.auth_state.is_ok(),
3274                "Auth state should be Unauthenticated"
3275            );
3276            assert!(
3277                connected.active_id.is_none(),
3278                "There should be no active thread since no session was created"
3279            );
3280            assert!(
3281                connected.threads.is_empty(),
3282                "There should be no threads since no session was created"
3283            );
3284        });
3285
3286        thread_view.read_with(cx, |view, _cx| {
3287            assert!(
3288                view.active_thread().is_none(),
3289                "active_thread() should be None when unauthenticated without a session"
3290            );
3291        });
3292
3293        // Authenticate using the real authenticate flow on ConnectionView.
3294        // This calls connection.authenticate(), which flips the internal flag,
3295        // then on success triggers reset() -> new_session() which now succeeds.
3296        thread_view.update_in(cx, |view, window, cx| {
3297            view.authenticate(
3298                acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID),
3299                window,
3300                cx,
3301            );
3302        });
3303        cx.run_until_parked();
3304
3305        // After auth, the server should have an active thread in the Ok state.
3306        thread_view.read_with(cx, |view, cx| {
3307            let connected = view
3308                .as_connected()
3309                .expect("Should still be in Connected state after auth");
3310            assert!(connected.auth_state.is_ok(), "Auth state should be Ok");
3311            assert!(
3312                connected.active_id.is_some(),
3313                "There should be an active thread after successful auth"
3314            );
3315            assert_eq!(
3316                connected.threads.len(),
3317                1,
3318                "There should be exactly one thread"
3319            );
3320
3321            let active = view
3322                .active_thread()
3323                .expect("active_thread() should return the new thread");
3324            assert!(
3325                active.read(cx).thread_error.is_none(),
3326                "The new thread should have no errors"
3327            );
3328        });
3329    }
3330
3331    #[gpui::test]
3332    async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3333        init_test(cx);
3334
3335        let tool_call_id = acp::ToolCallId::new("1");
3336        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label")
3337            .kind(acp::ToolKind::Edit)
3338            .content(vec!["hi".into()]);
3339        let connection =
3340            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
3341                tool_call_id,
3342                PermissionOptions::Flat(vec![acp::PermissionOption::new(
3343                    "1",
3344                    "Allow",
3345                    acp::PermissionOptionKind::AllowOnce,
3346                )]),
3347            )]));
3348
3349        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
3350
3351        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
3352
3353        let message_editor = message_editor(&thread_view, cx);
3354        message_editor.update_in(cx, |editor, window, cx| {
3355            editor.set_text("Hello", window, cx);
3356        });
3357
3358        cx.deactivate_window();
3359
3360        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3361
3362        cx.run_until_parked();
3363
3364        assert!(
3365            cx.windows()
3366                .iter()
3367                .any(|window| window.downcast::<AgentNotification>().is_some())
3368        );
3369    }
3370
3371    #[gpui::test]
3372    async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) {
3373        init_test(cx);
3374
3375        let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3376
3377        add_to_workspace(thread_view.clone(), cx);
3378
3379        let message_editor = message_editor(&thread_view, cx);
3380
3381        message_editor.update_in(cx, |editor, window, cx| {
3382            editor.set_text("Hello", window, cx);
3383        });
3384
3385        // Window is active (don't deactivate), but panel will be hidden
3386        // Note: In the test environment, the panel is not actually added to the dock,
3387        // so is_agent_panel_hidden will return true
3388
3389        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3390
3391        cx.run_until_parked();
3392
3393        // Should show notification because window is active but panel is hidden
3394        assert!(
3395            cx.windows()
3396                .iter()
3397                .any(|window| window.downcast::<AgentNotification>().is_some()),
3398            "Expected notification when panel is hidden"
3399        );
3400    }
3401
3402    #[gpui::test]
3403    async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) {
3404        init_test(cx);
3405
3406        let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3407
3408        let message_editor = message_editor(&thread_view, cx);
3409        message_editor.update_in(cx, |editor, window, cx| {
3410            editor.set_text("Hello", window, cx);
3411        });
3412
3413        // Deactivate window - should show notification regardless of setting
3414        cx.deactivate_window();
3415
3416        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3417
3418        cx.run_until_parked();
3419
3420        // Should still show notification when window is inactive (existing behavior)
3421        assert!(
3422            cx.windows()
3423                .iter()
3424                .any(|window| window.downcast::<AgentNotification>().is_some()),
3425            "Expected notification when window is inactive"
3426        );
3427    }
3428
3429    #[gpui::test]
3430    async fn test_notification_when_workspace_is_background_in_multi_workspace(
3431        cx: &mut TestAppContext,
3432    ) {
3433        init_test(cx);
3434
3435        // Enable multi-workspace feature flag and init globals needed by AgentPanel
3436        let fs = FakeFs::new(cx.executor());
3437
3438        cx.update(|cx| {
3439            cx.update_flags(true, vec!["agent-v2".to_string()]);
3440            agent::ThreadStore::init_global(cx);
3441            language_model::LanguageModelRegistry::test(cx);
3442            <dyn Fs>::set_global(fs.clone(), cx);
3443        });
3444
3445        let project1 = Project::test(fs.clone(), [], cx).await;
3446
3447        // Create a MultiWorkspace window with one workspace
3448        let multi_workspace_handle =
3449            cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
3450
3451        // Get workspace 1 (the initial workspace)
3452        let workspace1 = multi_workspace_handle
3453            .read_with(cx, |mw, _cx| mw.workspace().clone())
3454            .unwrap();
3455
3456        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3457
3458        workspace1.update_in(cx, |workspace, window, cx| {
3459            let text_thread_store =
3460                cx.new(|cx| TextThreadStore::fake(workspace.project().clone(), cx));
3461            let panel =
3462                cx.new(|cx| crate::AgentPanel::new(workspace, text_thread_store, None, window, cx));
3463            let position = panel.read(cx).position(window, cx);
3464            workspace.add_panel(panel, position, window, cx);
3465
3466            // Open the dock and activate the agent panel so it's visible
3467            workspace.focus_panel::<crate::AgentPanel>(window, cx);
3468        });
3469
3470        cx.run_until_parked();
3471
3472        cx.read(|cx| {
3473            assert!(
3474                crate::AgentPanel::is_visible(&workspace1, cx),
3475                "AgentPanel should be visible in workspace1's dock"
3476            );
3477        });
3478
3479        // Set up thread view in workspace 1
3480        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3481        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
3482
3483        let agent = StubAgentServer::default_response();
3484        let thread_view = cx.update(|window, cx| {
3485            cx.new(|cx| {
3486                ConnectionView::new(
3487                    Rc::new(agent),
3488                    None,
3489                    None,
3490                    None,
3491                    None,
3492                    workspace1.downgrade(),
3493                    project1.clone(),
3494                    Some(thread_store),
3495                    None,
3496                    history,
3497                    window,
3498                    cx,
3499                )
3500            })
3501        });
3502        cx.run_until_parked();
3503
3504        let message_editor = message_editor(&thread_view, cx);
3505        message_editor.update_in(cx, |editor, window, cx| {
3506            editor.set_text("Hello", window, cx);
3507        });
3508
3509        // Create a second workspace and switch to it.
3510        // This makes workspace1 the "background" workspace.
3511        let project2 = Project::test(fs, [], cx).await;
3512        multi_workspace_handle
3513            .update(cx, |mw, window, cx| {
3514                mw.test_add_workspace(project2, window, cx);
3515            })
3516            .unwrap();
3517
3518        cx.run_until_parked();
3519
3520        // Verify workspace1 is no longer the active workspace
3521        multi_workspace_handle
3522            .read_with(cx, |mw, _cx| {
3523                assert_eq!(mw.active_workspace_index(), 1);
3524                assert_ne!(mw.workspace(), &workspace1);
3525            })
3526            .unwrap();
3527
3528        // Window is active, agent panel is visible in workspace1, but workspace1
3529        // is in the background. The notification should show because the user
3530        // can't actually see the agent panel.
3531        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3532
3533        cx.run_until_parked();
3534
3535        assert!(
3536            cx.windows()
3537                .iter()
3538                .any(|window| window.downcast::<AgentNotification>().is_some()),
3539            "Expected notification when workspace is in background within MultiWorkspace"
3540        );
3541
3542        // Also verify: clicking "View Panel" should switch to workspace1.
3543        cx.windows()
3544            .iter()
3545            .find_map(|window| window.downcast::<AgentNotification>())
3546            .unwrap()
3547            .update(cx, |window, _, cx| window.accept(cx))
3548            .unwrap();
3549
3550        cx.run_until_parked();
3551
3552        multi_workspace_handle
3553            .read_with(cx, |mw, _cx| {
3554                assert_eq!(
3555                    mw.workspace(),
3556                    &workspace1,
3557                    "Expected workspace1 to become the active workspace after accepting notification"
3558                );
3559            })
3560            .unwrap();
3561    }
3562
3563    #[gpui::test]
3564    async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
3565        init_test(cx);
3566
3567        // Set notify_when_agent_waiting to Never
3568        cx.update(|cx| {
3569            AgentSettings::override_global(
3570                AgentSettings {
3571                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
3572                    ..AgentSettings::get_global(cx).clone()
3573                },
3574                cx,
3575            );
3576        });
3577
3578        let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3579
3580        let message_editor = message_editor(&thread_view, cx);
3581        message_editor.update_in(cx, |editor, window, cx| {
3582            editor.set_text("Hello", window, cx);
3583        });
3584
3585        // Window is active
3586
3587        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3588
3589        cx.run_until_parked();
3590
3591        // Should NOT show notification because notify_when_agent_waiting is Never
3592        assert!(
3593            !cx.windows()
3594                .iter()
3595                .any(|window| window.downcast::<AgentNotification>().is_some()),
3596            "Expected no notification when notify_when_agent_waiting is Never"
3597        );
3598    }
3599
3600    #[gpui::test]
3601    async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
3602        init_test(cx);
3603
3604        let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
3605
3606        let weak_view = thread_view.downgrade();
3607
3608        let message_editor = message_editor(&thread_view, cx);
3609        message_editor.update_in(cx, |editor, window, cx| {
3610            editor.set_text("Hello", window, cx);
3611        });
3612
3613        cx.deactivate_window();
3614
3615        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
3616
3617        cx.run_until_parked();
3618
3619        // Verify notification is shown
3620        assert!(
3621            cx.windows()
3622                .iter()
3623                .any(|window| window.downcast::<AgentNotification>().is_some()),
3624            "Expected notification to be shown"
3625        );
3626
3627        // Drop the thread view (simulating navigation to a new thread)
3628        drop(thread_view);
3629        drop(message_editor);
3630        // Trigger an update to flush effects, which will call release_dropped_entities
3631        cx.update(|_window, _cx| {});
3632        cx.run_until_parked();
3633
3634        // Verify the entity was actually released
3635        assert!(
3636            !weak_view.is_upgradable(),
3637            "Thread view entity should be released after dropping"
3638        );
3639
3640        // The notification should be automatically closed via on_release
3641        assert!(
3642            !cx.windows()
3643                .iter()
3644                .any(|window| window.downcast::<AgentNotification>().is_some()),
3645            "Notification should be closed when thread view is dropped"
3646        );
3647    }
3648
3649    async fn setup_thread_view(
3650        agent: impl AgentServer + 'static,
3651        cx: &mut TestAppContext,
3652    ) -> (Entity<ConnectionView>, &mut VisualTestContext) {
3653        let (thread_view, _history, cx) = setup_thread_view_with_history(agent, cx).await;
3654        (thread_view, cx)
3655    }
3656
3657    async fn setup_thread_view_with_history(
3658        agent: impl AgentServer + 'static,
3659        cx: &mut TestAppContext,
3660    ) -> (
3661        Entity<ConnectionView>,
3662        Entity<ThreadHistory>,
3663        &mut VisualTestContext,
3664    ) {
3665        setup_thread_view_with_history_and_initial_content(agent, None, cx).await
3666    }
3667
3668    async fn setup_thread_view_with_initial_content(
3669        agent: impl AgentServer + 'static,
3670        initial_content: AgentInitialContent,
3671        cx: &mut TestAppContext,
3672    ) -> (Entity<ConnectionView>, &mut VisualTestContext) {
3673        let (thread_view, _history, cx) =
3674            setup_thread_view_with_history_and_initial_content(agent, Some(initial_content), cx)
3675                .await;
3676        (thread_view, cx)
3677    }
3678
3679    async fn setup_thread_view_with_history_and_initial_content(
3680        agent: impl AgentServer + 'static,
3681        initial_content: Option<AgentInitialContent>,
3682        cx: &mut TestAppContext,
3683    ) -> (
3684        Entity<ConnectionView>,
3685        Entity<ThreadHistory>,
3686        &mut VisualTestContext,
3687    ) {
3688        let fs = FakeFs::new(cx.executor());
3689        let project = Project::test(fs, [], cx).await;
3690        let (multi_workspace, cx) =
3691            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3692        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3693
3694        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3695        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
3696
3697        let thread_view = cx.update(|window, cx| {
3698            cx.new(|cx| {
3699                ConnectionView::new(
3700                    Rc::new(agent),
3701                    None,
3702                    None,
3703                    None,
3704                    initial_content,
3705                    workspace.downgrade(),
3706                    project,
3707                    Some(thread_store),
3708                    None,
3709                    history.clone(),
3710                    window,
3711                    cx,
3712                )
3713            })
3714        });
3715        cx.run_until_parked();
3716        (thread_view, history, cx)
3717    }
3718
3719    fn add_to_workspace(thread_view: Entity<ConnectionView>, cx: &mut VisualTestContext) {
3720        let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
3721
3722        workspace
3723            .update_in(cx, |workspace, window, cx| {
3724                workspace.add_item_to_active_pane(
3725                    Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))),
3726                    None,
3727                    true,
3728                    window,
3729                    cx,
3730                );
3731            })
3732            .unwrap();
3733    }
3734
3735    struct ThreadViewItem(Entity<ConnectionView>);
3736
3737    impl Item for ThreadViewItem {
3738        type Event = ();
3739
3740        fn include_in_nav_history() -> bool {
3741            false
3742        }
3743
3744        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
3745            "Test".into()
3746        }
3747    }
3748
3749    impl EventEmitter<()> for ThreadViewItem {}
3750
3751    impl Focusable for ThreadViewItem {
3752        fn focus_handle(&self, cx: &App) -> FocusHandle {
3753            self.0.read(cx).focus_handle(cx)
3754        }
3755    }
3756
3757    impl Render for ThreadViewItem {
3758        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3759            self.0.clone().into_any_element()
3760        }
3761    }
3762
3763    pub(crate) struct StubAgentServer<C> {
3764        connection: C,
3765    }
3766
3767    impl<C> StubAgentServer<C> {
3768        pub(crate) fn new(connection: C) -> Self {
3769            Self { connection }
3770        }
3771    }
3772
3773    impl StubAgentServer<StubAgentConnection> {
3774        pub(crate) fn default_response() -> Self {
3775            let conn = StubAgentConnection::new();
3776            conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3777                acp::ContentChunk::new("Default response".into()),
3778            )]);
3779            Self::new(conn)
3780        }
3781    }
3782
3783    impl<C> AgentServer for StubAgentServer<C>
3784    where
3785        C: 'static + AgentConnection + Send + Clone,
3786    {
3787        fn logo(&self) -> ui::IconName {
3788            ui::IconName::Ai
3789        }
3790
3791        fn name(&self) -> SharedString {
3792            "Test".into()
3793        }
3794
3795        fn connect(
3796            &self,
3797            _delegate: AgentServerDelegate,
3798            _cx: &mut App,
3799        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3800            Task::ready(Ok(Rc::new(self.connection.clone())))
3801        }
3802
3803        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3804            self
3805        }
3806    }
3807
3808    struct FailingAgentServer;
3809
3810    impl AgentServer for FailingAgentServer {
3811        fn logo(&self) -> ui::IconName {
3812            ui::IconName::AiOpenAi
3813        }
3814
3815        fn name(&self) -> SharedString {
3816            "Codex CLI".into()
3817        }
3818
3819        fn connect(
3820            &self,
3821            _delegate: AgentServerDelegate,
3822            _cx: &mut App,
3823        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3824            Task::ready(Err(anyhow!(
3825                "extracting downloaded asset for \
3826                 https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\
3827                 codex-acp-0.9.4-aarch64-pc-windows-msvc.zip: \
3828                 failed to iterate over archive: Invalid gzip header"
3829            )))
3830        }
3831
3832        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3833            self
3834        }
3835    }
3836
3837    #[derive(Clone)]
3838    struct StubSessionList {
3839        sessions: Vec<AgentSessionInfo>,
3840    }
3841
3842    impl StubSessionList {
3843        fn new(sessions: Vec<AgentSessionInfo>) -> Self {
3844            Self { sessions }
3845        }
3846    }
3847
3848    impl AgentSessionList for StubSessionList {
3849        fn list_sessions(
3850            &self,
3851            _request: AgentSessionListRequest,
3852            _cx: &mut App,
3853        ) -> Task<anyhow::Result<AgentSessionListResponse>> {
3854            Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
3855        }
3856
3857        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3858            self
3859        }
3860    }
3861
3862    #[derive(Clone)]
3863    struct SessionHistoryConnection {
3864        sessions: Vec<AgentSessionInfo>,
3865    }
3866
3867    impl SessionHistoryConnection {
3868        fn new(sessions: Vec<AgentSessionInfo>) -> Self {
3869            Self { sessions }
3870        }
3871    }
3872
3873    fn build_test_thread(
3874        connection: Rc<dyn AgentConnection>,
3875        project: Entity<Project>,
3876        name: &'static str,
3877        session_id: SessionId,
3878        cx: &mut App,
3879    ) -> Entity<AcpThread> {
3880        let action_log = cx.new(|_| ActionLog::new(project.clone()));
3881        cx.new(|cx| {
3882            AcpThread::new(
3883                None,
3884                name,
3885                None,
3886                connection,
3887                project,
3888                action_log,
3889                session_id,
3890                watch::Receiver::constant(
3891                    acp::PromptCapabilities::new()
3892                        .image(true)
3893                        .audio(true)
3894                        .embedded_context(true),
3895                ),
3896                cx,
3897            )
3898        })
3899    }
3900
3901    impl AgentConnection for SessionHistoryConnection {
3902        fn telemetry_id(&self) -> SharedString {
3903            "history-connection".into()
3904        }
3905
3906        fn new_session(
3907            self: Rc<Self>,
3908            project: Entity<Project>,
3909            _cwd: &Path,
3910            cx: &mut App,
3911        ) -> Task<anyhow::Result<Entity<AcpThread>>> {
3912            let thread = build_test_thread(
3913                self,
3914                project,
3915                "SessionHistoryConnection",
3916                SessionId::new("history-session"),
3917                cx,
3918            );
3919            Task::ready(Ok(thread))
3920        }
3921
3922        fn supports_load_session(&self) -> bool {
3923            true
3924        }
3925
3926        fn session_list(&self, _cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
3927            Some(Rc::new(StubSessionList::new(self.sessions.clone())))
3928        }
3929
3930        fn auth_methods(&self) -> &[acp::AuthMethod] {
3931            &[]
3932        }
3933
3934        fn authenticate(
3935            &self,
3936            _method_id: acp::AuthMethodId,
3937            _cx: &mut App,
3938        ) -> Task<anyhow::Result<()>> {
3939            Task::ready(Ok(()))
3940        }
3941
3942        fn prompt(
3943            &self,
3944            _id: Option<acp_thread::UserMessageId>,
3945            _params: acp::PromptRequest,
3946            _cx: &mut App,
3947        ) -> Task<anyhow::Result<acp::PromptResponse>> {
3948            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
3949        }
3950
3951        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
3952
3953        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3954            self
3955        }
3956    }
3957
3958    #[derive(Clone)]
3959    struct ResumeOnlyAgentConnection;
3960
3961    impl AgentConnection for ResumeOnlyAgentConnection {
3962        fn telemetry_id(&self) -> SharedString {
3963            "resume-only".into()
3964        }
3965
3966        fn new_session(
3967            self: Rc<Self>,
3968            project: Entity<Project>,
3969            _cwd: &Path,
3970            cx: &mut gpui::App,
3971        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3972            let thread = build_test_thread(
3973                self,
3974                project,
3975                "ResumeOnlyAgentConnection",
3976                SessionId::new("new-session"),
3977                cx,
3978            );
3979            Task::ready(Ok(thread))
3980        }
3981
3982        fn supports_resume_session(&self) -> bool {
3983            true
3984        }
3985
3986        fn resume_session(
3987            self: Rc<Self>,
3988            session_id: acp::SessionId,
3989            project: Entity<Project>,
3990            _cwd: &Path,
3991            _title: Option<SharedString>,
3992            cx: &mut App,
3993        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3994            let thread =
3995                build_test_thread(self, project, "ResumeOnlyAgentConnection", session_id, cx);
3996            Task::ready(Ok(thread))
3997        }
3998
3999        fn auth_methods(&self) -> &[acp::AuthMethod] {
4000            &[]
4001        }
4002
4003        fn authenticate(
4004            &self,
4005            _method_id: acp::AuthMethodId,
4006            _cx: &mut App,
4007        ) -> Task<gpui::Result<()>> {
4008            Task::ready(Ok(()))
4009        }
4010
4011        fn prompt(
4012            &self,
4013            _id: Option<acp_thread::UserMessageId>,
4014            _params: acp::PromptRequest,
4015            _cx: &mut App,
4016        ) -> Task<gpui::Result<acp::PromptResponse>> {
4017            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4018        }
4019
4020        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4021
4022        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4023            self
4024        }
4025    }
4026
4027    /// Simulates an agent that requires authentication before a session can be
4028    /// created. `new_session` returns `AuthRequired` until `authenticate` is
4029    /// called with the correct method, after which sessions are created normally.
4030    #[derive(Clone)]
4031    struct AuthGatedAgentConnection {
4032        authenticated: Arc<Mutex<bool>>,
4033        auth_method: acp::AuthMethod,
4034    }
4035
4036    impl AuthGatedAgentConnection {
4037        const AUTH_METHOD_ID: &str = "test-login";
4038
4039        fn new() -> Self {
4040            Self {
4041                authenticated: Arc::new(Mutex::new(false)),
4042                auth_method: acp::AuthMethod::new(Self::AUTH_METHOD_ID, "Test Login"),
4043            }
4044        }
4045    }
4046
4047    impl AgentConnection for AuthGatedAgentConnection {
4048        fn telemetry_id(&self) -> SharedString {
4049            "auth-gated".into()
4050        }
4051
4052        fn new_session(
4053            self: Rc<Self>,
4054            project: Entity<Project>,
4055            cwd: &Path,
4056            cx: &mut gpui::App,
4057        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4058            if !*self.authenticated.lock() {
4059                return Task::ready(Err(acp_thread::AuthRequired::new()
4060                    .with_description("Sign in to continue".to_string())
4061                    .into()));
4062            }
4063
4064            let session_id = acp::SessionId::new("auth-gated-session");
4065            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4066            Task::ready(Ok(cx.new(|cx| {
4067                AcpThread::new(
4068                    None,
4069                    "AuthGatedAgent",
4070                    Some(cwd.to_path_buf()),
4071                    self,
4072                    project,
4073                    action_log,
4074                    session_id,
4075                    watch::Receiver::constant(
4076                        acp::PromptCapabilities::new()
4077                            .image(true)
4078                            .audio(true)
4079                            .embedded_context(true),
4080                    ),
4081                    cx,
4082                )
4083            })))
4084        }
4085
4086        fn auth_methods(&self) -> &[acp::AuthMethod] {
4087            std::slice::from_ref(&self.auth_method)
4088        }
4089
4090        fn authenticate(
4091            &self,
4092            method_id: acp::AuthMethodId,
4093            _cx: &mut App,
4094        ) -> Task<gpui::Result<()>> {
4095            if method_id == self.auth_method.id {
4096                *self.authenticated.lock() = true;
4097                Task::ready(Ok(()))
4098            } else {
4099                Task::ready(Err(anyhow::anyhow!("Unknown auth method")))
4100            }
4101        }
4102
4103        fn prompt(
4104            &self,
4105            _id: Option<acp_thread::UserMessageId>,
4106            _params: acp::PromptRequest,
4107            _cx: &mut App,
4108        ) -> Task<gpui::Result<acp::PromptResponse>> {
4109            unimplemented!()
4110        }
4111
4112        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4113            unimplemented!()
4114        }
4115
4116        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4117            self
4118        }
4119    }
4120
4121    #[derive(Clone)]
4122    struct SaboteurAgentConnection;
4123
4124    impl AgentConnection for SaboteurAgentConnection {
4125        fn telemetry_id(&self) -> SharedString {
4126            "saboteur".into()
4127        }
4128
4129        fn new_session(
4130            self: Rc<Self>,
4131            project: Entity<Project>,
4132            cwd: &Path,
4133            cx: &mut gpui::App,
4134        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4135            Task::ready(Ok(cx.new(|cx| {
4136                let action_log = cx.new(|_| ActionLog::new(project.clone()));
4137                AcpThread::new(
4138                    None,
4139                    "SaboteurAgentConnection",
4140                    Some(cwd.to_path_buf()),
4141                    self,
4142                    project,
4143                    action_log,
4144                    SessionId::new("test"),
4145                    watch::Receiver::constant(
4146                        acp::PromptCapabilities::new()
4147                            .image(true)
4148                            .audio(true)
4149                            .embedded_context(true),
4150                    ),
4151                    cx,
4152                )
4153            })))
4154        }
4155
4156        fn auth_methods(&self) -> &[acp::AuthMethod] {
4157            &[]
4158        }
4159
4160        fn authenticate(
4161            &self,
4162            _method_id: acp::AuthMethodId,
4163            _cx: &mut App,
4164        ) -> Task<gpui::Result<()>> {
4165            unimplemented!()
4166        }
4167
4168        fn prompt(
4169            &self,
4170            _id: Option<acp_thread::UserMessageId>,
4171            _params: acp::PromptRequest,
4172            _cx: &mut App,
4173        ) -> Task<gpui::Result<acp::PromptResponse>> {
4174            Task::ready(Err(anyhow::anyhow!("Error prompting")))
4175        }
4176
4177        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4178            unimplemented!()
4179        }
4180
4181        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4182            self
4183        }
4184    }
4185
4186    /// Simulates a model which always returns a refusal response
4187    #[derive(Clone)]
4188    struct RefusalAgentConnection;
4189
4190    impl AgentConnection for RefusalAgentConnection {
4191        fn telemetry_id(&self) -> SharedString {
4192            "refusal".into()
4193        }
4194
4195        fn new_session(
4196            self: Rc<Self>,
4197            project: Entity<Project>,
4198            cwd: &Path,
4199            cx: &mut gpui::App,
4200        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4201            Task::ready(Ok(cx.new(|cx| {
4202                let action_log = cx.new(|_| ActionLog::new(project.clone()));
4203                AcpThread::new(
4204                    None,
4205                    "RefusalAgentConnection",
4206                    Some(cwd.to_path_buf()),
4207                    self,
4208                    project,
4209                    action_log,
4210                    SessionId::new("test"),
4211                    watch::Receiver::constant(
4212                        acp::PromptCapabilities::new()
4213                            .image(true)
4214                            .audio(true)
4215                            .embedded_context(true),
4216                    ),
4217                    cx,
4218                )
4219            })))
4220        }
4221
4222        fn auth_methods(&self) -> &[acp::AuthMethod] {
4223            &[]
4224        }
4225
4226        fn authenticate(
4227            &self,
4228            _method_id: acp::AuthMethodId,
4229            _cx: &mut App,
4230        ) -> Task<gpui::Result<()>> {
4231            unimplemented!()
4232        }
4233
4234        fn prompt(
4235            &self,
4236            _id: Option<acp_thread::UserMessageId>,
4237            _params: acp::PromptRequest,
4238            _cx: &mut App,
4239        ) -> Task<gpui::Result<acp::PromptResponse>> {
4240            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal)))
4241        }
4242
4243        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4244            unimplemented!()
4245        }
4246
4247        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4248            self
4249        }
4250    }
4251
4252    #[derive(Clone)]
4253    struct CwdCapturingConnection {
4254        captured_cwd: Arc<Mutex<Option<PathBuf>>>,
4255    }
4256
4257    impl CwdCapturingConnection {
4258        fn new() -> Self {
4259            Self {
4260                captured_cwd: Arc::new(Mutex::new(None)),
4261            }
4262        }
4263    }
4264
4265    impl AgentConnection for CwdCapturingConnection {
4266        fn telemetry_id(&self) -> SharedString {
4267            "cwd-capturing".into()
4268        }
4269
4270        fn new_session(
4271            self: Rc<Self>,
4272            project: Entity<Project>,
4273            cwd: &Path,
4274            cx: &mut gpui::App,
4275        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4276            *self.captured_cwd.lock() = Some(cwd.to_path_buf());
4277            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4278            let thread = cx.new(|cx| {
4279                AcpThread::new(
4280                    None,
4281                    "CwdCapturingConnection",
4282                    Some(cwd.to_path_buf()),
4283                    self.clone(),
4284                    project,
4285                    action_log,
4286                    SessionId::new("new-session"),
4287                    watch::Receiver::constant(
4288                        acp::PromptCapabilities::new()
4289                            .image(true)
4290                            .audio(true)
4291                            .embedded_context(true),
4292                    ),
4293                    cx,
4294                )
4295            });
4296            Task::ready(Ok(thread))
4297        }
4298
4299        fn supports_load_session(&self) -> bool {
4300            true
4301        }
4302
4303        fn load_session(
4304            self: Rc<Self>,
4305            session_id: acp::SessionId,
4306            project: Entity<Project>,
4307            cwd: &Path,
4308            _title: Option<SharedString>,
4309            cx: &mut App,
4310        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4311            *self.captured_cwd.lock() = Some(cwd.to_path_buf());
4312            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4313            let thread = cx.new(|cx| {
4314                AcpThread::new(
4315                    None,
4316                    "CwdCapturingConnection",
4317                    Some(cwd.to_path_buf()),
4318                    self.clone(),
4319                    project,
4320                    action_log,
4321                    session_id,
4322                    watch::Receiver::constant(
4323                        acp::PromptCapabilities::new()
4324                            .image(true)
4325                            .audio(true)
4326                            .embedded_context(true),
4327                    ),
4328                    cx,
4329                )
4330            });
4331            Task::ready(Ok(thread))
4332        }
4333
4334        fn auth_methods(&self) -> &[acp::AuthMethod] {
4335            &[]
4336        }
4337
4338        fn authenticate(
4339            &self,
4340            _method_id: acp::AuthMethodId,
4341            _cx: &mut App,
4342        ) -> Task<gpui::Result<()>> {
4343            Task::ready(Ok(()))
4344        }
4345
4346        fn prompt(
4347            &self,
4348            _id: Option<acp_thread::UserMessageId>,
4349            _params: acp::PromptRequest,
4350            _cx: &mut App,
4351        ) -> Task<gpui::Result<acp::PromptResponse>> {
4352            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4353        }
4354
4355        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4356
4357        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4358            self
4359        }
4360    }
4361
4362    pub(crate) fn init_test(cx: &mut TestAppContext) {
4363        cx.update(|cx| {
4364            let settings_store = SettingsStore::test(cx);
4365            cx.set_global(settings_store);
4366            theme::init(theme::LoadThemes::JustBase, cx);
4367            editor::init(cx);
4368            agent_panel::init(cx);
4369            release_channel::init(semver::Version::new(0, 0, 0), cx);
4370            prompt_store::init(cx)
4371        });
4372    }
4373
4374    fn active_thread(
4375        thread_view: &Entity<ConnectionView>,
4376        cx: &TestAppContext,
4377    ) -> Entity<ThreadView> {
4378        cx.read(|cx| {
4379            thread_view
4380                .read(cx)
4381                .active_thread()
4382                .expect("No active thread")
4383                .clone()
4384        })
4385    }
4386
4387    fn message_editor(
4388        thread_view: &Entity<ConnectionView>,
4389        cx: &TestAppContext,
4390    ) -> Entity<MessageEditor> {
4391        let thread = active_thread(thread_view, cx);
4392        cx.read(|cx| thread.read(cx).message_editor.clone())
4393    }
4394
4395    #[gpui::test]
4396    async fn test_rewind_views(cx: &mut TestAppContext) {
4397        init_test(cx);
4398
4399        let fs = FakeFs::new(cx.executor());
4400        fs.insert_tree(
4401            "/project",
4402            json!({
4403                "test1.txt": "old content 1",
4404                "test2.txt": "old content 2"
4405            }),
4406        )
4407        .await;
4408        let project = Project::test(fs, [Path::new("/project")], cx).await;
4409        let (multi_workspace, cx) =
4410            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4411        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4412
4413        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4414        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
4415
4416        let connection = Rc::new(StubAgentConnection::new());
4417        let thread_view = cx.update(|window, cx| {
4418            cx.new(|cx| {
4419                ConnectionView::new(
4420                    Rc::new(StubAgentServer::new(connection.as_ref().clone())),
4421                    None,
4422                    None,
4423                    None,
4424                    None,
4425                    workspace.downgrade(),
4426                    project.clone(),
4427                    Some(thread_store.clone()),
4428                    None,
4429                    history,
4430                    window,
4431                    cx,
4432                )
4433            })
4434        });
4435
4436        cx.run_until_parked();
4437
4438        let thread = thread_view
4439            .read_with(cx, |view, cx| {
4440                view.active_thread().map(|r| r.read(cx).thread.clone())
4441            })
4442            .unwrap();
4443
4444        // First user message
4445        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4446            acp::ToolCall::new("tool1", "Edit file 1")
4447                .kind(acp::ToolKind::Edit)
4448                .status(acp::ToolCallStatus::Completed)
4449                .content(vec![acp::ToolCallContent::Diff(
4450                    acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"),
4451                )]),
4452        )]);
4453
4454        thread
4455            .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
4456            .await
4457            .unwrap();
4458        cx.run_until_parked();
4459
4460        thread.read_with(cx, |thread, _cx| {
4461            assert_eq!(thread.entries().len(), 2);
4462        });
4463
4464        thread_view.read_with(cx, |view, cx| {
4465            let entry_view_state = view
4466                .active_thread()
4467                .map(|active| active.read(cx).entry_view_state.clone())
4468                .unwrap();
4469            entry_view_state.read_with(cx, |entry_view_state, _| {
4470                assert!(
4471                    entry_view_state
4472                        .entry(0)
4473                        .unwrap()
4474                        .message_editor()
4475                        .is_some()
4476                );
4477                assert!(entry_view_state.entry(1).unwrap().has_content());
4478            });
4479        });
4480
4481        // Second user message
4482        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4483            acp::ToolCall::new("tool2", "Edit file 2")
4484                .kind(acp::ToolKind::Edit)
4485                .status(acp::ToolCallStatus::Completed)
4486                .content(vec![acp::ToolCallContent::Diff(
4487                    acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"),
4488                )]),
4489        )]);
4490
4491        thread
4492            .update(cx, |thread, cx| thread.send_raw("Another one", cx))
4493            .await
4494            .unwrap();
4495        cx.run_until_parked();
4496
4497        let second_user_message_id = thread.read_with(cx, |thread, _| {
4498            assert_eq!(thread.entries().len(), 4);
4499            let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
4500                panic!();
4501            };
4502            user_message.id.clone().unwrap()
4503        });
4504
4505        thread_view.read_with(cx, |view, cx| {
4506            let entry_view_state = view
4507                .active_thread()
4508                .unwrap()
4509                .read(cx)
4510                .entry_view_state
4511                .clone();
4512            entry_view_state.read_with(cx, |entry_view_state, _| {
4513                assert!(
4514                    entry_view_state
4515                        .entry(0)
4516                        .unwrap()
4517                        .message_editor()
4518                        .is_some()
4519                );
4520                assert!(entry_view_state.entry(1).unwrap().has_content());
4521                assert!(
4522                    entry_view_state
4523                        .entry(2)
4524                        .unwrap()
4525                        .message_editor()
4526                        .is_some()
4527                );
4528                assert!(entry_view_state.entry(3).unwrap().has_content());
4529            });
4530        });
4531
4532        // Rewind to first message
4533        thread
4534            .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
4535            .await
4536            .unwrap();
4537
4538        cx.run_until_parked();
4539
4540        thread.read_with(cx, |thread, _| {
4541            assert_eq!(thread.entries().len(), 2);
4542        });
4543
4544        thread_view.read_with(cx, |view, cx| {
4545            let active = view.active_thread().unwrap();
4546            active
4547                .read(cx)
4548                .entry_view_state
4549                .read_with(cx, |entry_view_state, _| {
4550                    assert!(
4551                        entry_view_state
4552                            .entry(0)
4553                            .unwrap()
4554                            .message_editor()
4555                            .is_some()
4556                    );
4557                    assert!(entry_view_state.entry(1).unwrap().has_content());
4558
4559                    // Old views should be dropped
4560                    assert!(entry_view_state.entry(2).is_none());
4561                    assert!(entry_view_state.entry(3).is_none());
4562                });
4563        });
4564    }
4565
4566    #[gpui::test]
4567    async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
4568        init_test(cx);
4569
4570        let connection = StubAgentConnection::new();
4571
4572        // Each user prompt will result in a user message entry plus an agent message entry.
4573        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4574            acp::ContentChunk::new("Response 1".into()),
4575        )]);
4576
4577        let (thread_view, cx) =
4578            setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4579
4580        let thread = thread_view
4581            .read_with(cx, |view, cx| {
4582                view.active_thread().map(|r| r.read(cx).thread.clone())
4583            })
4584            .unwrap();
4585
4586        thread
4587            .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
4588            .await
4589            .unwrap();
4590        cx.run_until_parked();
4591
4592        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4593            acp::ContentChunk::new("Response 2".into()),
4594        )]);
4595
4596        thread
4597            .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
4598            .await
4599            .unwrap();
4600        cx.run_until_parked();
4601
4602        // Move somewhere else first so we're not trivially already on the last user prompt.
4603        active_thread(&thread_view, cx).update(cx, |view, cx| {
4604            view.scroll_to_top(cx);
4605        });
4606        cx.run_until_parked();
4607
4608        active_thread(&thread_view, cx).update(cx, |view, cx| {
4609            view.scroll_to_most_recent_user_prompt(cx);
4610            let scroll_top = view.list_state.logical_scroll_top();
4611            // Entries layout is: [User1, Assistant1, User2, Assistant2]
4612            assert_eq!(scroll_top.item_ix, 2);
4613        });
4614    }
4615
4616    #[gpui::test]
4617    async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
4618        cx: &mut TestAppContext,
4619    ) {
4620        init_test(cx);
4621
4622        let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
4623
4624        // With no entries, scrolling should be a no-op and must not panic.
4625        active_thread(&thread_view, cx).update(cx, |view, cx| {
4626            view.scroll_to_most_recent_user_prompt(cx);
4627            let scroll_top = view.list_state.logical_scroll_top();
4628            assert_eq!(scroll_top.item_ix, 0);
4629        });
4630    }
4631
4632    #[gpui::test]
4633    async fn test_message_editing_cancel(cx: &mut TestAppContext) {
4634        init_test(cx);
4635
4636        let connection = StubAgentConnection::new();
4637
4638        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4639            acp::ContentChunk::new("Response".into()),
4640        )]);
4641
4642        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4643        add_to_workspace(thread_view.clone(), cx);
4644
4645        let message_editor = message_editor(&thread_view, cx);
4646        message_editor.update_in(cx, |editor, window, cx| {
4647            editor.set_text("Original message to edit", window, cx);
4648        });
4649        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4650
4651        cx.run_until_parked();
4652
4653        let user_message_editor = thread_view.read_with(cx, |view, cx| {
4654            assert_eq!(
4655                view.active_thread()
4656                    .and_then(|active| active.read(cx).editing_message),
4657                None
4658            );
4659
4660            view.active_thread()
4661                .map(|active| &active.read(cx).entry_view_state)
4662                .as_ref()
4663                .unwrap()
4664                .read(cx)
4665                .entry(0)
4666                .unwrap()
4667                .message_editor()
4668                .unwrap()
4669                .clone()
4670        });
4671
4672        // Focus
4673        cx.focus(&user_message_editor);
4674        thread_view.read_with(cx, |view, cx| {
4675            assert_eq!(
4676                view.active_thread()
4677                    .and_then(|active| active.read(cx).editing_message),
4678                Some(0)
4679            );
4680        });
4681
4682        // Edit
4683        user_message_editor.update_in(cx, |editor, window, cx| {
4684            editor.set_text("Edited message content", window, cx);
4685        });
4686
4687        // Cancel
4688        user_message_editor.update_in(cx, |_editor, window, cx| {
4689            window.dispatch_action(Box::new(editor::actions::Cancel), cx);
4690        });
4691
4692        thread_view.read_with(cx, |view, cx| {
4693            assert_eq!(
4694                view.active_thread()
4695                    .and_then(|active| active.read(cx).editing_message),
4696                None
4697            );
4698        });
4699
4700        user_message_editor.read_with(cx, |editor, cx| {
4701            assert_eq!(editor.text(cx), "Original message to edit");
4702        });
4703    }
4704
4705    #[gpui::test]
4706    async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
4707        init_test(cx);
4708
4709        let connection = StubAgentConnection::new();
4710
4711        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
4712        add_to_workspace(thread_view.clone(), cx);
4713
4714        let message_editor = message_editor(&thread_view, cx);
4715        message_editor.update_in(cx, |editor, window, cx| {
4716            editor.set_text("", window, cx);
4717        });
4718
4719        let thread = cx.read(|cx| {
4720            thread_view
4721                .read(cx)
4722                .active_thread()
4723                .unwrap()
4724                .read(cx)
4725                .thread
4726                .clone()
4727        });
4728        let entries_before = cx.read(|cx| thread.read(cx).entries().len());
4729
4730        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
4731            view.send(window, cx);
4732        });
4733        cx.run_until_parked();
4734
4735        let entries_after = cx.read(|cx| thread.read(cx).entries().len());
4736        assert_eq!(
4737            entries_before, entries_after,
4738            "No message should be sent when editor is empty"
4739        );
4740    }
4741
4742    #[gpui::test]
4743    async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
4744        init_test(cx);
4745
4746        let connection = StubAgentConnection::new();
4747
4748        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4749            acp::ContentChunk::new("Response".into()),
4750        )]);
4751
4752        let (thread_view, cx) =
4753            setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4754        add_to_workspace(thread_view.clone(), cx);
4755
4756        let message_editor = message_editor(&thread_view, cx);
4757        message_editor.update_in(cx, |editor, window, cx| {
4758            editor.set_text("Original message to edit", window, cx);
4759        });
4760        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4761
4762        cx.run_until_parked();
4763
4764        let user_message_editor = thread_view.read_with(cx, |view, cx| {
4765            assert_eq!(
4766                view.active_thread()
4767                    .and_then(|active| active.read(cx).editing_message),
4768                None
4769            );
4770            assert_eq!(
4771                view.active_thread()
4772                    .unwrap()
4773                    .read(cx)
4774                    .thread
4775                    .read(cx)
4776                    .entries()
4777                    .len(),
4778                2
4779            );
4780
4781            view.active_thread()
4782                .map(|active| &active.read(cx).entry_view_state)
4783                .as_ref()
4784                .unwrap()
4785                .read(cx)
4786                .entry(0)
4787                .unwrap()
4788                .message_editor()
4789                .unwrap()
4790                .clone()
4791        });
4792
4793        // Focus
4794        cx.focus(&user_message_editor);
4795
4796        // Edit
4797        user_message_editor.update_in(cx, |editor, window, cx| {
4798            editor.set_text("Edited message content", window, cx);
4799        });
4800
4801        // Send
4802        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4803            acp::ContentChunk::new("New Response".into()),
4804        )]);
4805
4806        user_message_editor.update_in(cx, |_editor, window, cx| {
4807            window.dispatch_action(Box::new(Chat), cx);
4808        });
4809
4810        cx.run_until_parked();
4811
4812        thread_view.read_with(cx, |view, cx| {
4813            assert_eq!(
4814                view.active_thread()
4815                    .and_then(|active| active.read(cx).editing_message),
4816                None
4817            );
4818
4819            let entries = view
4820                .active_thread()
4821                .unwrap()
4822                .read(cx)
4823                .thread
4824                .read(cx)
4825                .entries();
4826            assert_eq!(entries.len(), 2);
4827            assert_eq!(
4828                entries[0].to_markdown(cx),
4829                "## User\n\nEdited message content\n\n"
4830            );
4831            assert_eq!(
4832                entries[1].to_markdown(cx),
4833                "## Assistant\n\nNew Response\n\n"
4834            );
4835
4836            let entry_view_state = view
4837                .active_thread()
4838                .map(|active| &active.read(cx).entry_view_state)
4839                .unwrap();
4840            let new_editor = entry_view_state.read_with(cx, |state, _cx| {
4841                assert!(!state.entry(1).unwrap().has_content());
4842                state.entry(0).unwrap().message_editor().unwrap().clone()
4843            });
4844
4845            assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
4846        })
4847    }
4848
4849    #[gpui::test]
4850    async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
4851        init_test(cx);
4852
4853        let connection = StubAgentConnection::new();
4854
4855        let (thread_view, cx) =
4856            setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4857        add_to_workspace(thread_view.clone(), cx);
4858
4859        let message_editor = message_editor(&thread_view, cx);
4860        message_editor.update_in(cx, |editor, window, cx| {
4861            editor.set_text("Original message to edit", window, cx);
4862        });
4863        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4864
4865        cx.run_until_parked();
4866
4867        let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
4868            let thread = view.active_thread().unwrap().read(cx).thread.read(cx);
4869            assert_eq!(thread.entries().len(), 1);
4870
4871            let editor = view
4872                .active_thread()
4873                .map(|active| &active.read(cx).entry_view_state)
4874                .as_ref()
4875                .unwrap()
4876                .read(cx)
4877                .entry(0)
4878                .unwrap()
4879                .message_editor()
4880                .unwrap()
4881                .clone();
4882
4883            (editor, thread.session_id().clone())
4884        });
4885
4886        // Focus
4887        cx.focus(&user_message_editor);
4888
4889        thread_view.read_with(cx, |view, cx| {
4890            assert_eq!(
4891                view.active_thread()
4892                    .and_then(|active| active.read(cx).editing_message),
4893                Some(0)
4894            );
4895        });
4896
4897        // Edit
4898        user_message_editor.update_in(cx, |editor, window, cx| {
4899            editor.set_text("Edited message content", window, cx);
4900        });
4901
4902        thread_view.read_with(cx, |view, cx| {
4903            assert_eq!(
4904                view.active_thread()
4905                    .and_then(|active| active.read(cx).editing_message),
4906                Some(0)
4907            );
4908        });
4909
4910        // Finish streaming response
4911        cx.update(|_, cx| {
4912            connection.send_update(
4913                session_id.clone(),
4914                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())),
4915                cx,
4916            );
4917            connection.end_turn(session_id, acp::StopReason::EndTurn);
4918        });
4919
4920        thread_view.read_with(cx, |view, cx| {
4921            assert_eq!(
4922                view.active_thread()
4923                    .and_then(|active| active.read(cx).editing_message),
4924                Some(0)
4925            );
4926        });
4927
4928        cx.run_until_parked();
4929
4930        // Should still be editing
4931        cx.update(|window, cx| {
4932            assert!(user_message_editor.focus_handle(cx).is_focused(window));
4933            assert_eq!(
4934                thread_view
4935                    .read(cx)
4936                    .active_thread()
4937                    .and_then(|active| active.read(cx).editing_message),
4938                Some(0)
4939            );
4940            assert_eq!(
4941                user_message_editor.read(cx).text(cx),
4942                "Edited message content"
4943            );
4944        });
4945    }
4946
4947    struct GeneratingThreadSetup {
4948        thread_view: Entity<ConnectionView>,
4949        thread: Entity<AcpThread>,
4950        message_editor: Entity<MessageEditor>,
4951    }
4952
4953    async fn setup_generating_thread(
4954        cx: &mut TestAppContext,
4955    ) -> (GeneratingThreadSetup, &mut VisualTestContext) {
4956        let connection = StubAgentConnection::new();
4957
4958        let (thread_view, cx) =
4959            setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
4960        add_to_workspace(thread_view.clone(), cx);
4961
4962        let message_editor = message_editor(&thread_view, cx);
4963        message_editor.update_in(cx, |editor, window, cx| {
4964            editor.set_text("Hello", window, cx);
4965        });
4966        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
4967
4968        let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
4969            let thread = view
4970                .active_thread()
4971                .as_ref()
4972                .unwrap()
4973                .read(cx)
4974                .thread
4975                .clone();
4976            (thread.clone(), thread.read(cx).session_id().clone())
4977        });
4978
4979        cx.run_until_parked();
4980
4981        cx.update(|_, cx| {
4982            connection.send_update(
4983                session_id.clone(),
4984                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
4985                    "Response chunk".into(),
4986                )),
4987                cx,
4988            );
4989        });
4990
4991        cx.run_until_parked();
4992
4993        thread.read_with(cx, |thread, _cx| {
4994            assert_eq!(thread.status(), ThreadStatus::Generating);
4995        });
4996
4997        (
4998            GeneratingThreadSetup {
4999                thread_view,
5000                thread,
5001                message_editor,
5002            },
5003            cx,
5004        )
5005    }
5006
5007    #[gpui::test]
5008    async fn test_escape_cancels_generation_from_conversation_focus(cx: &mut TestAppContext) {
5009        init_test(cx);
5010
5011        let (setup, cx) = setup_generating_thread(cx).await;
5012
5013        let focus_handle = setup
5014            .thread_view
5015            .read_with(cx, |view, cx| view.focus_handle(cx));
5016        cx.update(|window, cx| {
5017            window.focus(&focus_handle, cx);
5018        });
5019
5020        setup.thread_view.update_in(cx, |_, window, cx| {
5021            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
5022        });
5023
5024        cx.run_until_parked();
5025
5026        setup.thread.read_with(cx, |thread, _cx| {
5027            assert_eq!(thread.status(), ThreadStatus::Idle);
5028        });
5029    }
5030
5031    #[gpui::test]
5032    async fn test_escape_cancels_generation_from_editor_focus(cx: &mut TestAppContext) {
5033        init_test(cx);
5034
5035        let (setup, cx) = setup_generating_thread(cx).await;
5036
5037        let editor_focus_handle = setup
5038            .message_editor
5039            .read_with(cx, |editor, cx| editor.focus_handle(cx));
5040        cx.update(|window, cx| {
5041            window.focus(&editor_focus_handle, cx);
5042        });
5043
5044        setup.message_editor.update_in(cx, |_, window, cx| {
5045            window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx);
5046        });
5047
5048        cx.run_until_parked();
5049
5050        setup.thread.read_with(cx, |thread, _cx| {
5051            assert_eq!(thread.status(), ThreadStatus::Idle);
5052        });
5053    }
5054
5055    #[gpui::test]
5056    async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) {
5057        init_test(cx);
5058
5059        let (thread_view, cx) =
5060            setup_thread_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
5061        add_to_workspace(thread_view.clone(), cx);
5062
5063        let thread = thread_view.read_with(cx, |view, cx| {
5064            view.active_thread().unwrap().read(cx).thread.clone()
5065        });
5066
5067        thread.read_with(cx, |thread, _cx| {
5068            assert_eq!(thread.status(), ThreadStatus::Idle);
5069        });
5070
5071        let focus_handle = thread_view.read_with(cx, |view, _cx| view.focus_handle.clone());
5072        cx.update(|window, cx| {
5073            window.focus(&focus_handle, cx);
5074        });
5075
5076        thread_view.update_in(cx, |_, window, cx| {
5077            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
5078        });
5079
5080        cx.run_until_parked();
5081
5082        thread.read_with(cx, |thread, _cx| {
5083            assert_eq!(thread.status(), ThreadStatus::Idle);
5084        });
5085    }
5086
5087    #[gpui::test]
5088    async fn test_interrupt(cx: &mut TestAppContext) {
5089        init_test(cx);
5090
5091        let connection = StubAgentConnection::new();
5092
5093        let (thread_view, cx) =
5094            setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
5095        add_to_workspace(thread_view.clone(), cx);
5096
5097        let message_editor = message_editor(&thread_view, cx);
5098        message_editor.update_in(cx, |editor, window, cx| {
5099            editor.set_text("Message 1", window, cx);
5100        });
5101        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5102
5103        let (thread, session_id) = thread_view.read_with(cx, |view, cx| {
5104            let thread = view.active_thread().unwrap().read(cx).thread.clone();
5105
5106            (thread.clone(), thread.read(cx).session_id().clone())
5107        });
5108
5109        cx.run_until_parked();
5110
5111        cx.update(|_, cx| {
5112            connection.send_update(
5113                session_id.clone(),
5114                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5115                    "Message 1 resp".into(),
5116                )),
5117                cx,
5118            );
5119        });
5120
5121        cx.run_until_parked();
5122
5123        thread.read_with(cx, |thread, cx| {
5124            assert_eq!(
5125                thread.to_markdown(cx),
5126                indoc::indoc! {"
5127                        ## User
5128
5129                        Message 1
5130
5131                        ## Assistant
5132
5133                        Message 1 resp
5134
5135                    "}
5136            )
5137        });
5138
5139        message_editor.update_in(cx, |editor, window, cx| {
5140            editor.set_text("Message 2", window, cx);
5141        });
5142        active_thread(&thread_view, cx)
5143            .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx));
5144
5145        cx.update(|_, cx| {
5146            // Simulate a response sent after beginning to cancel
5147            connection.send_update(
5148                session_id.clone(),
5149                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())),
5150                cx,
5151            );
5152        });
5153
5154        cx.run_until_parked();
5155
5156        // Last Message 1 response should appear before Message 2
5157        thread.read_with(cx, |thread, cx| {
5158            assert_eq!(
5159                thread.to_markdown(cx),
5160                indoc::indoc! {"
5161                        ## User
5162
5163                        Message 1
5164
5165                        ## Assistant
5166
5167                        Message 1 response
5168
5169                        ## User
5170
5171                        Message 2
5172
5173                    "}
5174            )
5175        });
5176
5177        cx.update(|_, cx| {
5178            connection.send_update(
5179                session_id.clone(),
5180                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5181                    "Message 2 response".into(),
5182                )),
5183                cx,
5184            );
5185            connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5186        });
5187
5188        cx.run_until_parked();
5189
5190        thread.read_with(cx, |thread, cx| {
5191            assert_eq!(
5192                thread.to_markdown(cx),
5193                indoc::indoc! {"
5194                        ## User
5195
5196                        Message 1
5197
5198                        ## Assistant
5199
5200                        Message 1 response
5201
5202                        ## User
5203
5204                        Message 2
5205
5206                        ## Assistant
5207
5208                        Message 2 response
5209
5210                    "}
5211            )
5212        });
5213    }
5214
5215    #[gpui::test]
5216    async fn test_message_editing_insert_selections(cx: &mut TestAppContext) {
5217        init_test(cx);
5218
5219        let connection = StubAgentConnection::new();
5220        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5221            acp::ContentChunk::new("Response".into()),
5222        )]);
5223
5224        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5225        add_to_workspace(thread_view.clone(), cx);
5226
5227        let message_editor = message_editor(&thread_view, cx);
5228        message_editor.update_in(cx, |editor, window, cx| {
5229            editor.set_text("Original message to edit", window, cx)
5230        });
5231        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5232        cx.run_until_parked();
5233
5234        let user_message_editor = thread_view.read_with(cx, |thread_view, cx| {
5235            thread_view
5236                .active_thread()
5237                .map(|active| &active.read(cx).entry_view_state)
5238                .as_ref()
5239                .unwrap()
5240                .read(cx)
5241                .entry(0)
5242                .expect("Should have at least one entry")
5243                .message_editor()
5244                .expect("Should have message editor")
5245                .clone()
5246        });
5247
5248        cx.focus(&user_message_editor);
5249        thread_view.read_with(cx, |view, cx| {
5250            assert_eq!(
5251                view.active_thread()
5252                    .and_then(|active| active.read(cx).editing_message),
5253                Some(0)
5254            );
5255        });
5256
5257        // Ensure to edit the focused message before proceeding otherwise, since
5258        // its content is not different from what was sent, focus will be lost.
5259        user_message_editor.update_in(cx, |editor, window, cx| {
5260            editor.set_text("Original message to edit with ", window, cx)
5261        });
5262
5263        // Create a simple buffer with some text so we can create a selection
5264        // that will then be added to the message being edited.
5265        let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| {
5266            (thread_view.workspace.clone(), thread_view.project.clone())
5267        });
5268        let buffer = project.update(cx, |project, cx| {
5269            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5270        });
5271
5272        workspace
5273            .update_in(cx, |workspace, window, cx| {
5274                let editor = cx.new(|cx| {
5275                    let mut editor =
5276                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5277
5278                    editor.change_selections(Default::default(), window, cx, |selections| {
5279                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5280                    });
5281
5282                    editor
5283                });
5284                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5285            })
5286            .unwrap();
5287
5288        thread_view.update_in(cx, |view, window, cx| {
5289            assert_eq!(
5290                view.active_thread()
5291                    .and_then(|active| active.read(cx).editing_message),
5292                Some(0)
5293            );
5294            view.insert_selections(window, cx);
5295        });
5296
5297        user_message_editor.read_with(cx, |editor, cx| {
5298            let text = editor.editor().read(cx).text(cx);
5299            let expected_text = String::from("Original message to edit with selection ");
5300
5301            assert_eq!(text, expected_text);
5302        });
5303    }
5304
5305    #[gpui::test]
5306    async fn test_insert_selections(cx: &mut TestAppContext) {
5307        init_test(cx);
5308
5309        let connection = StubAgentConnection::new();
5310        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5311            acp::ContentChunk::new("Response".into()),
5312        )]);
5313
5314        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5315        add_to_workspace(thread_view.clone(), cx);
5316
5317        let message_editor = message_editor(&thread_view, cx);
5318        message_editor.update_in(cx, |editor, window, cx| {
5319            editor.set_text("Can you review this snippet ", window, cx)
5320        });
5321
5322        // Create a simple buffer with some text so we can create a selection
5323        // that will then be added to the message being edited.
5324        let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| {
5325            (thread_view.workspace.clone(), thread_view.project.clone())
5326        });
5327        let buffer = project.update(cx, |project, cx| {
5328            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5329        });
5330
5331        workspace
5332            .update_in(cx, |workspace, window, cx| {
5333                let editor = cx.new(|cx| {
5334                    let mut editor =
5335                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5336
5337                    editor.change_selections(Default::default(), window, cx, |selections| {
5338                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5339                    });
5340
5341                    editor
5342                });
5343                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5344            })
5345            .unwrap();
5346
5347        thread_view.update_in(cx, |view, window, cx| {
5348            assert_eq!(
5349                view.active_thread()
5350                    .and_then(|active| active.read(cx).editing_message),
5351                None
5352            );
5353            view.insert_selections(window, cx);
5354        });
5355
5356        message_editor.read_with(cx, |editor, cx| {
5357            let text = editor.text(cx);
5358            let expected_txt = String::from("Can you review this snippet selection ");
5359
5360            assert_eq!(text, expected_txt);
5361        })
5362    }
5363
5364    #[gpui::test]
5365    async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
5366        init_test(cx);
5367
5368        let tool_call_id = acp::ToolCallId::new("terminal-1");
5369        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`")
5370            .kind(acp::ToolKind::Edit);
5371
5372        let permission_options = ToolPermissionContext::new(
5373            TerminalTool::NAME,
5374            vec!["cargo build --release".to_string()],
5375        )
5376        .build_permission_options();
5377
5378        let connection =
5379            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5380                tool_call_id.clone(),
5381                permission_options,
5382            )]));
5383
5384        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5385
5386        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5387
5388        // Disable notifications to avoid popup windows
5389        cx.update(|_window, cx| {
5390            AgentSettings::override_global(
5391                AgentSettings {
5392                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5393                    ..AgentSettings::get_global(cx).clone()
5394                },
5395                cx,
5396            );
5397        });
5398
5399        let message_editor = message_editor(&thread_view, cx);
5400        message_editor.update_in(cx, |editor, window, cx| {
5401            editor.set_text("Run cargo build", window, cx);
5402        });
5403
5404        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5405
5406        cx.run_until_parked();
5407
5408        // Verify the tool call is in WaitingForConfirmation state with the expected options
5409        thread_view.read_with(cx, |thread_view, cx| {
5410            let thread = thread_view
5411                .active_thread()
5412                .expect("Thread should exist")
5413                .read(cx)
5414                .thread
5415                .clone();
5416            let thread = thread.read(cx);
5417
5418            let tool_call = thread.entries().iter().find_map(|entry| {
5419                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5420                    Some(call)
5421                } else {
5422                    None
5423                }
5424            });
5425
5426            assert!(tool_call.is_some(), "Expected a tool call entry");
5427            let tool_call = tool_call.unwrap();
5428
5429            // Verify it's waiting for confirmation
5430            assert!(
5431                matches!(
5432                    tool_call.status,
5433                    acp_thread::ToolCallStatus::WaitingForConfirmation { .. }
5434                ),
5435                "Expected WaitingForConfirmation status, got {:?}",
5436                tool_call.status
5437            );
5438
5439            // Verify the options count (granularity options only, no separate Deny option)
5440            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5441                &tool_call.status
5442            {
5443                let PermissionOptions::Dropdown(choices) = options else {
5444                    panic!("Expected dropdown permission options");
5445                };
5446
5447                assert_eq!(
5448                    choices.len(),
5449                    3,
5450                    "Expected 3 permission options (granularity only)"
5451                );
5452
5453                // Verify specific button labels (now using neutral names)
5454                let labels: Vec<&str> = choices
5455                    .iter()
5456                    .map(|choice| choice.allow.name.as_ref())
5457                    .collect();
5458                assert!(
5459                    labels.contains(&"Always for terminal"),
5460                    "Missing 'Always for terminal' option"
5461                );
5462                assert!(
5463                    labels.contains(&"Always for `cargo build` commands"),
5464                    "Missing pattern option"
5465                );
5466                assert!(
5467                    labels.contains(&"Only this time"),
5468                    "Missing 'Only this time' option"
5469                );
5470            }
5471        });
5472    }
5473
5474    #[gpui::test]
5475    async fn test_tool_permission_buttons_edit_file_with_path_pattern(cx: &mut TestAppContext) {
5476        init_test(cx);
5477
5478        let tool_call_id = acp::ToolCallId::new("edit-file-1");
5479        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`")
5480            .kind(acp::ToolKind::Edit);
5481
5482        let permission_options =
5483            ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()])
5484                .build_permission_options();
5485
5486        let connection =
5487            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5488                tool_call_id.clone(),
5489                permission_options,
5490            )]));
5491
5492        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5493
5494        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5495
5496        // Disable notifications
5497        cx.update(|_window, cx| {
5498            AgentSettings::override_global(
5499                AgentSettings {
5500                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5501                    ..AgentSettings::get_global(cx).clone()
5502                },
5503                cx,
5504            );
5505        });
5506
5507        let message_editor = message_editor(&thread_view, cx);
5508        message_editor.update_in(cx, |editor, window, cx| {
5509            editor.set_text("Edit the main file", window, cx);
5510        });
5511
5512        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5513
5514        cx.run_until_parked();
5515
5516        // Verify the options
5517        thread_view.read_with(cx, |thread_view, cx| {
5518            let thread = thread_view
5519                .active_thread()
5520                .expect("Thread should exist")
5521                .read(cx)
5522                .thread
5523                .clone();
5524            let thread = thread.read(cx);
5525
5526            let tool_call = thread.entries().iter().find_map(|entry| {
5527                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5528                    Some(call)
5529                } else {
5530                    None
5531                }
5532            });
5533
5534            assert!(tool_call.is_some(), "Expected a tool call entry");
5535            let tool_call = tool_call.unwrap();
5536
5537            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5538                &tool_call.status
5539            {
5540                let PermissionOptions::Dropdown(choices) = options else {
5541                    panic!("Expected dropdown permission options");
5542                };
5543
5544                let labels: Vec<&str> = choices
5545                    .iter()
5546                    .map(|choice| choice.allow.name.as_ref())
5547                    .collect();
5548                assert!(
5549                    labels.contains(&"Always for edit file"),
5550                    "Missing 'Always for edit file' option"
5551                );
5552                assert!(
5553                    labels.contains(&"Always for `src/`"),
5554                    "Missing path pattern option"
5555                );
5556            } else {
5557                panic!("Expected WaitingForConfirmation status");
5558            }
5559        });
5560    }
5561
5562    #[gpui::test]
5563    async fn test_tool_permission_buttons_fetch_with_domain_pattern(cx: &mut TestAppContext) {
5564        init_test(cx);
5565
5566        let tool_call_id = acp::ToolCallId::new("fetch-1");
5567        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Fetch `https://docs.rs/gpui`")
5568            .kind(acp::ToolKind::Fetch);
5569
5570        let permission_options =
5571            ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()])
5572                .build_permission_options();
5573
5574        let connection =
5575            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5576                tool_call_id.clone(),
5577                permission_options,
5578            )]));
5579
5580        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5581
5582        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5583
5584        // Disable notifications
5585        cx.update(|_window, cx| {
5586            AgentSettings::override_global(
5587                AgentSettings {
5588                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5589                    ..AgentSettings::get_global(cx).clone()
5590                },
5591                cx,
5592            );
5593        });
5594
5595        let message_editor = message_editor(&thread_view, cx);
5596        message_editor.update_in(cx, |editor, window, cx| {
5597            editor.set_text("Fetch the docs", window, cx);
5598        });
5599
5600        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5601
5602        cx.run_until_parked();
5603
5604        // Verify the options
5605        thread_view.read_with(cx, |thread_view, cx| {
5606            let thread = thread_view
5607                .active_thread()
5608                .expect("Thread should exist")
5609                .read(cx)
5610                .thread
5611                .clone();
5612            let thread = thread.read(cx);
5613
5614            let tool_call = thread.entries().iter().find_map(|entry| {
5615                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5616                    Some(call)
5617                } else {
5618                    None
5619                }
5620            });
5621
5622            assert!(tool_call.is_some(), "Expected a tool call entry");
5623            let tool_call = tool_call.unwrap();
5624
5625            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5626                &tool_call.status
5627            {
5628                let PermissionOptions::Dropdown(choices) = options else {
5629                    panic!("Expected dropdown permission options");
5630                };
5631
5632                let labels: Vec<&str> = choices
5633                    .iter()
5634                    .map(|choice| choice.allow.name.as_ref())
5635                    .collect();
5636                assert!(
5637                    labels.contains(&"Always for fetch"),
5638                    "Missing 'Always for fetch' option"
5639                );
5640                assert!(
5641                    labels.contains(&"Always for `docs.rs`"),
5642                    "Missing domain pattern option"
5643                );
5644            } else {
5645                panic!("Expected WaitingForConfirmation status");
5646            }
5647        });
5648    }
5649
5650    #[gpui::test]
5651    async fn test_tool_permission_buttons_without_pattern(cx: &mut TestAppContext) {
5652        init_test(cx);
5653
5654        let tool_call_id = acp::ToolCallId::new("terminal-no-pattern-1");
5655        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `./deploy.sh --production`")
5656            .kind(acp::ToolKind::Edit);
5657
5658        // No pattern button since ./deploy.sh doesn't match the alphanumeric pattern
5659        let permission_options = ToolPermissionContext::new(
5660            TerminalTool::NAME,
5661            vec!["./deploy.sh --production".to_string()],
5662        )
5663        .build_permission_options();
5664
5665        let connection =
5666            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5667                tool_call_id.clone(),
5668                permission_options,
5669            )]));
5670
5671        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5672
5673        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5674
5675        // Disable notifications
5676        cx.update(|_window, cx| {
5677            AgentSettings::override_global(
5678                AgentSettings {
5679                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5680                    ..AgentSettings::get_global(cx).clone()
5681                },
5682                cx,
5683            );
5684        });
5685
5686        let message_editor = message_editor(&thread_view, cx);
5687        message_editor.update_in(cx, |editor, window, cx| {
5688            editor.set_text("Run the deploy script", window, cx);
5689        });
5690
5691        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5692
5693        cx.run_until_parked();
5694
5695        // Verify only 2 options (no pattern button when command doesn't match pattern)
5696        thread_view.read_with(cx, |thread_view, cx| {
5697            let thread = thread_view
5698                .active_thread()
5699                .expect("Thread should exist")
5700                .read(cx)
5701                .thread
5702                .clone();
5703            let thread = thread.read(cx);
5704
5705            let tool_call = thread.entries().iter().find_map(|entry| {
5706                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5707                    Some(call)
5708                } else {
5709                    None
5710                }
5711            });
5712
5713            assert!(tool_call.is_some(), "Expected a tool call entry");
5714            let tool_call = tool_call.unwrap();
5715
5716            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5717                &tool_call.status
5718            {
5719                let PermissionOptions::Dropdown(choices) = options else {
5720                    panic!("Expected dropdown permission options");
5721                };
5722
5723                assert_eq!(
5724                    choices.len(),
5725                    2,
5726                    "Expected 2 permission options (no pattern option)"
5727                );
5728
5729                let labels: Vec<&str> = choices
5730                    .iter()
5731                    .map(|choice| choice.allow.name.as_ref())
5732                    .collect();
5733                assert!(
5734                    labels.contains(&"Always for terminal"),
5735                    "Missing 'Always for terminal' option"
5736                );
5737                assert!(
5738                    labels.contains(&"Only this time"),
5739                    "Missing 'Only this time' option"
5740                );
5741                // Should NOT contain a pattern option
5742                assert!(
5743                    !labels.iter().any(|l| l.contains("commands")),
5744                    "Should not have pattern option"
5745                );
5746            } else {
5747                panic!("Expected WaitingForConfirmation status");
5748            }
5749        });
5750    }
5751
5752    #[gpui::test]
5753    async fn test_authorize_tool_call_action_triggers_authorization(cx: &mut TestAppContext) {
5754        init_test(cx);
5755
5756        let tool_call_id = acp::ToolCallId::new("action-test-1");
5757        let tool_call =
5758            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit);
5759
5760        let permission_options =
5761            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo test".to_string()])
5762                .build_permission_options();
5763
5764        let connection =
5765            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5766                tool_call_id.clone(),
5767                permission_options,
5768            )]));
5769
5770        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5771
5772        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5773        add_to_workspace(thread_view.clone(), cx);
5774
5775        cx.update(|_window, cx| {
5776            AgentSettings::override_global(
5777                AgentSettings {
5778                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5779                    ..AgentSettings::get_global(cx).clone()
5780                },
5781                cx,
5782            );
5783        });
5784
5785        let message_editor = message_editor(&thread_view, cx);
5786        message_editor.update_in(cx, |editor, window, cx| {
5787            editor.set_text("Run tests", window, cx);
5788        });
5789
5790        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5791
5792        cx.run_until_parked();
5793
5794        // Verify tool call is waiting for confirmation
5795        thread_view.read_with(cx, |thread_view, cx| {
5796            let tool_call = thread_view.pending_tool_call(cx);
5797            assert!(
5798                tool_call.is_some(),
5799                "Expected a tool call waiting for confirmation"
5800            );
5801        });
5802
5803        // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection)
5804        thread_view.update_in(cx, |_, window, cx| {
5805            window.dispatch_action(
5806                crate::AuthorizeToolCall {
5807                    tool_call_id: "action-test-1".to_string(),
5808                    option_id: "allow".to_string(),
5809                    option_kind: "AllowOnce".to_string(),
5810                }
5811                .boxed_clone(),
5812                cx,
5813            );
5814        });
5815
5816        cx.run_until_parked();
5817
5818        // Verify tool call is no longer waiting for confirmation (was authorized)
5819        thread_view.read_with(cx, |thread_view, cx| {
5820            let tool_call = thread_view.pending_tool_call(cx);
5821            assert!(
5822                tool_call.is_none(),
5823                "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action"
5824            );
5825        });
5826    }
5827
5828    #[gpui::test]
5829    async fn test_authorize_tool_call_action_with_pattern_option(cx: &mut TestAppContext) {
5830        init_test(cx);
5831
5832        let tool_call_id = acp::ToolCallId::new("pattern-action-test-1");
5833        let tool_call =
5834            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
5835
5836        let permission_options =
5837            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
5838                .build_permission_options();
5839
5840        let connection =
5841            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5842                tool_call_id.clone(),
5843                permission_options.clone(),
5844            )]));
5845
5846        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5847
5848        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5849        add_to_workspace(thread_view.clone(), cx);
5850
5851        cx.update(|_window, cx| {
5852            AgentSettings::override_global(
5853                AgentSettings {
5854                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5855                    ..AgentSettings::get_global(cx).clone()
5856                },
5857                cx,
5858            );
5859        });
5860
5861        let message_editor = message_editor(&thread_view, cx);
5862        message_editor.update_in(cx, |editor, window, cx| {
5863            editor.set_text("Install dependencies", window, cx);
5864        });
5865
5866        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5867
5868        cx.run_until_parked();
5869
5870        // Find the pattern option ID
5871        let pattern_option = match &permission_options {
5872            PermissionOptions::Dropdown(choices) => choices
5873                .iter()
5874                .find(|choice| {
5875                    choice
5876                        .allow
5877                        .option_id
5878                        .0
5879                        .starts_with("always_allow_pattern:")
5880                })
5881                .map(|choice| &choice.allow)
5882                .expect("Should have a pattern option for npm command"),
5883            _ => panic!("Expected dropdown permission options"),
5884        };
5885
5886        // Dispatch action with the pattern option (simulating "Always allow `npm` commands")
5887        thread_view.update_in(cx, |_, window, cx| {
5888            window.dispatch_action(
5889                crate::AuthorizeToolCall {
5890                    tool_call_id: "pattern-action-test-1".to_string(),
5891                    option_id: pattern_option.option_id.0.to_string(),
5892                    option_kind: "AllowAlways".to_string(),
5893                }
5894                .boxed_clone(),
5895                cx,
5896            );
5897        });
5898
5899        cx.run_until_parked();
5900
5901        // Verify tool call was authorized
5902        thread_view.read_with(cx, |thread_view, cx| {
5903            let tool_call = thread_view.pending_tool_call(cx);
5904            assert!(
5905                tool_call.is_none(),
5906                "Tool call should be authorized after selecting pattern option"
5907            );
5908        });
5909    }
5910
5911    #[gpui::test]
5912    async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) {
5913        init_test(cx);
5914
5915        let tool_call_id = acp::ToolCallId::new("deny-granularity-test-1");
5916        let tool_call =
5917            acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit);
5918
5919        let permission_options =
5920            ToolPermissionContext::new(TerminalTool::NAME, vec!["git push".to_string()])
5921                .build_permission_options();
5922
5923        let connection =
5924            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5925                tool_call_id.clone(),
5926                permission_options.clone(),
5927            )]));
5928
5929        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5930
5931        let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
5932        add_to_workspace(thread_view.clone(), cx);
5933
5934        cx.update(|_window, cx| {
5935            AgentSettings::override_global(
5936                AgentSettings {
5937                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5938                    ..AgentSettings::get_global(cx).clone()
5939                },
5940                cx,
5941            );
5942        });
5943
5944        let message_editor = message_editor(&thread_view, cx);
5945        message_editor.update_in(cx, |editor, window, cx| {
5946            editor.set_text("Push changes", window, cx);
5947        });
5948
5949        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5950
5951        cx.run_until_parked();
5952
5953        // Use default granularity (last option = "Only this time")
5954        // Simulate clicking the Deny button
5955        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
5956            view.reject_once(&RejectOnce, window, cx)
5957        });
5958
5959        cx.run_until_parked();
5960
5961        // Verify tool call was rejected (no longer waiting for confirmation)
5962        thread_view.read_with(cx, |thread_view, cx| {
5963            let tool_call = thread_view.pending_tool_call(cx);
5964            assert!(
5965                tool_call.is_none(),
5966                "Tool call should be rejected after Deny"
5967            );
5968        });
5969    }
5970
5971    #[gpui::test]
5972    async fn test_option_id_transformation_for_allow() {
5973        let permission_options = ToolPermissionContext::new(
5974            TerminalTool::NAME,
5975            vec!["cargo build --release".to_string()],
5976        )
5977        .build_permission_options();
5978
5979        let PermissionOptions::Dropdown(choices) = permission_options else {
5980            panic!("Expected dropdown permission options");
5981        };
5982
5983        let allow_ids: Vec<String> = choices
5984            .iter()
5985            .map(|choice| choice.allow.option_id.0.to_string())
5986            .collect();
5987
5988        assert!(allow_ids.contains(&"always_allow:terminal".to_string()));
5989        assert!(allow_ids.contains(&"allow".to_string()));
5990        assert!(
5991            allow_ids
5992                .iter()
5993                .any(|id| id.starts_with("always_allow_pattern:terminal\n")),
5994            "Missing allow pattern option"
5995        );
5996    }
5997
5998    #[gpui::test]
5999    async fn test_option_id_transformation_for_deny() {
6000        let permission_options = ToolPermissionContext::new(
6001            TerminalTool::NAME,
6002            vec!["cargo build --release".to_string()],
6003        )
6004        .build_permission_options();
6005
6006        let PermissionOptions::Dropdown(choices) = permission_options else {
6007            panic!("Expected dropdown permission options");
6008        };
6009
6010        let deny_ids: Vec<String> = choices
6011            .iter()
6012            .map(|choice| choice.deny.option_id.0.to_string())
6013            .collect();
6014
6015        assert!(deny_ids.contains(&"always_deny:terminal".to_string()));
6016        assert!(deny_ids.contains(&"deny".to_string()));
6017        assert!(
6018            deny_ids
6019                .iter()
6020                .any(|id| id.starts_with("always_deny_pattern:terminal\n")),
6021            "Missing deny pattern option"
6022        );
6023    }
6024
6025    #[gpui::test]
6026    async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) {
6027        init_test(cx);
6028
6029        let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
6030
6031        let active = active_thread(&thread_view, cx);
6032        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6033        let thread = cx.read(|cx| active.read(cx).thread.clone());
6034
6035        title_editor.read_with(cx, |editor, cx| {
6036            assert!(!editor.read_only(cx));
6037        });
6038
6039        title_editor.update_in(cx, |editor, window, cx| {
6040            editor.set_text("My Custom Title", window, cx);
6041        });
6042        cx.run_until_parked();
6043
6044        title_editor.read_with(cx, |editor, cx| {
6045            assert_eq!(editor.text(cx), "My Custom Title");
6046        });
6047        thread.read_with(cx, |thread, _cx| {
6048            assert_eq!(thread.title().as_ref(), "My Custom Title");
6049        });
6050    }
6051
6052    #[gpui::test]
6053    async fn test_title_editor_is_read_only_when_set_title_unsupported(cx: &mut TestAppContext) {
6054        init_test(cx);
6055
6056        let (thread_view, cx) =
6057            setup_thread_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await;
6058
6059        let active = active_thread(&thread_view, cx);
6060        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6061
6062        title_editor.read_with(cx, |editor, cx| {
6063            assert!(
6064                editor.read_only(cx),
6065                "Title editor should be read-only when the connection does not support set_title"
6066            );
6067        });
6068    }
6069
6070    #[gpui::test]
6071    async fn test_max_tokens_error_is_rendered(cx: &mut TestAppContext) {
6072        init_test(cx);
6073
6074        let connection = StubAgentConnection::new();
6075
6076        let (thread_view, cx) =
6077            setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
6078
6079        let message_editor = message_editor(&thread_view, cx);
6080        message_editor.update_in(cx, |editor, window, cx| {
6081            editor.set_text("Some prompt", window, cx);
6082        });
6083        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
6084
6085        let session_id = thread_view.read_with(cx, |view, cx| {
6086            view.active_thread()
6087                .unwrap()
6088                .read(cx)
6089                .thread
6090                .read(cx)
6091                .session_id()
6092                .clone()
6093        });
6094
6095        cx.run_until_parked();
6096
6097        cx.update(|_, _cx| {
6098            connection.end_turn(session_id, acp::StopReason::MaxTokens);
6099        });
6100
6101        cx.run_until_parked();
6102
6103        thread_view.read_with(cx, |thread_view, cx| {
6104            let state = thread_view.active_thread().unwrap();
6105            let error = &state.read(cx).thread_error;
6106            match error {
6107                Some(ThreadError::Other { message, .. }) => {
6108                    assert!(
6109                        message.contains("Max tokens reached"),
6110                        "Expected 'Max tokens reached' error, got: {}",
6111                        message
6112                    );
6113                }
6114                other => panic!(
6115                    "Expected ThreadError::Other with 'Max tokens reached', got: {:?}",
6116                    other.is_some()
6117                ),
6118            }
6119        });
6120    }
6121
6122    fn create_test_acp_thread(
6123        parent_session_id: Option<acp::SessionId>,
6124        session_id: &str,
6125        connection: Rc<dyn AgentConnection>,
6126        project: Entity<Project>,
6127        cx: &mut App,
6128    ) -> Entity<AcpThread> {
6129        let action_log = cx.new(|_| ActionLog::new(project.clone()));
6130        cx.new(|cx| {
6131            AcpThread::new(
6132                parent_session_id,
6133                "Test Thread",
6134                None,
6135                connection,
6136                project,
6137                action_log,
6138                acp::SessionId::new(session_id),
6139                watch::Receiver::constant(acp::PromptCapabilities::new()),
6140                cx,
6141            )
6142        })
6143    }
6144
6145    fn request_test_tool_authorization(
6146        thread: &Entity<AcpThread>,
6147        tool_call_id: &str,
6148        option_id: &str,
6149        cx: &mut TestAppContext,
6150    ) -> Task<acp::RequestPermissionOutcome> {
6151        let tool_call_id = acp::ToolCallId::new(tool_call_id);
6152        let label = format!("Tool {tool_call_id}");
6153        let option_id = acp::PermissionOptionId::new(option_id);
6154        cx.update(|cx| {
6155            thread.update(cx, |thread, cx| {
6156                thread
6157                    .request_tool_call_authorization(
6158                        acp::ToolCall::new(tool_call_id, label)
6159                            .kind(acp::ToolKind::Edit)
6160                            .into(),
6161                        PermissionOptions::Flat(vec![acp::PermissionOption::new(
6162                            option_id,
6163                            "Allow",
6164                            acp::PermissionOptionKind::AllowOnce,
6165                        )]),
6166                        cx,
6167                    )
6168                    .unwrap()
6169            })
6170        })
6171    }
6172
6173    #[gpui::test]
6174    async fn test_conversation_multiple_tool_calls_fifo_ordering(cx: &mut TestAppContext) {
6175        init_test(cx);
6176
6177        let fs = FakeFs::new(cx.executor());
6178        let project = Project::test(fs, [], cx).await;
6179        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6180
6181        let (thread, conversation) = cx.update(|cx| {
6182            let thread =
6183                create_test_acp_thread(None, "session-1", connection.clone(), project.clone(), cx);
6184            let conversation = cx.new(|cx| {
6185                let mut conversation = Conversation::default();
6186                conversation.register_thread(thread.clone(), cx);
6187                conversation
6188            });
6189            (thread, conversation)
6190        });
6191
6192        let _task1 = request_test_tool_authorization(&thread, "tc-1", "allow-1", cx);
6193        let _task2 = request_test_tool_authorization(&thread, "tc-2", "allow-2", cx);
6194
6195        cx.read(|cx| {
6196            let session_id = acp::SessionId::new("session-1");
6197            let (_, tool_call_id, _) = conversation
6198                .read(cx)
6199                .pending_tool_call(&session_id, cx)
6200                .expect("Expected a pending tool call");
6201            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-1"));
6202        });
6203
6204        cx.update(|cx| {
6205            conversation.update(cx, |conversation, cx| {
6206                conversation.authorize_tool_call(
6207                    acp::SessionId::new("session-1"),
6208                    acp::ToolCallId::new("tc-1"),
6209                    acp::PermissionOptionId::new("allow-1"),
6210                    acp::PermissionOptionKind::AllowOnce,
6211                    cx,
6212                );
6213            });
6214        });
6215
6216        cx.run_until_parked();
6217
6218        cx.read(|cx| {
6219            let session_id = acp::SessionId::new("session-1");
6220            let (_, tool_call_id, _) = conversation
6221                .read(cx)
6222                .pending_tool_call(&session_id, cx)
6223                .expect("Expected tc-2 to be pending after tc-1 was authorized");
6224            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-2"));
6225        });
6226
6227        cx.update(|cx| {
6228            conversation.update(cx, |conversation, cx| {
6229                conversation.authorize_tool_call(
6230                    acp::SessionId::new("session-1"),
6231                    acp::ToolCallId::new("tc-2"),
6232                    acp::PermissionOptionId::new("allow-2"),
6233                    acp::PermissionOptionKind::AllowOnce,
6234                    cx,
6235                );
6236            });
6237        });
6238
6239        cx.run_until_parked();
6240
6241        cx.read(|cx| {
6242            let session_id = acp::SessionId::new("session-1");
6243            assert!(
6244                conversation
6245                    .read(cx)
6246                    .pending_tool_call(&session_id, cx)
6247                    .is_none(),
6248                "Expected no pending tool calls after both were authorized"
6249            );
6250        });
6251    }
6252
6253    #[gpui::test]
6254    async fn test_conversation_subagent_scoped_pending_tool_call(cx: &mut TestAppContext) {
6255        init_test(cx);
6256
6257        let fs = FakeFs::new(cx.executor());
6258        let project = Project::test(fs, [], cx).await;
6259        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6260
6261        let (parent_thread, subagent_thread, conversation) = cx.update(|cx| {
6262            let parent_thread =
6263                create_test_acp_thread(None, "parent", connection.clone(), project.clone(), cx);
6264            let subagent_thread = create_test_acp_thread(
6265                Some(acp::SessionId::new("parent")),
6266                "subagent",
6267                connection.clone(),
6268                project.clone(),
6269                cx,
6270            );
6271            let conversation = cx.new(|cx| {
6272                let mut conversation = Conversation::default();
6273                conversation.register_thread(parent_thread.clone(), cx);
6274                conversation.register_thread(subagent_thread.clone(), cx);
6275                conversation
6276            });
6277            (parent_thread, subagent_thread, conversation)
6278        });
6279
6280        let _parent_task =
6281            request_test_tool_authorization(&parent_thread, "parent-tc", "allow-parent", cx);
6282        let _subagent_task =
6283            request_test_tool_authorization(&subagent_thread, "subagent-tc", "allow-subagent", cx);
6284
6285        // Querying with the subagent's session ID returns only the
6286        // subagent's own tool call (subagent path is scoped to its session)
6287        cx.read(|cx| {
6288            let subagent_id = acp::SessionId::new("subagent");
6289            let (session_id, tool_call_id, _) = conversation
6290                .read(cx)
6291                .pending_tool_call(&subagent_id, cx)
6292                .expect("Expected subagent's pending tool call");
6293            assert_eq!(session_id, acp::SessionId::new("subagent"));
6294            assert_eq!(tool_call_id, acp::ToolCallId::new("subagent-tc"));
6295        });
6296
6297        // Querying with the parent's session ID returns the first pending
6298        // request in FIFO order across all sessions
6299        cx.read(|cx| {
6300            let parent_id = acp::SessionId::new("parent");
6301            let (session_id, tool_call_id, _) = conversation
6302                .read(cx)
6303                .pending_tool_call(&parent_id, cx)
6304                .expect("Expected a pending tool call from parent query");
6305            assert_eq!(session_id, acp::SessionId::new("parent"));
6306            assert_eq!(tool_call_id, acp::ToolCallId::new("parent-tc"));
6307        });
6308    }
6309
6310    #[gpui::test]
6311    async fn test_conversation_parent_pending_tool_call_returns_first_across_threads(
6312        cx: &mut TestAppContext,
6313    ) {
6314        init_test(cx);
6315
6316        let fs = FakeFs::new(cx.executor());
6317        let project = Project::test(fs, [], cx).await;
6318        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6319
6320        let (thread_a, thread_b, conversation) = cx.update(|cx| {
6321            let thread_a =
6322                create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx);
6323            let thread_b =
6324                create_test_acp_thread(None, "thread-b", connection.clone(), project.clone(), cx);
6325            let conversation = cx.new(|cx| {
6326                let mut conversation = Conversation::default();
6327                conversation.register_thread(thread_a.clone(), cx);
6328                conversation.register_thread(thread_b.clone(), cx);
6329                conversation
6330            });
6331            (thread_a, thread_b, conversation)
6332        });
6333
6334        let _task_a = request_test_tool_authorization(&thread_a, "tc-a", "allow-a", cx);
6335        let _task_b = request_test_tool_authorization(&thread_b, "tc-b", "allow-b", cx);
6336
6337        // Both threads are non-subagent, so pending_tool_call always returns
6338        // the first entry from permission_requests (FIFO across all sessions)
6339        cx.read(|cx| {
6340            let session_a = acp::SessionId::new("thread-a");
6341            let (session_id, tool_call_id, _) = conversation
6342                .read(cx)
6343                .pending_tool_call(&session_a, cx)
6344                .expect("Expected a pending tool call");
6345            assert_eq!(session_id, acp::SessionId::new("thread-a"));
6346            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
6347        });
6348
6349        // Querying with thread-b also returns thread-a's tool call,
6350        // because non-subagent queries always use permission_requests.first()
6351        cx.read(|cx| {
6352            let session_b = acp::SessionId::new("thread-b");
6353            let (session_id, tool_call_id, _) = conversation
6354                .read(cx)
6355                .pending_tool_call(&session_b, cx)
6356                .expect("Expected a pending tool call from thread-b query");
6357            assert_eq!(
6358                session_id,
6359                acp::SessionId::new("thread-a"),
6360                "Non-subagent queries always return the first pending request in FIFO order"
6361            );
6362            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
6363        });
6364
6365        // After authorizing thread-a's tool call, thread-b's becomes first
6366        cx.update(|cx| {
6367            conversation.update(cx, |conversation, cx| {
6368                conversation.authorize_tool_call(
6369                    acp::SessionId::new("thread-a"),
6370                    acp::ToolCallId::new("tc-a"),
6371                    acp::PermissionOptionId::new("allow-a"),
6372                    acp::PermissionOptionKind::AllowOnce,
6373                    cx,
6374                );
6375            });
6376        });
6377
6378        cx.run_until_parked();
6379
6380        cx.read(|cx| {
6381            let session_b = acp::SessionId::new("thread-b");
6382            let (session_id, tool_call_id, _) = conversation
6383                .read(cx)
6384                .pending_tool_call(&session_b, cx)
6385                .expect("Expected thread-b's tool call after thread-a's was authorized");
6386            assert_eq!(session_id, acp::SessionId::new("thread-b"));
6387            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b"));
6388        });
6389    }
6390
6391    #[gpui::test]
6392    async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) {
6393        init_test(cx);
6394
6395        let (connection_view, cx) =
6396            setup_thread_view(StubAgentServer::default_response(), cx).await;
6397
6398        // Add a plain-text message to the queue directly.
6399        active_thread(&connection_view, cx).update_in(cx, |thread, window, cx| {
6400            thread.add_to_queue(
6401                vec![acp::ContentBlock::Text(acp::TextContent::new(
6402                    "queued message".to_string(),
6403                ))],
6404                vec![],
6405                cx,
6406            );
6407            // Main editor must be empty for this path — it is by default, but
6408            // assert to make the precondition explicit.
6409            assert!(thread.message_editor.read(cx).is_empty(cx));
6410            thread.move_queued_message_to_main_editor(0, None, window, cx);
6411        });
6412
6413        cx.run_until_parked();
6414
6415        // Queue should now be empty.
6416        let queue_len = active_thread(&connection_view, cx)
6417            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
6418        assert_eq!(queue_len, 0, "Queue should be empty after move");
6419
6420        // Main editor should contain the queued message text.
6421        let text = message_editor(&connection_view, cx).update(cx, |editor, cx| editor.text(cx));
6422        assert_eq!(
6423            text, "queued message",
6424            "Main editor should contain the moved queued message"
6425        );
6426    }
6427
6428    #[gpui::test]
6429    async fn test_move_queued_message_to_non_empty_main_editor(cx: &mut TestAppContext) {
6430        init_test(cx);
6431
6432        let (connection_view, cx) =
6433            setup_thread_view(StubAgentServer::default_response(), cx).await;
6434
6435        // Seed the main editor with existing content.
6436        message_editor(&connection_view, cx).update_in(cx, |editor, window, cx| {
6437            editor.set_message(
6438                vec![acp::ContentBlock::Text(acp::TextContent::new(
6439                    "existing content".to_string(),
6440                ))],
6441                window,
6442                cx,
6443            );
6444        });
6445
6446        // Add a plain-text message to the queue.
6447        active_thread(&connection_view, cx).update_in(cx, |thread, window, cx| {
6448            thread.add_to_queue(
6449                vec![acp::ContentBlock::Text(acp::TextContent::new(
6450                    "queued message".to_string(),
6451                ))],
6452                vec![],
6453                cx,
6454            );
6455            thread.move_queued_message_to_main_editor(0, None, window, cx);
6456        });
6457
6458        cx.run_until_parked();
6459
6460        // Queue should now be empty.
6461        let queue_len = active_thread(&connection_view, cx)
6462            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
6463        assert_eq!(queue_len, 0, "Queue should be empty after move");
6464
6465        // Main editor should contain existing content + separator + queued content.
6466        let text = message_editor(&connection_view, cx).update(cx, |editor, cx| editor.text(cx));
6467        assert_eq!(
6468            text, "existing content\n\nqueued message",
6469            "Main editor should have existing content and queued message separated by two newlines"
6470        );
6471    }
6472}