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