conversation_view.rs

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