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};
  16use audio::{Audio, Sound};
  17use buffer_diff::BufferDiff;
  18use client::zed_urls;
  19use collections::{HashMap, HashSet, IndexMap};
  20use editor::scroll::Autoscroll;
  21use editor::{
  22    Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
  23};
  24use feature_flags::{AgentSharingFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _};
  25use file_icons::FileIcons;
  26use fs::Fs;
  27use futures::FutureExt as _;
  28use gpui::{
  29    Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle,
  30    ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState,
  31    ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle,
  32    WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient,
  33    list, point, pulsating_between,
  34};
  35use language::Buffer;
  36use language_model::LanguageModelRegistry;
  37use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
  38use parking_lot::RwLock;
  39use project::{AgentId, AgentServerStore, Project, ProjectEntryId};
  40use prompt_store::{PromptId, PromptStore};
  41
  42use crate::DEFAULT_THREAD_TITLE;
  43use crate::message_editor::SessionCapabilities;
  44use rope::Point;
  45use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
  46use std::path::Path;
  47use std::sync::Arc;
  48use std::time::Instant;
  49use std::{collections::BTreeMap, rc::Rc, time::Duration};
  50use terminal_view::terminal_panel::TerminalPanel;
  51use text::Anchor;
  52use theme_settings::AgentFontSize;
  53use ui::{
  54    Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton,
  55    DecoratedIcon, DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind,
  56    KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar,
  57    prelude::*, right_click_menu,
  58};
  59use util::{ResultExt, size::format_file_size, time::duration_alt_display};
  60use util::{debug_panic, defer};
  61use workspace::PathList;
  62use workspace::{
  63    CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
  64};
  65use zed_actions::agent::{Chat, ToggleModelSelector};
  66use zed_actions::assistant::OpenRulesLibrary;
  67
  68use super::config_options::ConfigOptionsView;
  69use super::entry_view_state::EntryViewState;
  70use super::thread_history::ThreadHistory;
  71use crate::ModeSelector;
  72use crate::ModelSelectorPopover;
  73use crate::agent_connection_store::{
  74    AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore,
  75};
  76use crate::agent_diff::AgentDiff;
  77use crate::entry_view_state::{EntryViewEvent, ViewEvent};
  78use crate::message_editor::{MessageEditor, MessageEditorEvent};
  79use crate::profile_selector::{ProfileProvider, ProfileSelector};
  80use crate::thread_metadata_store::SidebarThreadMetadataStore;
  81use crate::ui::{AgentNotification, AgentNotificationEvent};
  82use crate::{
  83    Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce,
  84    AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector,
  85    CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread,
  86    OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce,
  87    RemoveFirstQueuedMessage, ScrollOutputPageDown, ScrollOutputPageUp, SendImmediately,
  88    SendNextQueuedMessage, ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu,
  89    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        self.play_notification_sound(window, cx);
2283        self.show_notification(caption, icon, window, cx);
2284    }
2285
2286    fn agent_panel_visible(&self, multi_workspace: &Entity<MultiWorkspace>, cx: &App) -> bool {
2287        let Some(workspace) = self.workspace.upgrade() else {
2288            return false;
2289        };
2290
2291        multi_workspace.read(cx).workspace() == &workspace && AgentPanel::is_visible(&workspace, cx)
2292    }
2293
2294    fn agent_status_visible(&self, window: &Window, cx: &App) -> bool {
2295        if !window.is_window_active() {
2296            return false;
2297        }
2298
2299        if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
2300            multi_workspace.read(cx).sidebar_open()
2301                || self.agent_panel_visible(&multi_workspace, cx)
2302        } else {
2303            self.workspace
2304                .upgrade()
2305                .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
2306        }
2307    }
2308
2309    fn play_notification_sound(&self, window: &Window, cx: &mut App) {
2310        let settings = AgentSettings::get_global(cx);
2311        let visible = window.is_window_active()
2312            && if let Some(mw) = window.root::<MultiWorkspace>().flatten() {
2313                self.agent_panel_visible(&mw, cx)
2314            } else {
2315                self.workspace
2316                    .upgrade()
2317                    .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
2318            };
2319        if settings.play_sound_when_agent_done && !visible {
2320            Audio::play_sound(Sound::AgentDone, cx);
2321        }
2322    }
2323
2324    fn show_notification(
2325        &mut self,
2326        caption: impl Into<SharedString>,
2327        icon: IconName,
2328        window: &mut Window,
2329        cx: &mut Context<Self>,
2330    ) {
2331        if !self.notifications.is_empty() {
2332            return;
2333        }
2334
2335        let settings = AgentSettings::get_global(cx);
2336
2337        let should_notify = !self.agent_status_visible(window, cx);
2338
2339        if !should_notify {
2340            return;
2341        }
2342
2343        // TODO: Change this once we have title summarization for external agents.
2344        let title = self.agent.agent_id().0;
2345
2346        match settings.notify_when_agent_waiting {
2347            NotifyWhenAgentWaiting::PrimaryScreen => {
2348                if let Some(primary) = cx.primary_display() {
2349                    self.pop_up(icon, caption.into(), title, window, primary, cx);
2350                }
2351            }
2352            NotifyWhenAgentWaiting::AllScreens => {
2353                let caption = caption.into();
2354                for screen in cx.displays() {
2355                    self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
2356                }
2357            }
2358            NotifyWhenAgentWaiting::Never => {
2359                // Don't show anything
2360            }
2361        }
2362    }
2363
2364    fn pop_up(
2365        &mut self,
2366        icon: IconName,
2367        caption: SharedString,
2368        title: SharedString,
2369        window: &mut Window,
2370        screen: Rc<dyn PlatformDisplay>,
2371        cx: &mut Context<Self>,
2372    ) {
2373        let options = AgentNotification::window_options(screen, cx);
2374
2375        let project_name = self.workspace.upgrade().and_then(|workspace| {
2376            workspace
2377                .read(cx)
2378                .project()
2379                .read(cx)
2380                .visible_worktrees(cx)
2381                .next()
2382                .map(|worktree| worktree.read(cx).root_name_str().to_string())
2383        });
2384
2385        if let Some(screen_window) = cx
2386            .open_window(options, |_window, cx| {
2387                cx.new(|_cx| {
2388                    AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
2389                })
2390            })
2391            .log_err()
2392            && let Some(pop_up) = screen_window.entity(cx).log_err()
2393        {
2394            self.notification_subscriptions
2395                .entry(screen_window)
2396                .or_insert_with(Vec::new)
2397                .push(cx.subscribe_in(&pop_up, window, {
2398                    |this, _, event, window, cx| match event {
2399                        AgentNotificationEvent::Accepted => {
2400                            let Some(handle) = window.window_handle().downcast::<MultiWorkspace>()
2401                            else {
2402                                log::error!("root view should be a MultiWorkspace");
2403                                return;
2404                            };
2405                            cx.activate(true);
2406
2407                            let workspace_handle = this.workspace.clone();
2408
2409                            cx.defer(move |cx| {
2410                                handle
2411                                    .update(cx, |multi_workspace, window, cx| {
2412                                        window.activate_window();
2413                                        if let Some(workspace) = workspace_handle.upgrade() {
2414                                            multi_workspace.activate(workspace.clone(), cx);
2415                                            workspace.update(cx, |workspace, cx| {
2416                                                workspace.focus_panel::<AgentPanel>(window, cx);
2417                                            });
2418                                        }
2419                                    })
2420                                    .log_err();
2421                            });
2422
2423                            this.dismiss_notifications(cx);
2424                        }
2425                        AgentNotificationEvent::Dismissed => {
2426                            this.dismiss_notifications(cx);
2427                        }
2428                    }
2429                }));
2430
2431            self.notifications.push(screen_window);
2432
2433            // If the user manually refocuses the original window, dismiss the popup.
2434            self.notification_subscriptions
2435                .entry(screen_window)
2436                .or_insert_with(Vec::new)
2437                .push({
2438                    let pop_up_weak = pop_up.downgrade();
2439
2440                    cx.observe_window_activation(window, move |this, window, cx| {
2441                        if this.agent_status_visible(window, cx)
2442                            && let Some(pop_up) = pop_up_weak.upgrade()
2443                        {
2444                            pop_up.update(cx, |notification, cx| {
2445                                notification.dismiss(cx);
2446                            });
2447                        }
2448                    })
2449                });
2450        }
2451    }
2452
2453    fn dismiss_notifications(&mut self, cx: &mut Context<Self>) {
2454        for window in self.notifications.drain(..) {
2455            window
2456                .update(cx, |_, window, _| {
2457                    window.remove_window();
2458                })
2459                .ok();
2460
2461            self.notification_subscriptions.remove(&window);
2462        }
2463    }
2464
2465    fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
2466        if let Some(entry_view_state) = self
2467            .active_thread()
2468            .map(|active| active.read(cx).entry_view_state.clone())
2469        {
2470            entry_view_state.update(cx, |entry_view_state, cx| {
2471                entry_view_state.agent_ui_font_size_changed(cx);
2472            });
2473        }
2474    }
2475
2476    pub(crate) fn insert_dragged_files(
2477        &self,
2478        paths: Vec<project::ProjectPath>,
2479        added_worktrees: Vec<Entity<project::Worktree>>,
2480        window: &mut Window,
2481        cx: &mut Context<Self>,
2482    ) {
2483        if let Some(active_thread) = self.active_thread() {
2484            active_thread.update(cx, |thread, cx| {
2485                thread.message_editor.update(cx, |editor, cx| {
2486                    editor.insert_dragged_files(paths, added_worktrees, window, cx);
2487                    editor.focus_handle(cx).focus(window, cx);
2488                })
2489            });
2490        }
2491    }
2492
2493    /// Inserts the selected text into the message editor or the message being
2494    /// edited, if any.
2495    pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
2496        if let Some(active_thread) = self.active_thread() {
2497            active_thread.update(cx, |thread, cx| {
2498                thread.active_editor(cx).update(cx, |editor, cx| {
2499                    editor.insert_selections(window, cx);
2500                })
2501            });
2502        }
2503    }
2504
2505    /// Inserts terminal text as a crease into the message editor.
2506    pub(crate) fn insert_terminal_text(
2507        &self,
2508        text: String,
2509        window: &mut Window,
2510        cx: &mut Context<Self>,
2511    ) {
2512        if let Some(active_thread) = self.active_thread() {
2513            active_thread.update(cx, |thread, cx| {
2514                thread.message_editor.update(cx, |editor, cx| {
2515                    editor.insert_terminal_crease(text, window, cx);
2516                })
2517            });
2518        }
2519    }
2520
2521    fn current_model_name(&self, cx: &App) -> SharedString {
2522        // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
2523        // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI")
2524        // This provides better clarity about what refused the request
2525        if self.as_native_connection(cx).is_some() {
2526            self.active_thread()
2527                .and_then(|active| active.read(cx).model_selector.clone())
2528                .and_then(|selector| selector.read(cx).active_model(cx))
2529                .map(|model| model.name.clone())
2530                .unwrap_or_else(|| SharedString::from("The model"))
2531        } else {
2532            // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI")
2533            self.agent.agent_id().0
2534        }
2535    }
2536
2537    fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
2538        let message = message.into();
2539
2540        CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
2541    }
2542
2543    pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2544        let agent_id = self.agent.agent_id();
2545        if let Some(active) = self.active_thread() {
2546            active.update(cx, |active, cx| active.clear_thread_error(cx));
2547        }
2548        let this = cx.weak_entity();
2549        let Some(connection) = self.as_connected().map(|c| c.connection.clone()) else {
2550            debug_panic!("This should not be possible");
2551            return;
2552        };
2553        window.defer(cx, |window, cx| {
2554            Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx);
2555        })
2556    }
2557
2558    pub fn history(&self) -> Option<&Entity<ThreadHistory>> {
2559        self.as_connected().and_then(|c| c.history.as_ref())
2560    }
2561
2562    pub fn delete_history_entry(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2563        let Some(connected) = self.as_connected() else {
2564            return;
2565        };
2566
2567        let Some(history) = &connected.history else {
2568            return;
2569        };
2570        let task = history.update(cx, |history, cx| history.delete_session(&session_id, cx));
2571        task.detach_and_log_err(cx);
2572
2573        if let Some(store) = SidebarThreadMetadataStore::try_global(cx) {
2574            store.update(cx, |store, cx| store.delete(session_id.clone(), cx));
2575        }
2576    }
2577}
2578
2579fn loading_contents_spinner(size: IconSize) -> AnyElement {
2580    Icon::new(IconName::LoadCircle)
2581        .size(size)
2582        .color(Color::Accent)
2583        .with_rotate_animation(3)
2584        .into_any_element()
2585}
2586
2587fn placeholder_text(agent_name: &str, has_commands: bool) -> String {
2588    if agent_name == agent::ZED_AGENT_ID.as_ref() {
2589        format!("Message the {} — @ to include context", agent_name)
2590    } else if has_commands {
2591        format!(
2592            "Message {} — @ to include context, / for commands",
2593            agent_name
2594        )
2595    } else {
2596        format!("Message {} — @ to include context", agent_name)
2597    }
2598}
2599
2600impl Focusable for ConversationView {
2601    fn focus_handle(&self, cx: &App) -> FocusHandle {
2602        match self.active_thread() {
2603            Some(thread) => thread.read(cx).focus_handle(cx),
2604            None => self.focus_handle.clone(),
2605        }
2606    }
2607}
2608
2609#[cfg(any(test, feature = "test-support"))]
2610impl ConversationView {
2611    /// Expands a tool call so its content is visible.
2612    /// This is primarily useful for visual testing.
2613    pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context<Self>) {
2614        if let Some(active) = self.active_thread() {
2615            active.update(cx, |active, _cx| {
2616                active.expanded_tool_calls.insert(tool_call_id);
2617            });
2618            cx.notify();
2619        }
2620    }
2621
2622    #[cfg(any(test, feature = "test-support"))]
2623    pub fn set_updated_at(&mut self, updated_at: Instant, cx: &mut Context<Self>) {
2624        let Some(connected) = self.as_connected_mut() else {
2625            return;
2626        };
2627
2628        connected.conversation.update(cx, |conversation, _cx| {
2629            conversation.updated_at = Some(updated_at);
2630        });
2631    }
2632}
2633
2634impl Render for ConversationView {
2635    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2636        self.sync_queued_message_editors(window, cx);
2637        let v2_flag = cx.has_flag::<AgentV2FeatureFlag>();
2638
2639        v_flex()
2640            .track_focus(&self.focus_handle)
2641            .size_full()
2642            .bg(cx.theme().colors().panel_background)
2643            .child(match &self.server_state {
2644                ServerState::Loading { .. } => v_flex()
2645                    .flex_1()
2646                    .when(v2_flag, |this| {
2647                        this.size_full().items_center().justify_center().child(
2648                            Label::new("Loading…").color(Color::Muted).with_animation(
2649                                "loading-agent-label",
2650                                Animation::new(Duration::from_secs(2))
2651                                    .repeat()
2652                                    .with_easing(pulsating_between(0.3, 0.7)),
2653                                |label, delta| label.alpha(delta),
2654                            ),
2655                        )
2656                    })
2657                    .into_any(),
2658                ServerState::LoadError { error: e, .. } => v_flex()
2659                    .flex_1()
2660                    .size_full()
2661                    .items_center()
2662                    .justify_end()
2663                    .child(self.render_load_error(e, window, cx))
2664                    .into_any(),
2665                ServerState::Connected(ConnectedServerState {
2666                    connection,
2667                    auth_state:
2668                        AuthState::Unauthenticated {
2669                            description,
2670                            configuration_view,
2671                            pending_auth_method,
2672                            _subscription,
2673                        },
2674                    ..
2675                }) => v_flex()
2676                    .flex_1()
2677                    .size_full()
2678                    .justify_end()
2679                    .child(self.render_auth_required_state(
2680                        connection,
2681                        description.as_ref(),
2682                        configuration_view.as_ref(),
2683                        pending_auth_method.as_ref(),
2684                        window,
2685                        cx,
2686                    ))
2687                    .into_any_element(),
2688                ServerState::Connected(connected) => {
2689                    if let Some(view) = connected.active_view() {
2690                        view.clone().into_any_element()
2691                    } else {
2692                        debug_panic!("This state should never be reached");
2693                        div().into_any_element()
2694                    }
2695                }
2696            })
2697    }
2698}
2699
2700fn plan_label_markdown_style(
2701    status: &acp::PlanEntryStatus,
2702    window: &Window,
2703    cx: &App,
2704) -> MarkdownStyle {
2705    let default_md_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
2706
2707    MarkdownStyle {
2708        base_text_style: TextStyle {
2709            color: cx.theme().colors().text_muted,
2710            strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
2711                Some(gpui::StrikethroughStyle {
2712                    thickness: px(1.),
2713                    color: Some(cx.theme().colors().text_muted.opacity(0.8)),
2714                })
2715            } else {
2716                None
2717            },
2718            ..default_md_style.base_text_style
2719        },
2720        ..default_md_style
2721    }
2722}
2723
2724#[cfg(test)]
2725pub(crate) mod tests {
2726    use acp_thread::{
2727        AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, StubAgentConnection,
2728    };
2729    use action_log::ActionLog;
2730    use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
2731    use agent_client_protocol::SessionId;
2732    use assistant_text_thread::TextThreadStore;
2733    use editor::MultiBufferOffset;
2734    use fs::FakeFs;
2735    use gpui::{EventEmitter, TestAppContext, VisualTestContext};
2736    use parking_lot::Mutex;
2737    use project::Project;
2738    use serde_json::json;
2739    use settings::SettingsStore;
2740    use std::any::Any;
2741    use std::path::{Path, PathBuf};
2742    use std::rc::Rc;
2743    use std::sync::Arc;
2744    use workspace::{Item, MultiWorkspace};
2745
2746    use crate::agent_panel;
2747
2748    use super::*;
2749
2750    #[gpui::test]
2751    async fn test_drop(cx: &mut TestAppContext) {
2752        init_test(cx);
2753
2754        let (conversation_view, _cx) =
2755            setup_conversation_view(StubAgentServer::default_response(), cx).await;
2756        let weak_view = conversation_view.downgrade();
2757        drop(conversation_view);
2758        assert!(!weak_view.is_upgradable());
2759    }
2760
2761    #[gpui::test]
2762    async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) {
2763        init_test(cx);
2764
2765        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2766            panic!("expected prompt from external source to sanitize successfully");
2767        };
2768        let initial_content = AgentInitialContent::FromExternalSource(prompt);
2769
2770        let (conversation_view, cx) = setup_conversation_view_with_initial_content(
2771            StubAgentServer::default_response(),
2772            initial_content,
2773            cx,
2774        )
2775        .await;
2776
2777        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
2778            assert!(view.show_external_source_prompt_warning);
2779            assert_eq!(view.thread.read(cx).entries().len(), 0);
2780            assert_eq!(view.message_editor.read(cx).text(cx), "Write me a script");
2781        });
2782    }
2783
2784    #[gpui::test]
2785    async fn test_external_source_prompt_warning_clears_after_send(cx: &mut TestAppContext) {
2786        init_test(cx);
2787
2788        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2789            panic!("expected prompt from external source to sanitize successfully");
2790        };
2791        let initial_content = AgentInitialContent::FromExternalSource(prompt);
2792
2793        let (conversation_view, cx) = setup_conversation_view_with_initial_content(
2794            StubAgentServer::default_response(),
2795            initial_content,
2796            cx,
2797        )
2798        .await;
2799
2800        active_thread(&conversation_view, cx)
2801            .update_in(cx, |view, window, cx| view.send(window, cx));
2802        cx.run_until_parked();
2803
2804        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
2805            assert!(!view.show_external_source_prompt_warning);
2806            assert_eq!(view.message_editor.read(cx).text(cx), "");
2807            assert_eq!(view.thread.read(cx).entries().len(), 2);
2808        });
2809    }
2810
2811    #[gpui::test]
2812    async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
2813        init_test(cx);
2814
2815        let (conversation_view, cx) =
2816            setup_conversation_view(StubAgentServer::default_response(), cx).await;
2817
2818        let message_editor = message_editor(&conversation_view, cx);
2819        message_editor.update_in(cx, |editor, window, cx| {
2820            editor.set_text("Hello", window, cx);
2821        });
2822
2823        cx.deactivate_window();
2824
2825        active_thread(&conversation_view, cx)
2826            .update_in(cx, |view, window, cx| view.send(window, cx));
2827
2828        cx.run_until_parked();
2829
2830        assert!(
2831            cx.windows()
2832                .iter()
2833                .any(|window| window.downcast::<AgentNotification>().is_some())
2834        );
2835    }
2836
2837    #[gpui::test]
2838    async fn test_notification_for_error(cx: &mut TestAppContext) {
2839        init_test(cx);
2840
2841        let (conversation_view, cx) =
2842            setup_conversation_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
2843
2844        let message_editor = message_editor(&conversation_view, cx);
2845        message_editor.update_in(cx, |editor, window, cx| {
2846            editor.set_text("Hello", window, cx);
2847        });
2848
2849        cx.deactivate_window();
2850
2851        active_thread(&conversation_view, cx)
2852            .update_in(cx, |view, window, cx| view.send(window, cx));
2853
2854        cx.run_until_parked();
2855
2856        assert!(
2857            cx.windows()
2858                .iter()
2859                .any(|window| window.downcast::<AgentNotification>().is_some())
2860        );
2861    }
2862
2863    #[gpui::test]
2864    async fn test_recent_history_refreshes_when_history_cache_updated(cx: &mut TestAppContext) {
2865        init_test(cx);
2866
2867        let session_a = AgentSessionInfo::new(SessionId::new("session-a"));
2868        let session_b = AgentSessionInfo::new(SessionId::new("session-b"));
2869
2870        // Use a connection that provides a session list so ThreadHistory is created
2871        let (conversation_view, history, cx) = setup_thread_view_with_history(
2872            StubAgentServer::new(SessionHistoryConnection::new(vec![session_a.clone()])),
2873            cx,
2874        )
2875        .await;
2876
2877        // Initially has session_a from the connection's session list
2878        active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
2879            assert_eq!(view.recent_history_entries.len(), 1);
2880            assert_eq!(
2881                view.recent_history_entries[0].session_id,
2882                session_a.session_id
2883            );
2884        });
2885
2886        // Swap to a different session list
2887        let list_b: Rc<dyn AgentSessionList> =
2888            Rc::new(StubSessionList::new(vec![session_b.clone()]));
2889        history.update(cx, |history, cx| {
2890            history.set_session_list(list_b, cx);
2891        });
2892        cx.run_until_parked();
2893
2894        active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
2895            assert_eq!(view.recent_history_entries.len(), 1);
2896            assert_eq!(
2897                view.recent_history_entries[0].session_id,
2898                session_b.session_id
2899            );
2900        });
2901    }
2902
2903    #[gpui::test]
2904    async fn test_new_thread_creation_triggers_session_list_refresh(cx: &mut TestAppContext) {
2905        init_test(cx);
2906
2907        let session = AgentSessionInfo::new(SessionId::new("history-session"));
2908        let (conversation_view, _history, cx) = setup_thread_view_with_history(
2909            StubAgentServer::new(SessionHistoryConnection::new(vec![session.clone()])),
2910            cx,
2911        )
2912        .await;
2913
2914        active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
2915            assert_eq!(view.recent_history_entries.len(), 1);
2916            assert_eq!(
2917                view.recent_history_entries[0].session_id,
2918                session.session_id
2919            );
2920        });
2921    }
2922
2923    #[gpui::test]
2924    async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
2925        init_test(cx);
2926
2927        let fs = FakeFs::new(cx.executor());
2928        let project = Project::test(fs, [], cx).await;
2929        let (multi_workspace, cx) =
2930            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2931        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2932
2933        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
2934        let connection_store =
2935            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
2936
2937        let conversation_view = cx.update(|window, cx| {
2938            cx.new(|cx| {
2939                ConversationView::new(
2940                    Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
2941                    connection_store,
2942                    Agent::Custom { id: "Test".into() },
2943                    Some(SessionId::new("resume-session")),
2944                    None,
2945                    None,
2946                    None,
2947                    workspace.downgrade(),
2948                    project,
2949                    Some(thread_store),
2950                    None,
2951                    window,
2952                    cx,
2953                )
2954            })
2955        });
2956
2957        cx.run_until_parked();
2958
2959        conversation_view.read_with(cx, |view, cx| {
2960            let state = view.active_thread().unwrap();
2961            assert!(state.read(cx).resumed_without_history);
2962            assert_eq!(state.read(cx).list_state.item_count(), 0);
2963        });
2964    }
2965
2966    #[gpui::test]
2967    async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) {
2968        init_test(cx);
2969
2970        let fs = FakeFs::new(cx.executor());
2971        fs.insert_tree(
2972            "/project",
2973            json!({
2974                "subdir": {
2975                    "file.txt": "hello"
2976                }
2977            }),
2978        )
2979        .await;
2980        let project = Project::test(fs, [Path::new("/project")], cx).await;
2981        let (multi_workspace, cx) =
2982            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2983        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2984
2985        let connection = CwdCapturingConnection::new();
2986        let captured_cwd = connection.captured_work_dirs.clone();
2987
2988        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
2989        let connection_store =
2990            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
2991
2992        let _conversation_view = cx.update(|window, cx| {
2993            cx.new(|cx| {
2994                ConversationView::new(
2995                    Rc::new(StubAgentServer::new(connection)),
2996                    connection_store,
2997                    Agent::Custom { id: "Test".into() },
2998                    Some(SessionId::new("session-1")),
2999                    Some(PathList::new(&[PathBuf::from("/project/subdir")])),
3000                    None,
3001                    None,
3002                    workspace.downgrade(),
3003                    project,
3004                    Some(thread_store),
3005                    None,
3006                    window,
3007                    cx,
3008                )
3009            })
3010        });
3011
3012        cx.run_until_parked();
3013
3014        assert_eq!(
3015            captured_cwd.lock().as_ref().unwrap(),
3016            &PathList::new(&[Path::new("/project/subdir")]),
3017            "Should use session cwd when it's inside the project"
3018        );
3019    }
3020
3021    #[gpui::test]
3022    async fn test_refusal_handling(cx: &mut TestAppContext) {
3023        init_test(cx);
3024
3025        let (conversation_view, cx) =
3026            setup_conversation_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
3027
3028        let message_editor = message_editor(&conversation_view, cx);
3029        message_editor.update_in(cx, |editor, window, cx| {
3030            editor.set_text("Do something harmful", window, cx);
3031        });
3032
3033        active_thread(&conversation_view, cx)
3034            .update_in(cx, |view, window, cx| view.send(window, cx));
3035
3036        cx.run_until_parked();
3037
3038        // Check that the refusal error is set
3039        conversation_view.read_with(cx, |thread_view, cx| {
3040            let state = thread_view.active_thread().unwrap();
3041            assert!(
3042                matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)),
3043                "Expected refusal error to be set"
3044            );
3045        });
3046    }
3047
3048    #[gpui::test]
3049    async fn test_connect_failure_transitions_to_load_error(cx: &mut TestAppContext) {
3050        init_test(cx);
3051
3052        let (conversation_view, cx) = setup_conversation_view(FailingAgentServer, cx).await;
3053
3054        conversation_view.read_with(cx, |view, cx| {
3055            let title = view.title(cx);
3056            assert_eq!(
3057                title.as_ref(),
3058                "Error Loading Codex CLI",
3059                "Tab title should show the agent name with an error prefix"
3060            );
3061            match &view.server_state {
3062                ServerState::LoadError {
3063                    error: LoadError::Other(msg),
3064                    ..
3065                } => {
3066                    assert!(
3067                        msg.contains("Invalid gzip header"),
3068                        "Error callout should contain the underlying extraction error, got: {msg}"
3069                    );
3070                }
3071                other => panic!(
3072                    "Expected LoadError::Other, got: {}",
3073                    match other {
3074                        ServerState::Loading(_) => "Loading (stuck!)",
3075                        ServerState::LoadError { .. } => "LoadError (wrong variant)",
3076                        ServerState::Connected(_) => "Connected",
3077                    }
3078                ),
3079            }
3080        });
3081    }
3082
3083    #[gpui::test]
3084    async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) {
3085        init_test(cx);
3086
3087        let connection = AuthGatedAgentConnection::new();
3088        let (conversation_view, cx) =
3089            setup_conversation_view(StubAgentServer::new(connection), cx).await;
3090
3091        // When new_session returns AuthRequired, the server should transition
3092        // to Connected + Unauthenticated rather than getting stuck in Loading.
3093        conversation_view.read_with(cx, |view, _cx| {
3094            let connected = view
3095                .as_connected()
3096                .expect("Should be in Connected state even though auth is required");
3097            assert!(
3098                !connected.auth_state.is_ok(),
3099                "Auth state should be Unauthenticated"
3100            );
3101            assert!(
3102                connected.active_id.is_none(),
3103                "There should be no active thread since no session was created"
3104            );
3105            assert!(
3106                connected.threads.is_empty(),
3107                "There should be no threads since no session was created"
3108            );
3109        });
3110
3111        conversation_view.read_with(cx, |view, _cx| {
3112            assert!(
3113                view.active_thread().is_none(),
3114                "active_thread() should be None when unauthenticated without a session"
3115            );
3116        });
3117
3118        // Authenticate using the real authenticate flow on ConnectionView.
3119        // This calls connection.authenticate(), which flips the internal flag,
3120        // then on success triggers reset() -> new_session() which now succeeds.
3121        conversation_view.update_in(cx, |view, window, cx| {
3122            view.authenticate(
3123                acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID),
3124                window,
3125                cx,
3126            );
3127        });
3128        cx.run_until_parked();
3129
3130        // After auth, the server should have an active thread in the Ok state.
3131        conversation_view.read_with(cx, |view, cx| {
3132            let connected = view
3133                .as_connected()
3134                .expect("Should still be in Connected state after auth");
3135            assert!(connected.auth_state.is_ok(), "Auth state should be Ok");
3136            assert!(
3137                connected.active_id.is_some(),
3138                "There should be an active thread after successful auth"
3139            );
3140            assert_eq!(
3141                connected.threads.len(),
3142                1,
3143                "There should be exactly one thread"
3144            );
3145
3146            let active = view
3147                .active_thread()
3148                .expect("active_thread() should return the new thread");
3149            assert!(
3150                active.read(cx).thread_error.is_none(),
3151                "The new thread should have no errors"
3152            );
3153        });
3154    }
3155
3156    #[gpui::test]
3157    async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3158        init_test(cx);
3159
3160        let tool_call_id = acp::ToolCallId::new("1");
3161        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label")
3162            .kind(acp::ToolKind::Edit)
3163            .content(vec!["hi".into()]);
3164        let connection =
3165            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
3166                tool_call_id,
3167                PermissionOptions::Flat(vec![acp::PermissionOption::new(
3168                    "1",
3169                    "Allow",
3170                    acp::PermissionOptionKind::AllowOnce,
3171                )]),
3172            )]));
3173
3174        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
3175
3176        let (conversation_view, cx) =
3177            setup_conversation_view(StubAgentServer::new(connection), cx).await;
3178
3179        let message_editor = message_editor(&conversation_view, cx);
3180        message_editor.update_in(cx, |editor, window, cx| {
3181            editor.set_text("Hello", window, cx);
3182        });
3183
3184        cx.deactivate_window();
3185
3186        active_thread(&conversation_view, cx)
3187            .update_in(cx, |view, window, cx| view.send(window, cx));
3188
3189        cx.run_until_parked();
3190
3191        assert!(
3192            cx.windows()
3193                .iter()
3194                .any(|window| window.downcast::<AgentNotification>().is_some())
3195        );
3196    }
3197
3198    #[gpui::test]
3199    async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) {
3200        init_test(cx);
3201
3202        let (conversation_view, cx) =
3203            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3204
3205        add_to_workspace(conversation_view.clone(), cx);
3206
3207        let message_editor = message_editor(&conversation_view, cx);
3208
3209        message_editor.update_in(cx, |editor, window, cx| {
3210            editor.set_text("Hello", window, cx);
3211        });
3212
3213        // Window is active (don't deactivate), but panel will be hidden
3214        // Note: In the test environment, the panel is not actually added to the dock,
3215        // so is_agent_panel_hidden will return true
3216
3217        active_thread(&conversation_view, cx)
3218            .update_in(cx, |view, window, cx| view.send(window, cx));
3219
3220        cx.run_until_parked();
3221
3222        // Should show notification because window is active but panel is hidden
3223        assert!(
3224            cx.windows()
3225                .iter()
3226                .any(|window| window.downcast::<AgentNotification>().is_some()),
3227            "Expected notification when panel is hidden"
3228        );
3229    }
3230
3231    #[gpui::test]
3232    async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) {
3233        init_test(cx);
3234
3235        let (conversation_view, cx) =
3236            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3237
3238        let message_editor = message_editor(&conversation_view, cx);
3239        message_editor.update_in(cx, |editor, window, cx| {
3240            editor.set_text("Hello", window, cx);
3241        });
3242
3243        // Deactivate window - should show notification regardless of setting
3244        cx.deactivate_window();
3245
3246        active_thread(&conversation_view, cx)
3247            .update_in(cx, |view, window, cx| view.send(window, cx));
3248
3249        cx.run_until_parked();
3250
3251        // Should still show notification when window is inactive (existing behavior)
3252        assert!(
3253            cx.windows()
3254                .iter()
3255                .any(|window| window.downcast::<AgentNotification>().is_some()),
3256            "Expected notification when window is inactive"
3257        );
3258    }
3259
3260    #[gpui::test]
3261    async fn test_notification_when_workspace_is_background_in_multi_workspace(
3262        cx: &mut TestAppContext,
3263    ) {
3264        init_test(cx);
3265
3266        // Enable multi-workspace feature flag and init globals needed by AgentPanel
3267        let fs = FakeFs::new(cx.executor());
3268
3269        cx.update(|cx| {
3270            cx.update_flags(true, vec!["agent-v2".to_string()]);
3271            agent::ThreadStore::init_global(cx);
3272            language_model::LanguageModelRegistry::test(cx);
3273            <dyn Fs>::set_global(fs.clone(), cx);
3274        });
3275
3276        let project1 = Project::test(fs.clone(), [], cx).await;
3277
3278        // Create a MultiWorkspace window with one workspace
3279        let multi_workspace_handle =
3280            cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
3281
3282        // Get workspace 1 (the initial workspace)
3283        let workspace1 = multi_workspace_handle
3284            .read_with(cx, |mw, _cx| mw.workspace().clone())
3285            .unwrap();
3286
3287        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3288
3289        workspace1.update_in(cx, |workspace, window, cx| {
3290            let text_thread_store =
3291                cx.new(|cx| TextThreadStore::fake(workspace.project().clone(), cx));
3292            let panel =
3293                cx.new(|cx| crate::AgentPanel::new(workspace, text_thread_store, None, window, cx));
3294            workspace.add_panel(panel, window, cx);
3295
3296            // Open the dock and activate the agent panel so it's visible
3297            workspace.focus_panel::<crate::AgentPanel>(window, cx);
3298        });
3299
3300        cx.run_until_parked();
3301
3302        cx.read(|cx| {
3303            assert!(
3304                crate::AgentPanel::is_visible(&workspace1, cx),
3305                "AgentPanel should be visible in workspace1's dock"
3306            );
3307        });
3308
3309        // Set up thread view in workspace 1
3310        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3311        let connection_store =
3312            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
3313
3314        let agent = StubAgentServer::default_response();
3315        let conversation_view = cx.update(|window, cx| {
3316            cx.new(|cx| {
3317                ConversationView::new(
3318                    Rc::new(agent),
3319                    connection_store,
3320                    Agent::Custom { id: "Test".into() },
3321                    None,
3322                    None,
3323                    None,
3324                    None,
3325                    workspace1.downgrade(),
3326                    project1.clone(),
3327                    Some(thread_store),
3328                    None,
3329                    window,
3330                    cx,
3331                )
3332            })
3333        });
3334        cx.run_until_parked();
3335
3336        let message_editor = message_editor(&conversation_view, cx);
3337        message_editor.update_in(cx, |editor, window, cx| {
3338            editor.set_text("Hello", window, cx);
3339        });
3340
3341        // Create a second workspace and switch to it.
3342        // This makes workspace1 the "background" workspace.
3343        let project2 = Project::test(fs, [], cx).await;
3344        multi_workspace_handle
3345            .update(cx, |mw, window, cx| {
3346                mw.test_add_workspace(project2, window, cx);
3347            })
3348            .unwrap();
3349
3350        cx.run_until_parked();
3351
3352        // Verify workspace1 is no longer the active workspace
3353        multi_workspace_handle
3354            .read_with(cx, |mw, _cx| {
3355                assert_eq!(mw.active_workspace_index(), 1);
3356                assert_ne!(mw.workspace(), &workspace1);
3357            })
3358            .unwrap();
3359
3360        // Window is active, agent panel is visible in workspace1, but workspace1
3361        // is in the background. The notification should show because the user
3362        // can't actually see the agent panel.
3363        active_thread(&conversation_view, cx)
3364            .update_in(cx, |view, window, cx| view.send(window, cx));
3365
3366        cx.run_until_parked();
3367
3368        assert!(
3369            cx.windows()
3370                .iter()
3371                .any(|window| window.downcast::<AgentNotification>().is_some()),
3372            "Expected notification when workspace is in background within MultiWorkspace"
3373        );
3374
3375        // Also verify: clicking "View Panel" should switch to workspace1.
3376        cx.windows()
3377            .iter()
3378            .find_map(|window| window.downcast::<AgentNotification>())
3379            .unwrap()
3380            .update(cx, |window, _, cx| window.accept(cx))
3381            .unwrap();
3382
3383        cx.run_until_parked();
3384
3385        multi_workspace_handle
3386            .read_with(cx, |mw, _cx| {
3387                assert_eq!(
3388                    mw.workspace(),
3389                    &workspace1,
3390                    "Expected workspace1 to become the active workspace after accepting notification"
3391                );
3392            })
3393            .unwrap();
3394    }
3395
3396    #[gpui::test]
3397    async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
3398        init_test(cx);
3399
3400        // Set notify_when_agent_waiting to Never
3401        cx.update(|cx| {
3402            AgentSettings::override_global(
3403                AgentSettings {
3404                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
3405                    ..AgentSettings::get_global(cx).clone()
3406                },
3407                cx,
3408            );
3409        });
3410
3411        let (conversation_view, cx) =
3412            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3413
3414        let message_editor = message_editor(&conversation_view, cx);
3415        message_editor.update_in(cx, |editor, window, cx| {
3416            editor.set_text("Hello", window, cx);
3417        });
3418
3419        // Window is active
3420
3421        active_thread(&conversation_view, cx)
3422            .update_in(cx, |view, window, cx| view.send(window, cx));
3423
3424        cx.run_until_parked();
3425
3426        // Should NOT show notification because notify_when_agent_waiting is Never
3427        assert!(
3428            !cx.windows()
3429                .iter()
3430                .any(|window| window.downcast::<AgentNotification>().is_some()),
3431            "Expected no notification when notify_when_agent_waiting is Never"
3432        );
3433    }
3434
3435    #[gpui::test]
3436    async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
3437        init_test(cx);
3438
3439        let (conversation_view, cx) =
3440            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3441
3442        let weak_view = conversation_view.downgrade();
3443
3444        let message_editor = message_editor(&conversation_view, cx);
3445        message_editor.update_in(cx, |editor, window, cx| {
3446            editor.set_text("Hello", window, cx);
3447        });
3448
3449        cx.deactivate_window();
3450
3451        active_thread(&conversation_view, cx)
3452            .update_in(cx, |view, window, cx| view.send(window, cx));
3453
3454        cx.run_until_parked();
3455
3456        // Verify notification is shown
3457        assert!(
3458            cx.windows()
3459                .iter()
3460                .any(|window| window.downcast::<AgentNotification>().is_some()),
3461            "Expected notification to be shown"
3462        );
3463
3464        // Drop the thread view (simulating navigation to a new thread)
3465        drop(conversation_view);
3466        drop(message_editor);
3467        // Trigger an update to flush effects, which will call release_dropped_entities
3468        cx.update(|_window, _cx| {});
3469        cx.run_until_parked();
3470
3471        // Verify the entity was actually released
3472        assert!(
3473            !weak_view.is_upgradable(),
3474            "Thread view entity should be released after dropping"
3475        );
3476
3477        // The notification should be automatically closed via on_release
3478        assert!(
3479            !cx.windows()
3480                .iter()
3481                .any(|window| window.downcast::<AgentNotification>().is_some()),
3482            "Notification should be closed when thread view is dropped"
3483        );
3484    }
3485
3486    async fn setup_conversation_view(
3487        agent: impl AgentServer + 'static,
3488        cx: &mut TestAppContext,
3489    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
3490        let (conversation_view, _history, cx) =
3491            setup_conversation_view_with_history_and_initial_content(agent, None, cx).await;
3492        (conversation_view, cx)
3493    }
3494
3495    async fn setup_thread_view_with_history(
3496        agent: impl AgentServer + 'static,
3497        cx: &mut TestAppContext,
3498    ) -> (
3499        Entity<ConversationView>,
3500        Entity<ThreadHistory>,
3501        &mut VisualTestContext,
3502    ) {
3503        let (conversation_view, history, cx) =
3504            setup_conversation_view_with_history_and_initial_content(agent, None, cx).await;
3505        (conversation_view, history.expect("Missing history"), cx)
3506    }
3507
3508    async fn setup_conversation_view_with_initial_content(
3509        agent: impl AgentServer + 'static,
3510        initial_content: AgentInitialContent,
3511        cx: &mut TestAppContext,
3512    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
3513        let (conversation_view, _history, cx) =
3514            setup_conversation_view_with_history_and_initial_content(
3515                agent,
3516                Some(initial_content),
3517                cx,
3518            )
3519            .await;
3520        (conversation_view, cx)
3521    }
3522
3523    async fn setup_conversation_view_with_history_and_initial_content(
3524        agent: impl AgentServer + 'static,
3525        initial_content: Option<AgentInitialContent>,
3526        cx: &mut TestAppContext,
3527    ) -> (
3528        Entity<ConversationView>,
3529        Option<Entity<ThreadHistory>>,
3530        &mut VisualTestContext,
3531    ) {
3532        let fs = FakeFs::new(cx.executor());
3533        let project = Project::test(fs, [], cx).await;
3534        let (multi_workspace, cx) =
3535            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3536        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3537
3538        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3539        let connection_store =
3540            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3541
3542        let agent_key = Agent::Custom { id: "Test".into() };
3543
3544        let conversation_view = cx.update(|window, cx| {
3545            cx.new(|cx| {
3546                ConversationView::new(
3547                    Rc::new(agent),
3548                    connection_store.clone(),
3549                    agent_key.clone(),
3550                    None,
3551                    None,
3552                    None,
3553                    initial_content,
3554                    workspace.downgrade(),
3555                    project,
3556                    Some(thread_store),
3557                    None,
3558                    window,
3559                    cx,
3560                )
3561            })
3562        });
3563        cx.run_until_parked();
3564
3565        let history = cx.update(|_window, cx| {
3566            connection_store
3567                .read(cx)
3568                .entry(&agent_key)
3569                .and_then(|e| e.read(cx).history().cloned())
3570        });
3571
3572        (conversation_view, history, cx)
3573    }
3574
3575    fn add_to_workspace(conversation_view: Entity<ConversationView>, cx: &mut VisualTestContext) {
3576        let workspace =
3577            conversation_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
3578
3579        workspace
3580            .update_in(cx, |workspace, window, cx| {
3581                workspace.add_item_to_active_pane(
3582                    Box::new(cx.new(|_| ThreadViewItem(conversation_view.clone()))),
3583                    None,
3584                    true,
3585                    window,
3586                    cx,
3587                );
3588            })
3589            .unwrap();
3590    }
3591
3592    struct ThreadViewItem(Entity<ConversationView>);
3593
3594    impl Item for ThreadViewItem {
3595        type Event = ();
3596
3597        fn include_in_nav_history() -> bool {
3598            false
3599        }
3600
3601        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
3602            "Test".into()
3603        }
3604    }
3605
3606    impl EventEmitter<()> for ThreadViewItem {}
3607
3608    impl Focusable for ThreadViewItem {
3609        fn focus_handle(&self, cx: &App) -> FocusHandle {
3610            self.0.read(cx).focus_handle(cx)
3611        }
3612    }
3613
3614    impl Render for ThreadViewItem {
3615        fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3616            // Render the title editor in the element tree too. In the real app
3617            // it is part of the agent panel
3618            let title_editor = self
3619                .0
3620                .read(cx)
3621                .active_thread()
3622                .map(|t| t.read(cx).title_editor.clone());
3623
3624            v_flex().children(title_editor).child(self.0.clone())
3625        }
3626    }
3627
3628    pub(crate) struct StubAgentServer<C> {
3629        connection: C,
3630    }
3631
3632    impl<C> StubAgentServer<C> {
3633        pub(crate) fn new(connection: C) -> Self {
3634            Self { connection }
3635        }
3636    }
3637
3638    impl StubAgentServer<StubAgentConnection> {
3639        pub(crate) fn default_response() -> Self {
3640            let conn = StubAgentConnection::new();
3641            conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3642                acp::ContentChunk::new("Default response".into()),
3643            )]);
3644            Self::new(conn)
3645        }
3646    }
3647
3648    impl<C> AgentServer for StubAgentServer<C>
3649    where
3650        C: 'static + AgentConnection + Send + Clone,
3651    {
3652        fn logo(&self) -> ui::IconName {
3653            ui::IconName::ZedAgent
3654        }
3655
3656        fn agent_id(&self) -> AgentId {
3657            "Test".into()
3658        }
3659
3660        fn connect(
3661            &self,
3662            _delegate: AgentServerDelegate,
3663            _project: Entity<Project>,
3664            _cx: &mut App,
3665        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3666            Task::ready(Ok(Rc::new(self.connection.clone())))
3667        }
3668
3669        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3670            self
3671        }
3672    }
3673
3674    struct FailingAgentServer;
3675
3676    impl AgentServer for FailingAgentServer {
3677        fn logo(&self) -> ui::IconName {
3678            ui::IconName::AiOpenAi
3679        }
3680
3681        fn agent_id(&self) -> AgentId {
3682            AgentId::new("Codex CLI")
3683        }
3684
3685        fn connect(
3686            &self,
3687            _delegate: AgentServerDelegate,
3688            _project: Entity<Project>,
3689            _cx: &mut App,
3690        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3691            Task::ready(Err(anyhow!(
3692                "extracting downloaded asset for \
3693                 https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\
3694                 codex-acp-0.9.4-aarch64-pc-windows-msvc.zip: \
3695                 failed to iterate over archive: Invalid gzip header"
3696            )))
3697        }
3698
3699        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3700            self
3701        }
3702    }
3703
3704    #[derive(Clone)]
3705    struct StubSessionList {
3706        sessions: Vec<AgentSessionInfo>,
3707    }
3708
3709    impl StubSessionList {
3710        fn new(sessions: Vec<AgentSessionInfo>) -> Self {
3711            Self { sessions }
3712        }
3713    }
3714
3715    impl AgentSessionList for StubSessionList {
3716        fn list_sessions(
3717            &self,
3718            _request: AgentSessionListRequest,
3719            _cx: &mut App,
3720        ) -> Task<anyhow::Result<AgentSessionListResponse>> {
3721            Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
3722        }
3723
3724        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3725            self
3726        }
3727    }
3728
3729    #[derive(Clone)]
3730    struct SessionHistoryConnection {
3731        sessions: Vec<AgentSessionInfo>,
3732    }
3733
3734    impl SessionHistoryConnection {
3735        fn new(sessions: Vec<AgentSessionInfo>) -> Self {
3736            Self { sessions }
3737        }
3738    }
3739
3740    fn build_test_thread(
3741        connection: Rc<dyn AgentConnection>,
3742        project: Entity<Project>,
3743        name: &'static str,
3744        session_id: SessionId,
3745        cx: &mut App,
3746    ) -> Entity<AcpThread> {
3747        let action_log = cx.new(|_| ActionLog::new(project.clone()));
3748        cx.new(|cx| {
3749            AcpThread::new(
3750                None,
3751                Some(name.into()),
3752                None,
3753                connection,
3754                project,
3755                action_log,
3756                session_id,
3757                watch::Receiver::constant(
3758                    acp::PromptCapabilities::new()
3759                        .image(true)
3760                        .audio(true)
3761                        .embedded_context(true),
3762                ),
3763                cx,
3764            )
3765        })
3766    }
3767
3768    impl AgentConnection for SessionHistoryConnection {
3769        fn agent_id(&self) -> AgentId {
3770            AgentId::new("history-connection")
3771        }
3772
3773        fn telemetry_id(&self) -> SharedString {
3774            "history-connection".into()
3775        }
3776
3777        fn new_session(
3778            self: Rc<Self>,
3779            project: Entity<Project>,
3780            _work_dirs: PathList,
3781            cx: &mut App,
3782        ) -> Task<anyhow::Result<Entity<AcpThread>>> {
3783            let thread = build_test_thread(
3784                self,
3785                project,
3786                "SessionHistoryConnection",
3787                SessionId::new("history-session"),
3788                cx,
3789            );
3790            Task::ready(Ok(thread))
3791        }
3792
3793        fn supports_load_session(&self) -> bool {
3794            true
3795        }
3796
3797        fn session_list(&self, _cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
3798            Some(Rc::new(StubSessionList::new(self.sessions.clone())))
3799        }
3800
3801        fn auth_methods(&self) -> &[acp::AuthMethod] {
3802            &[]
3803        }
3804
3805        fn authenticate(
3806            &self,
3807            _method_id: acp::AuthMethodId,
3808            _cx: &mut App,
3809        ) -> Task<anyhow::Result<()>> {
3810            Task::ready(Ok(()))
3811        }
3812
3813        fn prompt(
3814            &self,
3815            _id: Option<acp_thread::UserMessageId>,
3816            _params: acp::PromptRequest,
3817            _cx: &mut App,
3818        ) -> Task<anyhow::Result<acp::PromptResponse>> {
3819            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
3820        }
3821
3822        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
3823
3824        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3825            self
3826        }
3827    }
3828
3829    #[derive(Clone)]
3830    struct ResumeOnlyAgentConnection;
3831
3832    impl AgentConnection for ResumeOnlyAgentConnection {
3833        fn agent_id(&self) -> AgentId {
3834            AgentId::new("resume-only")
3835        }
3836
3837        fn telemetry_id(&self) -> SharedString {
3838            "resume-only".into()
3839        }
3840
3841        fn new_session(
3842            self: Rc<Self>,
3843            project: Entity<Project>,
3844            _work_dirs: PathList,
3845            cx: &mut gpui::App,
3846        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3847            let thread = build_test_thread(
3848                self,
3849                project,
3850                "ResumeOnlyAgentConnection",
3851                SessionId::new("new-session"),
3852                cx,
3853            );
3854            Task::ready(Ok(thread))
3855        }
3856
3857        fn supports_resume_session(&self) -> bool {
3858            true
3859        }
3860
3861        fn resume_session(
3862            self: Rc<Self>,
3863            session_id: acp::SessionId,
3864            project: Entity<Project>,
3865            _work_dirs: PathList,
3866            _title: Option<SharedString>,
3867            cx: &mut App,
3868        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3869            let thread =
3870                build_test_thread(self, project, "ResumeOnlyAgentConnection", session_id, cx);
3871            Task::ready(Ok(thread))
3872        }
3873
3874        fn auth_methods(&self) -> &[acp::AuthMethod] {
3875            &[]
3876        }
3877
3878        fn authenticate(
3879            &self,
3880            _method_id: acp::AuthMethodId,
3881            _cx: &mut App,
3882        ) -> Task<gpui::Result<()>> {
3883            Task::ready(Ok(()))
3884        }
3885
3886        fn prompt(
3887            &self,
3888            _id: Option<acp_thread::UserMessageId>,
3889            _params: acp::PromptRequest,
3890            _cx: &mut App,
3891        ) -> Task<gpui::Result<acp::PromptResponse>> {
3892            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
3893        }
3894
3895        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
3896
3897        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3898            self
3899        }
3900    }
3901
3902    /// Simulates an agent that requires authentication before a session can be
3903    /// created. `new_session` returns `AuthRequired` until `authenticate` is
3904    /// called with the correct method, after which sessions are created normally.
3905    #[derive(Clone)]
3906    struct AuthGatedAgentConnection {
3907        authenticated: Arc<Mutex<bool>>,
3908        auth_method: acp::AuthMethod,
3909    }
3910
3911    impl AuthGatedAgentConnection {
3912        const AUTH_METHOD_ID: &str = "test-login";
3913
3914        fn new() -> Self {
3915            Self {
3916                authenticated: Arc::new(Mutex::new(false)),
3917                auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
3918                    Self::AUTH_METHOD_ID,
3919                    "Test Login",
3920                )),
3921            }
3922        }
3923    }
3924
3925    impl AgentConnection for AuthGatedAgentConnection {
3926        fn agent_id(&self) -> AgentId {
3927            AgentId::new("auth-gated")
3928        }
3929
3930        fn telemetry_id(&self) -> SharedString {
3931            "auth-gated".into()
3932        }
3933
3934        fn new_session(
3935            self: Rc<Self>,
3936            project: Entity<Project>,
3937            work_dirs: PathList,
3938            cx: &mut gpui::App,
3939        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3940            if !*self.authenticated.lock() {
3941                return Task::ready(Err(acp_thread::AuthRequired::new()
3942                    .with_description("Sign in to continue".to_string())
3943                    .into()));
3944            }
3945
3946            let session_id = acp::SessionId::new("auth-gated-session");
3947            let action_log = cx.new(|_| ActionLog::new(project.clone()));
3948            Task::ready(Ok(cx.new(|cx| {
3949                AcpThread::new(
3950                    None,
3951                    None,
3952                    Some(work_dirs),
3953                    self,
3954                    project,
3955                    action_log,
3956                    session_id,
3957                    watch::Receiver::constant(
3958                        acp::PromptCapabilities::new()
3959                            .image(true)
3960                            .audio(true)
3961                            .embedded_context(true),
3962                    ),
3963                    cx,
3964                )
3965            })))
3966        }
3967
3968        fn auth_methods(&self) -> &[acp::AuthMethod] {
3969            std::slice::from_ref(&self.auth_method)
3970        }
3971
3972        fn authenticate(
3973            &self,
3974            method_id: acp::AuthMethodId,
3975            _cx: &mut App,
3976        ) -> Task<gpui::Result<()>> {
3977            if &method_id == self.auth_method.id() {
3978                *self.authenticated.lock() = true;
3979                Task::ready(Ok(()))
3980            } else {
3981                Task::ready(Err(anyhow::anyhow!("Unknown auth method")))
3982            }
3983        }
3984
3985        fn prompt(
3986            &self,
3987            _id: Option<acp_thread::UserMessageId>,
3988            _params: acp::PromptRequest,
3989            _cx: &mut App,
3990        ) -> Task<gpui::Result<acp::PromptResponse>> {
3991            unimplemented!()
3992        }
3993
3994        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
3995            unimplemented!()
3996        }
3997
3998        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3999            self
4000        }
4001    }
4002
4003    #[derive(Clone)]
4004    struct SaboteurAgentConnection;
4005
4006    impl AgentConnection for SaboteurAgentConnection {
4007        fn agent_id(&self) -> AgentId {
4008            AgentId::new("saboteur")
4009        }
4010
4011        fn telemetry_id(&self) -> SharedString {
4012            "saboteur".into()
4013        }
4014
4015        fn new_session(
4016            self: Rc<Self>,
4017            project: Entity<Project>,
4018            work_dirs: PathList,
4019            cx: &mut gpui::App,
4020        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4021            Task::ready(Ok(cx.new(|cx| {
4022                let action_log = cx.new(|_| ActionLog::new(project.clone()));
4023                AcpThread::new(
4024                    None,
4025                    None,
4026                    Some(work_dirs),
4027                    self,
4028                    project,
4029                    action_log,
4030                    SessionId::new("test"),
4031                    watch::Receiver::constant(
4032                        acp::PromptCapabilities::new()
4033                            .image(true)
4034                            .audio(true)
4035                            .embedded_context(true),
4036                    ),
4037                    cx,
4038                )
4039            })))
4040        }
4041
4042        fn auth_methods(&self) -> &[acp::AuthMethod] {
4043            &[]
4044        }
4045
4046        fn authenticate(
4047            &self,
4048            _method_id: acp::AuthMethodId,
4049            _cx: &mut App,
4050        ) -> Task<gpui::Result<()>> {
4051            unimplemented!()
4052        }
4053
4054        fn prompt(
4055            &self,
4056            _id: Option<acp_thread::UserMessageId>,
4057            _params: acp::PromptRequest,
4058            _cx: &mut App,
4059        ) -> Task<gpui::Result<acp::PromptResponse>> {
4060            Task::ready(Err(anyhow::anyhow!("Error prompting")))
4061        }
4062
4063        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4064            unimplemented!()
4065        }
4066
4067        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4068            self
4069        }
4070    }
4071
4072    /// Simulates a model which always returns a refusal response
4073    #[derive(Clone)]
4074    struct RefusalAgentConnection;
4075
4076    impl AgentConnection for RefusalAgentConnection {
4077        fn agent_id(&self) -> AgentId {
4078            AgentId::new("refusal")
4079        }
4080
4081        fn telemetry_id(&self) -> SharedString {
4082            "refusal".into()
4083        }
4084
4085        fn new_session(
4086            self: Rc<Self>,
4087            project: Entity<Project>,
4088            work_dirs: PathList,
4089            cx: &mut gpui::App,
4090        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4091            Task::ready(Ok(cx.new(|cx| {
4092                let action_log = cx.new(|_| ActionLog::new(project.clone()));
4093                AcpThread::new(
4094                    None,
4095                    None,
4096                    Some(work_dirs),
4097                    self,
4098                    project,
4099                    action_log,
4100                    SessionId::new("test"),
4101                    watch::Receiver::constant(
4102                        acp::PromptCapabilities::new()
4103                            .image(true)
4104                            .audio(true)
4105                            .embedded_context(true),
4106                    ),
4107                    cx,
4108                )
4109            })))
4110        }
4111
4112        fn auth_methods(&self) -> &[acp::AuthMethod] {
4113            &[]
4114        }
4115
4116        fn authenticate(
4117            &self,
4118            _method_id: acp::AuthMethodId,
4119            _cx: &mut App,
4120        ) -> Task<gpui::Result<()>> {
4121            unimplemented!()
4122        }
4123
4124        fn prompt(
4125            &self,
4126            _id: Option<acp_thread::UserMessageId>,
4127            _params: acp::PromptRequest,
4128            _cx: &mut App,
4129        ) -> Task<gpui::Result<acp::PromptResponse>> {
4130            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal)))
4131        }
4132
4133        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4134            unimplemented!()
4135        }
4136
4137        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4138            self
4139        }
4140    }
4141
4142    #[derive(Clone)]
4143    struct CwdCapturingConnection {
4144        captured_work_dirs: Arc<Mutex<Option<PathList>>>,
4145    }
4146
4147    impl CwdCapturingConnection {
4148        fn new() -> Self {
4149            Self {
4150                captured_work_dirs: Arc::new(Mutex::new(None)),
4151            }
4152        }
4153    }
4154
4155    impl AgentConnection for CwdCapturingConnection {
4156        fn agent_id(&self) -> AgentId {
4157            AgentId::new("cwd-capturing")
4158        }
4159
4160        fn telemetry_id(&self) -> SharedString {
4161            "cwd-capturing".into()
4162        }
4163
4164        fn new_session(
4165            self: Rc<Self>,
4166            project: Entity<Project>,
4167            work_dirs: PathList,
4168            cx: &mut gpui::App,
4169        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4170            *self.captured_work_dirs.lock() = Some(work_dirs.clone());
4171            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4172            let thread = cx.new(|cx| {
4173                AcpThread::new(
4174                    None,
4175                    None,
4176                    Some(work_dirs),
4177                    self.clone(),
4178                    project,
4179                    action_log,
4180                    SessionId::new("new-session"),
4181                    watch::Receiver::constant(
4182                        acp::PromptCapabilities::new()
4183                            .image(true)
4184                            .audio(true)
4185                            .embedded_context(true),
4186                    ),
4187                    cx,
4188                )
4189            });
4190            Task::ready(Ok(thread))
4191        }
4192
4193        fn supports_load_session(&self) -> bool {
4194            true
4195        }
4196
4197        fn load_session(
4198            self: Rc<Self>,
4199            session_id: acp::SessionId,
4200            project: Entity<Project>,
4201            work_dirs: PathList,
4202            _title: Option<SharedString>,
4203            cx: &mut App,
4204        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4205            *self.captured_work_dirs.lock() = Some(work_dirs.clone());
4206            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4207            let thread = cx.new(|cx| {
4208                AcpThread::new(
4209                    None,
4210                    None,
4211                    Some(work_dirs),
4212                    self.clone(),
4213                    project,
4214                    action_log,
4215                    session_id,
4216                    watch::Receiver::constant(
4217                        acp::PromptCapabilities::new()
4218                            .image(true)
4219                            .audio(true)
4220                            .embedded_context(true),
4221                    ),
4222                    cx,
4223                )
4224            });
4225            Task::ready(Ok(thread))
4226        }
4227
4228        fn auth_methods(&self) -> &[acp::AuthMethod] {
4229            &[]
4230        }
4231
4232        fn authenticate(
4233            &self,
4234            _method_id: acp::AuthMethodId,
4235            _cx: &mut App,
4236        ) -> Task<gpui::Result<()>> {
4237            Task::ready(Ok(()))
4238        }
4239
4240        fn prompt(
4241            &self,
4242            _id: Option<acp_thread::UserMessageId>,
4243            _params: acp::PromptRequest,
4244            _cx: &mut App,
4245        ) -> Task<gpui::Result<acp::PromptResponse>> {
4246            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4247        }
4248
4249        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4250
4251        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4252            self
4253        }
4254    }
4255
4256    pub(crate) fn init_test(cx: &mut TestAppContext) {
4257        cx.update(|cx| {
4258            let settings_store = SettingsStore::test(cx);
4259            cx.set_global(settings_store);
4260            SidebarThreadMetadataStore::init_global(cx);
4261            theme_settings::init(theme::LoadThemes::JustBase, cx);
4262            editor::init(cx);
4263            agent_panel::init(cx);
4264            release_channel::init(semver::Version::new(0, 0, 0), cx);
4265            prompt_store::init(cx)
4266        });
4267    }
4268
4269    fn active_thread(
4270        conversation_view: &Entity<ConversationView>,
4271        cx: &TestAppContext,
4272    ) -> Entity<ThreadView> {
4273        cx.read(|cx| {
4274            conversation_view
4275                .read(cx)
4276                .active_thread()
4277                .expect("No active thread")
4278                .clone()
4279        })
4280    }
4281
4282    fn message_editor(
4283        conversation_view: &Entity<ConversationView>,
4284        cx: &TestAppContext,
4285    ) -> Entity<MessageEditor> {
4286        let thread = active_thread(conversation_view, cx);
4287        cx.read(|cx| thread.read(cx).message_editor.clone())
4288    }
4289
4290    #[gpui::test]
4291    async fn test_rewind_views(cx: &mut TestAppContext) {
4292        init_test(cx);
4293
4294        let fs = FakeFs::new(cx.executor());
4295        fs.insert_tree(
4296            "/project",
4297            json!({
4298                "test1.txt": "old content 1",
4299                "test2.txt": "old content 2"
4300            }),
4301        )
4302        .await;
4303        let project = Project::test(fs, [Path::new("/project")], cx).await;
4304        let (multi_workspace, cx) =
4305            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4306        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4307
4308        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4309        let connection_store =
4310            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4311
4312        let connection = Rc::new(StubAgentConnection::new());
4313        let conversation_view = cx.update(|window, cx| {
4314            cx.new(|cx| {
4315                ConversationView::new(
4316                    Rc::new(StubAgentServer::new(connection.as_ref().clone())),
4317                    connection_store,
4318                    Agent::Custom { id: "Test".into() },
4319                    None,
4320                    None,
4321                    None,
4322                    None,
4323                    workspace.downgrade(),
4324                    project.clone(),
4325                    Some(thread_store.clone()),
4326                    None,
4327                    window,
4328                    cx,
4329                )
4330            })
4331        });
4332
4333        cx.run_until_parked();
4334
4335        let thread = conversation_view
4336            .read_with(cx, |view, cx| {
4337                view.active_thread().map(|r| r.read(cx).thread.clone())
4338            })
4339            .unwrap();
4340
4341        // First user message
4342        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4343            acp::ToolCall::new("tool1", "Edit file 1")
4344                .kind(acp::ToolKind::Edit)
4345                .status(acp::ToolCallStatus::Completed)
4346                .content(vec![acp::ToolCallContent::Diff(
4347                    acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"),
4348                )]),
4349        )]);
4350
4351        thread
4352            .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
4353            .await
4354            .unwrap();
4355        cx.run_until_parked();
4356
4357        thread.read_with(cx, |thread, _cx| {
4358            assert_eq!(thread.entries().len(), 2);
4359        });
4360
4361        conversation_view.read_with(cx, |view, cx| {
4362            let entry_view_state = view
4363                .active_thread()
4364                .map(|active| active.read(cx).entry_view_state.clone())
4365                .unwrap();
4366            entry_view_state.read_with(cx, |entry_view_state, _| {
4367                assert!(
4368                    entry_view_state
4369                        .entry(0)
4370                        .unwrap()
4371                        .message_editor()
4372                        .is_some()
4373                );
4374                assert!(entry_view_state.entry(1).unwrap().has_content());
4375            });
4376        });
4377
4378        // Second user message
4379        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4380            acp::ToolCall::new("tool2", "Edit file 2")
4381                .kind(acp::ToolKind::Edit)
4382                .status(acp::ToolCallStatus::Completed)
4383                .content(vec![acp::ToolCallContent::Diff(
4384                    acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"),
4385                )]),
4386        )]);
4387
4388        thread
4389            .update(cx, |thread, cx| thread.send_raw("Another one", cx))
4390            .await
4391            .unwrap();
4392        cx.run_until_parked();
4393
4394        let second_user_message_id = thread.read_with(cx, |thread, _| {
4395            assert_eq!(thread.entries().len(), 4);
4396            let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
4397                panic!();
4398            };
4399            user_message.id.clone().unwrap()
4400        });
4401
4402        conversation_view.read_with(cx, |view, cx| {
4403            let entry_view_state = view
4404                .active_thread()
4405                .unwrap()
4406                .read(cx)
4407                .entry_view_state
4408                .clone();
4409            entry_view_state.read_with(cx, |entry_view_state, _| {
4410                assert!(
4411                    entry_view_state
4412                        .entry(0)
4413                        .unwrap()
4414                        .message_editor()
4415                        .is_some()
4416                );
4417                assert!(entry_view_state.entry(1).unwrap().has_content());
4418                assert!(
4419                    entry_view_state
4420                        .entry(2)
4421                        .unwrap()
4422                        .message_editor()
4423                        .is_some()
4424                );
4425                assert!(entry_view_state.entry(3).unwrap().has_content());
4426            });
4427        });
4428
4429        // Rewind to first message
4430        thread
4431            .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
4432            .await
4433            .unwrap();
4434
4435        cx.run_until_parked();
4436
4437        thread.read_with(cx, |thread, _| {
4438            assert_eq!(thread.entries().len(), 2);
4439        });
4440
4441        conversation_view.read_with(cx, |view, cx| {
4442            let active = view.active_thread().unwrap();
4443            active
4444                .read(cx)
4445                .entry_view_state
4446                .read_with(cx, |entry_view_state, _| {
4447                    assert!(
4448                        entry_view_state
4449                            .entry(0)
4450                            .unwrap()
4451                            .message_editor()
4452                            .is_some()
4453                    );
4454                    assert!(entry_view_state.entry(1).unwrap().has_content());
4455
4456                    // Old views should be dropped
4457                    assert!(entry_view_state.entry(2).is_none());
4458                    assert!(entry_view_state.entry(3).is_none());
4459                });
4460        });
4461    }
4462
4463    #[gpui::test]
4464    async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
4465        init_test(cx);
4466
4467        let connection = StubAgentConnection::new();
4468
4469        // Each user prompt will result in a user message entry plus an agent message entry.
4470        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4471            acp::ContentChunk::new("Response 1".into()),
4472        )]);
4473
4474        let (conversation_view, cx) =
4475            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4476
4477        let thread = conversation_view
4478            .read_with(cx, |view, cx| {
4479                view.active_thread().map(|r| r.read(cx).thread.clone())
4480            })
4481            .unwrap();
4482
4483        thread
4484            .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
4485            .await
4486            .unwrap();
4487        cx.run_until_parked();
4488
4489        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4490            acp::ContentChunk::new("Response 2".into()),
4491        )]);
4492
4493        thread
4494            .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
4495            .await
4496            .unwrap();
4497        cx.run_until_parked();
4498
4499        // Move somewhere else first so we're not trivially already on the last user prompt.
4500        active_thread(&conversation_view, cx).update(cx, |view, cx| {
4501            view.scroll_to_top(cx);
4502        });
4503        cx.run_until_parked();
4504
4505        active_thread(&conversation_view, cx).update(cx, |view, cx| {
4506            view.scroll_to_most_recent_user_prompt(cx);
4507            let scroll_top = view.list_state.logical_scroll_top();
4508            // Entries layout is: [User1, Assistant1, User2, Assistant2]
4509            assert_eq!(scroll_top.item_ix, 2);
4510        });
4511    }
4512
4513    #[gpui::test]
4514    async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
4515        cx: &mut TestAppContext,
4516    ) {
4517        init_test(cx);
4518
4519        let (conversation_view, cx) =
4520            setup_conversation_view(StubAgentServer::default_response(), cx).await;
4521
4522        // With no entries, scrolling should be a no-op and must not panic.
4523        active_thread(&conversation_view, cx).update(cx, |view, cx| {
4524            view.scroll_to_most_recent_user_prompt(cx);
4525            let scroll_top = view.list_state.logical_scroll_top();
4526            assert_eq!(scroll_top.item_ix, 0);
4527        });
4528    }
4529
4530    #[gpui::test]
4531    async fn test_message_editing_cancel(cx: &mut TestAppContext) {
4532        init_test(cx);
4533
4534        let connection = StubAgentConnection::new();
4535
4536        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4537            acp::ContentChunk::new("Response".into()),
4538        )]);
4539
4540        let (conversation_view, cx) =
4541            setup_conversation_view(StubAgentServer::new(connection), cx).await;
4542        add_to_workspace(conversation_view.clone(), cx);
4543
4544        let message_editor = message_editor(&conversation_view, cx);
4545        message_editor.update_in(cx, |editor, window, cx| {
4546            editor.set_text("Original message to edit", window, cx);
4547        });
4548        active_thread(&conversation_view, cx)
4549            .update_in(cx, |view, window, cx| view.send(window, cx));
4550
4551        cx.run_until_parked();
4552
4553        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
4554            assert_eq!(
4555                view.active_thread()
4556                    .and_then(|active| active.read(cx).editing_message),
4557                None
4558            );
4559
4560            view.active_thread()
4561                .map(|active| &active.read(cx).entry_view_state)
4562                .as_ref()
4563                .unwrap()
4564                .read(cx)
4565                .entry(0)
4566                .unwrap()
4567                .message_editor()
4568                .unwrap()
4569                .clone()
4570        });
4571
4572        // Focus
4573        cx.focus(&user_message_editor);
4574        conversation_view.read_with(cx, |view, cx| {
4575            assert_eq!(
4576                view.active_thread()
4577                    .and_then(|active| active.read(cx).editing_message),
4578                Some(0)
4579            );
4580        });
4581
4582        // Edit
4583        user_message_editor.update_in(cx, |editor, window, cx| {
4584            editor.set_text("Edited message content", window, cx);
4585        });
4586
4587        // Cancel
4588        user_message_editor.update_in(cx, |_editor, window, cx| {
4589            window.dispatch_action(Box::new(editor::actions::Cancel), cx);
4590        });
4591
4592        conversation_view.read_with(cx, |view, cx| {
4593            assert_eq!(
4594                view.active_thread()
4595                    .and_then(|active| active.read(cx).editing_message),
4596                None
4597            );
4598        });
4599
4600        user_message_editor.read_with(cx, |editor, cx| {
4601            assert_eq!(editor.text(cx), "Original message to edit");
4602        });
4603    }
4604
4605    #[gpui::test]
4606    async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
4607        init_test(cx);
4608
4609        let connection = StubAgentConnection::new();
4610
4611        let (conversation_view, cx) =
4612            setup_conversation_view(StubAgentServer::new(connection), cx).await;
4613        add_to_workspace(conversation_view.clone(), cx);
4614
4615        let message_editor = message_editor(&conversation_view, cx);
4616        message_editor.update_in(cx, |editor, window, cx| {
4617            editor.set_text("", window, cx);
4618        });
4619
4620        let thread = cx.read(|cx| {
4621            conversation_view
4622                .read(cx)
4623                .active_thread()
4624                .unwrap()
4625                .read(cx)
4626                .thread
4627                .clone()
4628        });
4629        let entries_before = cx.read(|cx| thread.read(cx).entries().len());
4630
4631        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
4632            view.send(window, cx);
4633        });
4634        cx.run_until_parked();
4635
4636        let entries_after = cx.read(|cx| thread.read(cx).entries().len());
4637        assert_eq!(
4638            entries_before, entries_after,
4639            "No message should be sent when editor is empty"
4640        );
4641    }
4642
4643    #[gpui::test]
4644    async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
4645        init_test(cx);
4646
4647        let connection = StubAgentConnection::new();
4648
4649        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4650            acp::ContentChunk::new("Response".into()),
4651        )]);
4652
4653        let (conversation_view, cx) =
4654            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4655        add_to_workspace(conversation_view.clone(), cx);
4656
4657        let message_editor = message_editor(&conversation_view, cx);
4658        message_editor.update_in(cx, |editor, window, cx| {
4659            editor.set_text("Original message to edit", window, cx);
4660        });
4661        active_thread(&conversation_view, cx)
4662            .update_in(cx, |view, window, cx| view.send(window, cx));
4663
4664        cx.run_until_parked();
4665
4666        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
4667            assert_eq!(
4668                view.active_thread()
4669                    .and_then(|active| active.read(cx).editing_message),
4670                None
4671            );
4672            assert_eq!(
4673                view.active_thread()
4674                    .unwrap()
4675                    .read(cx)
4676                    .thread
4677                    .read(cx)
4678                    .entries()
4679                    .len(),
4680                2
4681            );
4682
4683            view.active_thread()
4684                .map(|active| &active.read(cx).entry_view_state)
4685                .as_ref()
4686                .unwrap()
4687                .read(cx)
4688                .entry(0)
4689                .unwrap()
4690                .message_editor()
4691                .unwrap()
4692                .clone()
4693        });
4694
4695        // Focus
4696        cx.focus(&user_message_editor);
4697
4698        // Edit
4699        user_message_editor.update_in(cx, |editor, window, cx| {
4700            editor.set_text("Edited message content", window, cx);
4701        });
4702
4703        // Send
4704        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4705            acp::ContentChunk::new("New Response".into()),
4706        )]);
4707
4708        user_message_editor.update_in(cx, |_editor, window, cx| {
4709            window.dispatch_action(Box::new(Chat), cx);
4710        });
4711
4712        cx.run_until_parked();
4713
4714        conversation_view.read_with(cx, |view, cx| {
4715            assert_eq!(
4716                view.active_thread()
4717                    .and_then(|active| active.read(cx).editing_message),
4718                None
4719            );
4720
4721            let entries = view
4722                .active_thread()
4723                .unwrap()
4724                .read(cx)
4725                .thread
4726                .read(cx)
4727                .entries();
4728            assert_eq!(entries.len(), 2);
4729            assert_eq!(
4730                entries[0].to_markdown(cx),
4731                "## User\n\nEdited message content\n\n"
4732            );
4733            assert_eq!(
4734                entries[1].to_markdown(cx),
4735                "## Assistant\n\nNew Response\n\n"
4736            );
4737
4738            let entry_view_state = view
4739                .active_thread()
4740                .map(|active| &active.read(cx).entry_view_state)
4741                .unwrap();
4742            let new_editor = entry_view_state.read_with(cx, |state, _cx| {
4743                assert!(!state.entry(1).unwrap().has_content());
4744                state.entry(0).unwrap().message_editor().unwrap().clone()
4745            });
4746
4747            assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
4748        })
4749    }
4750
4751    #[gpui::test]
4752    async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
4753        init_test(cx);
4754
4755        let connection = StubAgentConnection::new();
4756
4757        let (conversation_view, cx) =
4758            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4759        add_to_workspace(conversation_view.clone(), cx);
4760
4761        let message_editor = message_editor(&conversation_view, cx);
4762        message_editor.update_in(cx, |editor, window, cx| {
4763            editor.set_text("Original message to edit", window, cx);
4764        });
4765        active_thread(&conversation_view, cx)
4766            .update_in(cx, |view, window, cx| view.send(window, cx));
4767
4768        cx.run_until_parked();
4769
4770        let (user_message_editor, session_id) = conversation_view.read_with(cx, |view, cx| {
4771            let thread = view.active_thread().unwrap().read(cx).thread.read(cx);
4772            assert_eq!(thread.entries().len(), 1);
4773
4774            let editor = view
4775                .active_thread()
4776                .map(|active| &active.read(cx).entry_view_state)
4777                .as_ref()
4778                .unwrap()
4779                .read(cx)
4780                .entry(0)
4781                .unwrap()
4782                .message_editor()
4783                .unwrap()
4784                .clone();
4785
4786            (editor, thread.session_id().clone())
4787        });
4788
4789        // Focus
4790        cx.focus(&user_message_editor);
4791
4792        conversation_view.read_with(cx, |view, cx| {
4793            assert_eq!(
4794                view.active_thread()
4795                    .and_then(|active| active.read(cx).editing_message),
4796                Some(0)
4797            );
4798        });
4799
4800        // Edit
4801        user_message_editor.update_in(cx, |editor, window, cx| {
4802            editor.set_text("Edited message content", window, cx);
4803        });
4804
4805        conversation_view.read_with(cx, |view, cx| {
4806            assert_eq!(
4807                view.active_thread()
4808                    .and_then(|active| active.read(cx).editing_message),
4809                Some(0)
4810            );
4811        });
4812
4813        // Finish streaming response
4814        cx.update(|_, cx| {
4815            connection.send_update(
4816                session_id.clone(),
4817                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())),
4818                cx,
4819            );
4820            connection.end_turn(session_id, acp::StopReason::EndTurn);
4821        });
4822
4823        conversation_view.read_with(cx, |view, cx| {
4824            assert_eq!(
4825                view.active_thread()
4826                    .and_then(|active| active.read(cx).editing_message),
4827                Some(0)
4828            );
4829        });
4830
4831        cx.run_until_parked();
4832
4833        // Should still be editing
4834        cx.update(|window, cx| {
4835            assert!(user_message_editor.focus_handle(cx).is_focused(window));
4836            assert_eq!(
4837                conversation_view
4838                    .read(cx)
4839                    .active_thread()
4840                    .and_then(|active| active.read(cx).editing_message),
4841                Some(0)
4842            );
4843            assert_eq!(
4844                user_message_editor.read(cx).text(cx),
4845                "Edited message content"
4846            );
4847        });
4848    }
4849
4850    struct GeneratingThreadSetup {
4851        conversation_view: Entity<ConversationView>,
4852        thread: Entity<AcpThread>,
4853        message_editor: Entity<MessageEditor>,
4854    }
4855
4856    async fn setup_generating_thread(
4857        cx: &mut TestAppContext,
4858    ) -> (GeneratingThreadSetup, &mut VisualTestContext) {
4859        let connection = StubAgentConnection::new();
4860
4861        let (conversation_view, cx) =
4862            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4863        add_to_workspace(conversation_view.clone(), cx);
4864
4865        let message_editor = message_editor(&conversation_view, cx);
4866        message_editor.update_in(cx, |editor, window, cx| {
4867            editor.set_text("Hello", window, cx);
4868        });
4869        active_thread(&conversation_view, cx)
4870            .update_in(cx, |view, window, cx| view.send(window, cx));
4871
4872        let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
4873            let thread = view
4874                .active_thread()
4875                .as_ref()
4876                .unwrap()
4877                .read(cx)
4878                .thread
4879                .clone();
4880            (thread.clone(), thread.read(cx).session_id().clone())
4881        });
4882
4883        cx.run_until_parked();
4884
4885        cx.update(|_, cx| {
4886            connection.send_update(
4887                session_id.clone(),
4888                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
4889                    "Response chunk".into(),
4890                )),
4891                cx,
4892            );
4893        });
4894
4895        cx.run_until_parked();
4896
4897        thread.read_with(cx, |thread, _cx| {
4898            assert_eq!(thread.status(), ThreadStatus::Generating);
4899        });
4900
4901        (
4902            GeneratingThreadSetup {
4903                conversation_view,
4904                thread,
4905                message_editor,
4906            },
4907            cx,
4908        )
4909    }
4910
4911    #[gpui::test]
4912    async fn test_escape_cancels_generation_from_conversation_focus(cx: &mut TestAppContext) {
4913        init_test(cx);
4914
4915        let (setup, cx) = setup_generating_thread(cx).await;
4916
4917        let focus_handle = setup
4918            .conversation_view
4919            .read_with(cx, |view, cx| view.focus_handle(cx));
4920        cx.update(|window, cx| {
4921            window.focus(&focus_handle, cx);
4922        });
4923
4924        setup.conversation_view.update_in(cx, |_, window, cx| {
4925            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
4926        });
4927
4928        cx.run_until_parked();
4929
4930        setup.thread.read_with(cx, |thread, _cx| {
4931            assert_eq!(thread.status(), ThreadStatus::Idle);
4932        });
4933    }
4934
4935    #[gpui::test]
4936    async fn test_escape_cancels_generation_from_editor_focus(cx: &mut TestAppContext) {
4937        init_test(cx);
4938
4939        let (setup, cx) = setup_generating_thread(cx).await;
4940
4941        let editor_focus_handle = setup
4942            .message_editor
4943            .read_with(cx, |editor, cx| editor.focus_handle(cx));
4944        cx.update(|window, cx| {
4945            window.focus(&editor_focus_handle, cx);
4946        });
4947
4948        setup.message_editor.update_in(cx, |_, window, cx| {
4949            window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx);
4950        });
4951
4952        cx.run_until_parked();
4953
4954        setup.thread.read_with(cx, |thread, _cx| {
4955            assert_eq!(thread.status(), ThreadStatus::Idle);
4956        });
4957    }
4958
4959    #[gpui::test]
4960    async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) {
4961        init_test(cx);
4962
4963        let (conversation_view, cx) =
4964            setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
4965        add_to_workspace(conversation_view.clone(), cx);
4966
4967        let thread = conversation_view.read_with(cx, |view, cx| {
4968            view.active_thread().unwrap().read(cx).thread.clone()
4969        });
4970
4971        thread.read_with(cx, |thread, _cx| {
4972            assert_eq!(thread.status(), ThreadStatus::Idle);
4973        });
4974
4975        let focus_handle = conversation_view.read_with(cx, |view, _cx| view.focus_handle.clone());
4976        cx.update(|window, cx| {
4977            window.focus(&focus_handle, cx);
4978        });
4979
4980        conversation_view.update_in(cx, |_, window, cx| {
4981            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
4982        });
4983
4984        cx.run_until_parked();
4985
4986        thread.read_with(cx, |thread, _cx| {
4987            assert_eq!(thread.status(), ThreadStatus::Idle);
4988        });
4989    }
4990
4991    #[gpui::test]
4992    async fn test_interrupt(cx: &mut TestAppContext) {
4993        init_test(cx);
4994
4995        let connection = StubAgentConnection::new();
4996
4997        let (conversation_view, cx) =
4998            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4999        add_to_workspace(conversation_view.clone(), cx);
5000
5001        let message_editor = message_editor(&conversation_view, cx);
5002        message_editor.update_in(cx, |editor, window, cx| {
5003            editor.set_text("Message 1", window, cx);
5004        });
5005        active_thread(&conversation_view, cx)
5006            .update_in(cx, |view, window, cx| view.send(window, cx));
5007
5008        let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
5009            let thread = view.active_thread().unwrap().read(cx).thread.clone();
5010
5011            (thread.clone(), thread.read(cx).session_id().clone())
5012        });
5013
5014        cx.run_until_parked();
5015
5016        cx.update(|_, cx| {
5017            connection.send_update(
5018                session_id.clone(),
5019                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5020                    "Message 1 resp".into(),
5021                )),
5022                cx,
5023            );
5024        });
5025
5026        cx.run_until_parked();
5027
5028        thread.read_with(cx, |thread, cx| {
5029            assert_eq!(
5030                thread.to_markdown(cx),
5031                indoc::indoc! {"
5032                        ## User
5033
5034                        Message 1
5035
5036                        ## Assistant
5037
5038                        Message 1 resp
5039
5040                    "}
5041            )
5042        });
5043
5044        message_editor.update_in(cx, |editor, window, cx| {
5045            editor.set_text("Message 2", window, cx);
5046        });
5047        active_thread(&conversation_view, cx)
5048            .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx));
5049
5050        cx.update(|_, cx| {
5051            // Simulate a response sent after beginning to cancel
5052            connection.send_update(
5053                session_id.clone(),
5054                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())),
5055                cx,
5056            );
5057        });
5058
5059        cx.run_until_parked();
5060
5061        // Last Message 1 response should appear before Message 2
5062        thread.read_with(cx, |thread, cx| {
5063            assert_eq!(
5064                thread.to_markdown(cx),
5065                indoc::indoc! {"
5066                        ## User
5067
5068                        Message 1
5069
5070                        ## Assistant
5071
5072                        Message 1 response
5073
5074                        ## User
5075
5076                        Message 2
5077
5078                    "}
5079            )
5080        });
5081
5082        cx.update(|_, cx| {
5083            connection.send_update(
5084                session_id.clone(),
5085                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5086                    "Message 2 response".into(),
5087                )),
5088                cx,
5089            );
5090            connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5091        });
5092
5093        cx.run_until_parked();
5094
5095        thread.read_with(cx, |thread, cx| {
5096            assert_eq!(
5097                thread.to_markdown(cx),
5098                indoc::indoc! {"
5099                        ## User
5100
5101                        Message 1
5102
5103                        ## Assistant
5104
5105                        Message 1 response
5106
5107                        ## User
5108
5109                        Message 2
5110
5111                        ## Assistant
5112
5113                        Message 2 response
5114
5115                    "}
5116            )
5117        });
5118    }
5119
5120    #[gpui::test]
5121    async fn test_message_editing_insert_selections(cx: &mut TestAppContext) {
5122        init_test(cx);
5123
5124        let connection = StubAgentConnection::new();
5125        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5126            acp::ContentChunk::new("Response".into()),
5127        )]);
5128
5129        let (conversation_view, cx) =
5130            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5131        add_to_workspace(conversation_view.clone(), cx);
5132
5133        let message_editor = message_editor(&conversation_view, cx);
5134        message_editor.update_in(cx, |editor, window, cx| {
5135            editor.set_text("Original message to edit", window, cx)
5136        });
5137        active_thread(&conversation_view, cx)
5138            .update_in(cx, |view, window, cx| view.send(window, cx));
5139        cx.run_until_parked();
5140
5141        let user_message_editor = conversation_view.read_with(cx, |conversation_view, cx| {
5142            conversation_view
5143                .active_thread()
5144                .map(|active| &active.read(cx).entry_view_state)
5145                .as_ref()
5146                .unwrap()
5147                .read(cx)
5148                .entry(0)
5149                .expect("Should have at least one entry")
5150                .message_editor()
5151                .expect("Should have message editor")
5152                .clone()
5153        });
5154
5155        cx.focus(&user_message_editor);
5156        conversation_view.read_with(cx, |view, cx| {
5157            assert_eq!(
5158                view.active_thread()
5159                    .and_then(|active| active.read(cx).editing_message),
5160                Some(0)
5161            );
5162        });
5163
5164        // Ensure to edit the focused message before proceeding otherwise, since
5165        // its content is not different from what was sent, focus will be lost.
5166        user_message_editor.update_in(cx, |editor, window, cx| {
5167            editor.set_text("Original message to edit with ", window, cx)
5168        });
5169
5170        // Create a simple buffer with some text so we can create a selection
5171        // that will then be added to the message being edited.
5172        let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
5173            (
5174                conversation_view.workspace.clone(),
5175                conversation_view.project.clone(),
5176            )
5177        });
5178        let buffer = project.update(cx, |project, cx| {
5179            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5180        });
5181
5182        workspace
5183            .update_in(cx, |workspace, window, cx| {
5184                let editor = cx.new(|cx| {
5185                    let mut editor =
5186                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5187
5188                    editor.change_selections(Default::default(), window, cx, |selections| {
5189                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5190                    });
5191
5192                    editor
5193                });
5194                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5195            })
5196            .unwrap();
5197
5198        conversation_view.update_in(cx, |view, window, cx| {
5199            assert_eq!(
5200                view.active_thread()
5201                    .and_then(|active| active.read(cx).editing_message),
5202                Some(0)
5203            );
5204            view.insert_selections(window, cx);
5205        });
5206
5207        user_message_editor.read_with(cx, |editor, cx| {
5208            let text = editor.editor().read(cx).text(cx);
5209            let expected_text = String::from("Original message to edit with selection ");
5210
5211            assert_eq!(text, expected_text);
5212        });
5213    }
5214
5215    #[gpui::test]
5216    async fn test_insert_selections(cx: &mut TestAppContext) {
5217        init_test(cx);
5218
5219        let connection = StubAgentConnection::new();
5220        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5221            acp::ContentChunk::new("Response".into()),
5222        )]);
5223
5224        let (conversation_view, cx) =
5225            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5226        add_to_workspace(conversation_view.clone(), cx);
5227
5228        let message_editor = message_editor(&conversation_view, cx);
5229        message_editor.update_in(cx, |editor, window, cx| {
5230            editor.set_text("Can you review this snippet ", window, cx)
5231        });
5232
5233        // Create a simple buffer with some text so we can create a selection
5234        // that will then be added to the message being edited.
5235        let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
5236            (
5237                conversation_view.workspace.clone(),
5238                conversation_view.project.clone(),
5239            )
5240        });
5241        let buffer = project.update(cx, |project, cx| {
5242            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5243        });
5244
5245        workspace
5246            .update_in(cx, |workspace, window, cx| {
5247                let editor = cx.new(|cx| {
5248                    let mut editor =
5249                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5250
5251                    editor.change_selections(Default::default(), window, cx, |selections| {
5252                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5253                    });
5254
5255                    editor
5256                });
5257                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5258            })
5259            .unwrap();
5260
5261        conversation_view.update_in(cx, |view, window, cx| {
5262            assert_eq!(
5263                view.active_thread()
5264                    .and_then(|active| active.read(cx).editing_message),
5265                None
5266            );
5267            view.insert_selections(window, cx);
5268        });
5269
5270        message_editor.read_with(cx, |editor, cx| {
5271            let text = editor.text(cx);
5272            let expected_txt = String::from("Can you review this snippet selection ");
5273
5274            assert_eq!(text, expected_txt);
5275        })
5276    }
5277
5278    #[gpui::test]
5279    async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
5280        init_test(cx);
5281
5282        let tool_call_id = acp::ToolCallId::new("terminal-1");
5283        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`")
5284            .kind(acp::ToolKind::Edit);
5285
5286        let permission_options = ToolPermissionContext::new(
5287            TerminalTool::NAME,
5288            vec!["cargo build --release".to_string()],
5289        )
5290        .build_permission_options();
5291
5292        let connection =
5293            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5294                tool_call_id.clone(),
5295                permission_options,
5296            )]));
5297
5298        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5299
5300        let (conversation_view, cx) =
5301            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5302
5303        // Disable notifications to avoid popup windows
5304        cx.update(|_window, cx| {
5305            AgentSettings::override_global(
5306                AgentSettings {
5307                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5308                    ..AgentSettings::get_global(cx).clone()
5309                },
5310                cx,
5311            );
5312        });
5313
5314        let message_editor = message_editor(&conversation_view, cx);
5315        message_editor.update_in(cx, |editor, window, cx| {
5316            editor.set_text("Run cargo build", window, cx);
5317        });
5318
5319        active_thread(&conversation_view, cx)
5320            .update_in(cx, |view, window, cx| view.send(window, cx));
5321
5322        cx.run_until_parked();
5323
5324        // Verify the tool call is in WaitingForConfirmation state with the expected options
5325        conversation_view.read_with(cx, |conversation_view, cx| {
5326            let thread = conversation_view
5327                .active_thread()
5328                .expect("Thread should exist")
5329                .read(cx)
5330                .thread
5331                .clone();
5332            let thread = thread.read(cx);
5333
5334            let tool_call = thread.entries().iter().find_map(|entry| {
5335                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5336                    Some(call)
5337                } else {
5338                    None
5339                }
5340            });
5341
5342            assert!(tool_call.is_some(), "Expected a tool call entry");
5343            let tool_call = tool_call.unwrap();
5344
5345            // Verify it's waiting for confirmation
5346            assert!(
5347                matches!(
5348                    tool_call.status,
5349                    acp_thread::ToolCallStatus::WaitingForConfirmation { .. }
5350                ),
5351                "Expected WaitingForConfirmation status, got {:?}",
5352                tool_call.status
5353            );
5354
5355            // Verify the options count (granularity options only, no separate Deny option)
5356            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5357                &tool_call.status
5358            {
5359                let PermissionOptions::Dropdown(choices) = options else {
5360                    panic!("Expected dropdown permission options");
5361                };
5362
5363                assert_eq!(
5364                    choices.len(),
5365                    3,
5366                    "Expected 3 permission options (granularity only)"
5367                );
5368
5369                // Verify specific button labels (now using neutral names)
5370                let labels: Vec<&str> = choices
5371                    .iter()
5372                    .map(|choice| choice.allow.name.as_ref())
5373                    .collect();
5374                assert!(
5375                    labels.contains(&"Always for terminal"),
5376                    "Missing 'Always for terminal' option"
5377                );
5378                assert!(
5379                    labels.contains(&"Always for `cargo build` commands"),
5380                    "Missing pattern option"
5381                );
5382                assert!(
5383                    labels.contains(&"Only this time"),
5384                    "Missing 'Only this time' option"
5385                );
5386            }
5387        });
5388    }
5389
5390    #[gpui::test]
5391    async fn test_tool_permission_buttons_edit_file_with_path_pattern(cx: &mut TestAppContext) {
5392        init_test(cx);
5393
5394        let tool_call_id = acp::ToolCallId::new("edit-file-1");
5395        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`")
5396            .kind(acp::ToolKind::Edit);
5397
5398        let permission_options =
5399            ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()])
5400                .build_permission_options();
5401
5402        let connection =
5403            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5404                tool_call_id.clone(),
5405                permission_options,
5406            )]));
5407
5408        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5409
5410        let (conversation_view, cx) =
5411            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5412
5413        // Disable notifications
5414        cx.update(|_window, cx| {
5415            AgentSettings::override_global(
5416                AgentSettings {
5417                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5418                    ..AgentSettings::get_global(cx).clone()
5419                },
5420                cx,
5421            );
5422        });
5423
5424        let message_editor = message_editor(&conversation_view, cx);
5425        message_editor.update_in(cx, |editor, window, cx| {
5426            editor.set_text("Edit the main file", window, cx);
5427        });
5428
5429        active_thread(&conversation_view, cx)
5430            .update_in(cx, |view, window, cx| view.send(window, cx));
5431
5432        cx.run_until_parked();
5433
5434        // Verify the options
5435        conversation_view.read_with(cx, |conversation_view, cx| {
5436            let thread = conversation_view
5437                .active_thread()
5438                .expect("Thread should exist")
5439                .read(cx)
5440                .thread
5441                .clone();
5442            let thread = thread.read(cx);
5443
5444            let tool_call = thread.entries().iter().find_map(|entry| {
5445                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5446                    Some(call)
5447                } else {
5448                    None
5449                }
5450            });
5451
5452            assert!(tool_call.is_some(), "Expected a tool call entry");
5453            let tool_call = tool_call.unwrap();
5454
5455            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5456                &tool_call.status
5457            {
5458                let PermissionOptions::Dropdown(choices) = options else {
5459                    panic!("Expected dropdown permission options");
5460                };
5461
5462                let labels: Vec<&str> = choices
5463                    .iter()
5464                    .map(|choice| choice.allow.name.as_ref())
5465                    .collect();
5466                assert!(
5467                    labels.contains(&"Always for edit file"),
5468                    "Missing 'Always for edit file' option"
5469                );
5470                assert!(
5471                    labels.contains(&"Always for `src/`"),
5472                    "Missing path pattern option"
5473                );
5474            } else {
5475                panic!("Expected WaitingForConfirmation status");
5476            }
5477        });
5478    }
5479
5480    #[gpui::test]
5481    async fn test_tool_permission_buttons_fetch_with_domain_pattern(cx: &mut TestAppContext) {
5482        init_test(cx);
5483
5484        let tool_call_id = acp::ToolCallId::new("fetch-1");
5485        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Fetch `https://docs.rs/gpui`")
5486            .kind(acp::ToolKind::Fetch);
5487
5488        let permission_options =
5489            ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()])
5490                .build_permission_options();
5491
5492        let connection =
5493            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5494                tool_call_id.clone(),
5495                permission_options,
5496            )]));
5497
5498        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5499
5500        let (conversation_view, cx) =
5501            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5502
5503        // Disable notifications
5504        cx.update(|_window, cx| {
5505            AgentSettings::override_global(
5506                AgentSettings {
5507                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5508                    ..AgentSettings::get_global(cx).clone()
5509                },
5510                cx,
5511            );
5512        });
5513
5514        let message_editor = message_editor(&conversation_view, cx);
5515        message_editor.update_in(cx, |editor, window, cx| {
5516            editor.set_text("Fetch the docs", window, cx);
5517        });
5518
5519        active_thread(&conversation_view, cx)
5520            .update_in(cx, |view, window, cx| view.send(window, cx));
5521
5522        cx.run_until_parked();
5523
5524        // Verify the options
5525        conversation_view.read_with(cx, |conversation_view, cx| {
5526            let thread = conversation_view
5527                .active_thread()
5528                .expect("Thread should exist")
5529                .read(cx)
5530                .thread
5531                .clone();
5532            let thread = thread.read(cx);
5533
5534            let tool_call = thread.entries().iter().find_map(|entry| {
5535                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5536                    Some(call)
5537                } else {
5538                    None
5539                }
5540            });
5541
5542            assert!(tool_call.is_some(), "Expected a tool call entry");
5543            let tool_call = tool_call.unwrap();
5544
5545            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5546                &tool_call.status
5547            {
5548                let PermissionOptions::Dropdown(choices) = options else {
5549                    panic!("Expected dropdown permission options");
5550                };
5551
5552                let labels: Vec<&str> = choices
5553                    .iter()
5554                    .map(|choice| choice.allow.name.as_ref())
5555                    .collect();
5556                assert!(
5557                    labels.contains(&"Always for fetch"),
5558                    "Missing 'Always for fetch' option"
5559                );
5560                assert!(
5561                    labels.contains(&"Always for `docs.rs`"),
5562                    "Missing domain pattern option"
5563                );
5564            } else {
5565                panic!("Expected WaitingForConfirmation status");
5566            }
5567        });
5568    }
5569
5570    #[gpui::test]
5571    async fn test_tool_permission_buttons_without_pattern(cx: &mut TestAppContext) {
5572        init_test(cx);
5573
5574        let tool_call_id = acp::ToolCallId::new("terminal-no-pattern-1");
5575        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `./deploy.sh --production`")
5576            .kind(acp::ToolKind::Edit);
5577
5578        // No pattern button since ./deploy.sh doesn't match the alphanumeric pattern
5579        let permission_options = ToolPermissionContext::new(
5580            TerminalTool::NAME,
5581            vec!["./deploy.sh --production".to_string()],
5582        )
5583        .build_permission_options();
5584
5585        let connection =
5586            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5587                tool_call_id.clone(),
5588                permission_options,
5589            )]));
5590
5591        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5592
5593        let (conversation_view, cx) =
5594            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5595
5596        // Disable notifications
5597        cx.update(|_window, cx| {
5598            AgentSettings::override_global(
5599                AgentSettings {
5600                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5601                    ..AgentSettings::get_global(cx).clone()
5602                },
5603                cx,
5604            );
5605        });
5606
5607        let message_editor = message_editor(&conversation_view, cx);
5608        message_editor.update_in(cx, |editor, window, cx| {
5609            editor.set_text("Run the deploy script", window, cx);
5610        });
5611
5612        active_thread(&conversation_view, cx)
5613            .update_in(cx, |view, window, cx| view.send(window, cx));
5614
5615        cx.run_until_parked();
5616
5617        // Verify only 2 options (no pattern button when command doesn't match pattern)
5618        conversation_view.read_with(cx, |conversation_view, cx| {
5619            let thread = conversation_view
5620                .active_thread()
5621                .expect("Thread should exist")
5622                .read(cx)
5623                .thread
5624                .clone();
5625            let thread = thread.read(cx);
5626
5627            let tool_call = thread.entries().iter().find_map(|entry| {
5628                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5629                    Some(call)
5630                } else {
5631                    None
5632                }
5633            });
5634
5635            assert!(tool_call.is_some(), "Expected a tool call entry");
5636            let tool_call = tool_call.unwrap();
5637
5638            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5639                &tool_call.status
5640            {
5641                let PermissionOptions::Dropdown(choices) = options else {
5642                    panic!("Expected dropdown permission options");
5643                };
5644
5645                assert_eq!(
5646                    choices.len(),
5647                    2,
5648                    "Expected 2 permission options (no pattern option)"
5649                );
5650
5651                let labels: Vec<&str> = choices
5652                    .iter()
5653                    .map(|choice| choice.allow.name.as_ref())
5654                    .collect();
5655                assert!(
5656                    labels.contains(&"Always for terminal"),
5657                    "Missing 'Always for terminal' option"
5658                );
5659                assert!(
5660                    labels.contains(&"Only this time"),
5661                    "Missing 'Only this time' option"
5662                );
5663                // Should NOT contain a pattern option
5664                assert!(
5665                    !labels.iter().any(|l| l.contains("commands")),
5666                    "Should not have pattern option"
5667                );
5668            } else {
5669                panic!("Expected WaitingForConfirmation status");
5670            }
5671        });
5672    }
5673
5674    #[gpui::test]
5675    async fn test_authorize_tool_call_action_triggers_authorization(cx: &mut TestAppContext) {
5676        init_test(cx);
5677
5678        let tool_call_id = acp::ToolCallId::new("action-test-1");
5679        let tool_call =
5680            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit);
5681
5682        let permission_options =
5683            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo test".to_string()])
5684                .build_permission_options();
5685
5686        let connection =
5687            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5688                tool_call_id.clone(),
5689                permission_options,
5690            )]));
5691
5692        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5693
5694        let (conversation_view, cx) =
5695            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5696        add_to_workspace(conversation_view.clone(), cx);
5697
5698        cx.update(|_window, cx| {
5699            AgentSettings::override_global(
5700                AgentSettings {
5701                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5702                    ..AgentSettings::get_global(cx).clone()
5703                },
5704                cx,
5705            );
5706        });
5707
5708        let message_editor = message_editor(&conversation_view, cx);
5709        message_editor.update_in(cx, |editor, window, cx| {
5710            editor.set_text("Run tests", window, cx);
5711        });
5712
5713        active_thread(&conversation_view, cx)
5714            .update_in(cx, |view, window, cx| view.send(window, cx));
5715
5716        cx.run_until_parked();
5717
5718        // Verify tool call is waiting for confirmation
5719        conversation_view.read_with(cx, |conversation_view, cx| {
5720            let tool_call = conversation_view.pending_tool_call(cx);
5721            assert!(
5722                tool_call.is_some(),
5723                "Expected a tool call waiting for confirmation"
5724            );
5725        });
5726
5727        // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection)
5728        conversation_view.update_in(cx, |_, window, cx| {
5729            window.dispatch_action(
5730                crate::AuthorizeToolCall {
5731                    tool_call_id: "action-test-1".to_string(),
5732                    option_id: "allow".to_string(),
5733                    option_kind: "AllowOnce".to_string(),
5734                }
5735                .boxed_clone(),
5736                cx,
5737            );
5738        });
5739
5740        cx.run_until_parked();
5741
5742        // Verify tool call is no longer waiting for confirmation (was authorized)
5743        conversation_view.read_with(cx, |conversation_view, cx| {
5744            let tool_call = conversation_view.pending_tool_call(cx);
5745            assert!(
5746                tool_call.is_none(),
5747                "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action"
5748            );
5749        });
5750    }
5751
5752    #[gpui::test]
5753    async fn test_authorize_tool_call_action_with_pattern_option(cx: &mut TestAppContext) {
5754        init_test(cx);
5755
5756        let tool_call_id = acp::ToolCallId::new("pattern-action-test-1");
5757        let tool_call =
5758            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
5759
5760        let permission_options =
5761            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
5762                .build_permission_options();
5763
5764        let connection =
5765            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5766                tool_call_id.clone(),
5767                permission_options.clone(),
5768            )]));
5769
5770        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5771
5772        let (conversation_view, cx) =
5773            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5774        add_to_workspace(conversation_view.clone(), cx);
5775
5776        cx.update(|_window, cx| {
5777            AgentSettings::override_global(
5778                AgentSettings {
5779                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5780                    ..AgentSettings::get_global(cx).clone()
5781                },
5782                cx,
5783            );
5784        });
5785
5786        let message_editor = message_editor(&conversation_view, cx);
5787        message_editor.update_in(cx, |editor, window, cx| {
5788            editor.set_text("Install dependencies", window, cx);
5789        });
5790
5791        active_thread(&conversation_view, cx)
5792            .update_in(cx, |view, window, cx| view.send(window, cx));
5793
5794        cx.run_until_parked();
5795
5796        // Find the pattern option ID (the choice with non-empty sub_patterns)
5797        let pattern_option = match &permission_options {
5798            PermissionOptions::Dropdown(choices) => choices
5799                .iter()
5800                .find(|choice| !choice.sub_patterns.is_empty())
5801                .map(|choice| &choice.allow)
5802                .expect("Should have a pattern option for npm command"),
5803            _ => panic!("Expected dropdown permission options"),
5804        };
5805
5806        // Dispatch action with the pattern option (simulating "Always allow `npm` commands")
5807        conversation_view.update_in(cx, |_, window, cx| {
5808            window.dispatch_action(
5809                crate::AuthorizeToolCall {
5810                    tool_call_id: "pattern-action-test-1".to_string(),
5811                    option_id: pattern_option.option_id.0.to_string(),
5812                    option_kind: "AllowAlways".to_string(),
5813                }
5814                .boxed_clone(),
5815                cx,
5816            );
5817        });
5818
5819        cx.run_until_parked();
5820
5821        // Verify tool call was authorized
5822        conversation_view.read_with(cx, |conversation_view, cx| {
5823            let tool_call = conversation_view.pending_tool_call(cx);
5824            assert!(
5825                tool_call.is_none(),
5826                "Tool call should be authorized after selecting pattern option"
5827            );
5828        });
5829    }
5830
5831    #[gpui::test]
5832    async fn test_granularity_selection_updates_state(cx: &mut TestAppContext) {
5833        init_test(cx);
5834
5835        let tool_call_id = acp::ToolCallId::new("granularity-test-1");
5836        let tool_call =
5837            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit);
5838
5839        let permission_options =
5840            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
5841                .build_permission_options();
5842
5843        let connection =
5844            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5845                tool_call_id.clone(),
5846                permission_options.clone(),
5847            )]));
5848
5849        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5850
5851        let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
5852        add_to_workspace(thread_view.clone(), cx);
5853
5854        cx.update(|_window, cx| {
5855            AgentSettings::override_global(
5856                AgentSettings {
5857                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5858                    ..AgentSettings::get_global(cx).clone()
5859                },
5860                cx,
5861            );
5862        });
5863
5864        let message_editor = message_editor(&thread_view, cx);
5865        message_editor.update_in(cx, |editor, window, cx| {
5866            editor.set_text("Build the project", window, cx);
5867        });
5868
5869        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5870
5871        cx.run_until_parked();
5872
5873        // Verify default granularity is the last option (index 2 = "Only this time")
5874        thread_view.read_with(cx, |thread_view, cx| {
5875            let state = thread_view.active_thread().unwrap();
5876            let selected = state.read(cx).permission_selections.get(&tool_call_id);
5877            assert!(
5878                selected.is_none(),
5879                "Should have no selection initially (defaults to last)"
5880            );
5881        });
5882
5883        // Select the first option (index 0 = "Always for terminal")
5884        thread_view.update_in(cx, |_, window, cx| {
5885            window.dispatch_action(
5886                crate::SelectPermissionGranularity {
5887                    tool_call_id: "granularity-test-1".to_string(),
5888                    index: 0,
5889                }
5890                .boxed_clone(),
5891                cx,
5892            );
5893        });
5894
5895        cx.run_until_parked();
5896
5897        // Verify the selection was updated
5898        thread_view.read_with(cx, |thread_view, cx| {
5899            let state = thread_view.active_thread().unwrap();
5900            let selected = state.read(cx).permission_selections.get(&tool_call_id);
5901            assert_eq!(
5902                selected.and_then(|s| s.choice_index()),
5903                Some(0),
5904                "Should have selected index 0"
5905            );
5906        });
5907    }
5908
5909    #[gpui::test]
5910    async fn test_allow_button_uses_selected_granularity(cx: &mut TestAppContext) {
5911        init_test(cx);
5912
5913        let tool_call_id = acp::ToolCallId::new("allow-granularity-test-1");
5914        let tool_call =
5915            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
5916
5917        let permission_options =
5918            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
5919                .build_permission_options();
5920
5921        // Verify we have the expected options
5922        let PermissionOptions::Dropdown(choices) = &permission_options else {
5923            panic!("Expected dropdown permission options");
5924        };
5925
5926        assert_eq!(choices.len(), 3);
5927        assert!(
5928            choices[0]
5929                .allow
5930                .option_id
5931                .0
5932                .contains("always_allow:terminal")
5933        );
5934        assert!(
5935            choices[1]
5936                .allow
5937                .option_id
5938                .0
5939                .contains("always_allow:terminal")
5940        );
5941        assert!(!choices[1].sub_patterns.is_empty());
5942        assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
5943
5944        let connection =
5945            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5946                tool_call_id.clone(),
5947                permission_options.clone(),
5948            )]));
5949
5950        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5951
5952        let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
5953        add_to_workspace(thread_view.clone(), cx);
5954
5955        cx.update(|_window, cx| {
5956            AgentSettings::override_global(
5957                AgentSettings {
5958                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5959                    ..AgentSettings::get_global(cx).clone()
5960                },
5961                cx,
5962            );
5963        });
5964
5965        let message_editor = message_editor(&thread_view, cx);
5966        message_editor.update_in(cx, |editor, window, cx| {
5967            editor.set_text("Install dependencies", window, cx);
5968        });
5969
5970        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5971
5972        cx.run_until_parked();
5973
5974        // Select the pattern option (index 1 = "Always for `npm` commands")
5975        thread_view.update_in(cx, |_, window, cx| {
5976            window.dispatch_action(
5977                crate::SelectPermissionGranularity {
5978                    tool_call_id: "allow-granularity-test-1".to_string(),
5979                    index: 1,
5980                }
5981                .boxed_clone(),
5982                cx,
5983            );
5984        });
5985
5986        cx.run_until_parked();
5987
5988        // Simulate clicking the Allow button by dispatching AllowOnce action
5989        // which should use the selected granularity
5990        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
5991            view.allow_once(&AllowOnce, window, cx)
5992        });
5993
5994        cx.run_until_parked();
5995
5996        // Verify tool call was authorized
5997        thread_view.read_with(cx, |thread_view, cx| {
5998            let tool_call = thread_view.pending_tool_call(cx);
5999            assert!(
6000                tool_call.is_none(),
6001                "Tool call should be authorized after Allow with pattern granularity"
6002            );
6003        });
6004    }
6005
6006    #[gpui::test]
6007    async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) {
6008        init_test(cx);
6009
6010        let tool_call_id = acp::ToolCallId::new("deny-granularity-test-1");
6011        let tool_call =
6012            acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit);
6013
6014        let permission_options =
6015            ToolPermissionContext::new(TerminalTool::NAME, vec!["git push".to_string()])
6016                .build_permission_options();
6017
6018        let connection =
6019            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
6020                tool_call_id.clone(),
6021                permission_options.clone(),
6022            )]));
6023
6024        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6025
6026        let (conversation_view, cx) =
6027            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6028        add_to_workspace(conversation_view.clone(), cx);
6029
6030        cx.update(|_window, cx| {
6031            AgentSettings::override_global(
6032                AgentSettings {
6033                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6034                    ..AgentSettings::get_global(cx).clone()
6035                },
6036                cx,
6037            );
6038        });
6039
6040        let message_editor = message_editor(&conversation_view, cx);
6041        message_editor.update_in(cx, |editor, window, cx| {
6042            editor.set_text("Push changes", window, cx);
6043        });
6044
6045        active_thread(&conversation_view, cx)
6046            .update_in(cx, |view, window, cx| view.send(window, cx));
6047
6048        cx.run_until_parked();
6049
6050        // Use default granularity (last option = "Only this time")
6051        // Simulate clicking the Deny button
6052        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
6053            view.reject_once(&RejectOnce, window, cx)
6054        });
6055
6056        cx.run_until_parked();
6057
6058        // Verify tool call was rejected (no longer waiting for confirmation)
6059        conversation_view.read_with(cx, |conversation_view, cx| {
6060            let tool_call = conversation_view.pending_tool_call(cx);
6061            assert!(
6062                tool_call.is_none(),
6063                "Tool call should be rejected after Deny"
6064            );
6065        });
6066    }
6067
6068    #[gpui::test]
6069    async fn test_option_id_transformation_for_allow() {
6070        let permission_options = ToolPermissionContext::new(
6071            TerminalTool::NAME,
6072            vec!["cargo build --release".to_string()],
6073        )
6074        .build_permission_options();
6075
6076        let PermissionOptions::Dropdown(choices) = permission_options else {
6077            panic!("Expected dropdown permission options");
6078        };
6079
6080        let allow_ids: Vec<String> = choices
6081            .iter()
6082            .map(|choice| choice.allow.option_id.0.to_string())
6083            .collect();
6084
6085        assert!(allow_ids.contains(&"allow".to_string()));
6086        assert_eq!(
6087            allow_ids
6088                .iter()
6089                .filter(|id| *id == "always_allow:terminal")
6090                .count(),
6091            2,
6092            "Expected two always_allow:terminal IDs (one whole-tool, one pattern with sub_patterns)"
6093        );
6094    }
6095
6096    #[gpui::test]
6097    async fn test_option_id_transformation_for_deny() {
6098        let permission_options = ToolPermissionContext::new(
6099            TerminalTool::NAME,
6100            vec!["cargo build --release".to_string()],
6101        )
6102        .build_permission_options();
6103
6104        let PermissionOptions::Dropdown(choices) = permission_options else {
6105            panic!("Expected dropdown permission options");
6106        };
6107
6108        let deny_ids: Vec<String> = choices
6109            .iter()
6110            .map(|choice| choice.deny.option_id.0.to_string())
6111            .collect();
6112
6113        assert!(deny_ids.contains(&"deny".to_string()));
6114        assert_eq!(
6115            deny_ids
6116                .iter()
6117                .filter(|id| *id == "always_deny:terminal")
6118                .count(),
6119            2,
6120            "Expected two always_deny:terminal IDs (one whole-tool, one pattern with sub_patterns)"
6121        );
6122    }
6123
6124    #[gpui::test]
6125    async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) {
6126        init_test(cx);
6127
6128        let (conversation_view, cx) =
6129            setup_conversation_view(StubAgentServer::default_response(), cx).await;
6130        add_to_workspace(conversation_view.clone(), cx);
6131
6132        let active = active_thread(&conversation_view, cx);
6133        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6134        let thread = cx.read(|cx| active.read(cx).thread.clone());
6135
6136        title_editor.read_with(cx, |editor, cx| {
6137            assert!(!editor.read_only(cx));
6138        });
6139
6140        cx.focus(&conversation_view);
6141        cx.focus(&title_editor);
6142
6143        cx.dispatch_action(editor::actions::DeleteLine);
6144        cx.simulate_input("My Custom Title");
6145
6146        cx.run_until_parked();
6147
6148        title_editor.read_with(cx, |editor, cx| {
6149            assert_eq!(editor.text(cx), "My Custom Title");
6150        });
6151        thread.read_with(cx, |thread, _cx| {
6152            assert_eq!(thread.title(), Some("My Custom Title".into()));
6153        });
6154    }
6155
6156    #[gpui::test]
6157    async fn test_title_editor_is_read_only_when_set_title_unsupported(cx: &mut TestAppContext) {
6158        init_test(cx);
6159
6160        let (conversation_view, cx) =
6161            setup_conversation_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await;
6162
6163        let active = active_thread(&conversation_view, cx);
6164        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6165
6166        title_editor.read_with(cx, |editor, cx| {
6167            assert!(
6168                editor.read_only(cx),
6169                "Title editor should be read-only when the connection does not support set_title"
6170            );
6171        });
6172    }
6173
6174    #[gpui::test]
6175    async fn test_max_tokens_error_is_rendered(cx: &mut TestAppContext) {
6176        init_test(cx);
6177
6178        let connection = StubAgentConnection::new();
6179
6180        let (conversation_view, cx) =
6181            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
6182
6183        let message_editor = message_editor(&conversation_view, cx);
6184        message_editor.update_in(cx, |editor, window, cx| {
6185            editor.set_text("Some prompt", window, cx);
6186        });
6187        active_thread(&conversation_view, cx)
6188            .update_in(cx, |view, window, cx| view.send(window, cx));
6189
6190        let session_id = conversation_view.read_with(cx, |view, cx| {
6191            view.active_thread()
6192                .unwrap()
6193                .read(cx)
6194                .thread
6195                .read(cx)
6196                .session_id()
6197                .clone()
6198        });
6199
6200        cx.run_until_parked();
6201
6202        cx.update(|_, _cx| {
6203            connection.end_turn(session_id, acp::StopReason::MaxTokens);
6204        });
6205
6206        cx.run_until_parked();
6207
6208        conversation_view.read_with(cx, |conversation_view, cx| {
6209            let state = conversation_view.active_thread().unwrap();
6210            let error = &state.read(cx).thread_error;
6211            match error {
6212                Some(ThreadError::Other { message, .. }) => {
6213                    assert!(
6214                        message.contains("Max tokens reached"),
6215                        "Expected 'Max tokens reached' error, got: {}",
6216                        message
6217                    );
6218                }
6219                other => panic!(
6220                    "Expected ThreadError::Other with 'Max tokens reached', got: {:?}",
6221                    other.is_some()
6222                ),
6223            }
6224        });
6225    }
6226
6227    fn create_test_acp_thread(
6228        parent_session_id: Option<acp::SessionId>,
6229        session_id: &str,
6230        connection: Rc<dyn AgentConnection>,
6231        project: Entity<Project>,
6232        cx: &mut App,
6233    ) -> Entity<AcpThread> {
6234        let action_log = cx.new(|_| ActionLog::new(project.clone()));
6235        cx.new(|cx| {
6236            AcpThread::new(
6237                parent_session_id,
6238                None,
6239                None,
6240                connection,
6241                project,
6242                action_log,
6243                acp::SessionId::new(session_id),
6244                watch::Receiver::constant(acp::PromptCapabilities::new()),
6245                cx,
6246            )
6247        })
6248    }
6249
6250    fn request_test_tool_authorization(
6251        thread: &Entity<AcpThread>,
6252        tool_call_id: &str,
6253        option_id: &str,
6254        cx: &mut TestAppContext,
6255    ) -> Task<acp_thread::RequestPermissionOutcome> {
6256        let tool_call_id = acp::ToolCallId::new(tool_call_id);
6257        let label = format!("Tool {tool_call_id}");
6258        let option_id = acp::PermissionOptionId::new(option_id);
6259        cx.update(|cx| {
6260            thread.update(cx, |thread, cx| {
6261                thread
6262                    .request_tool_call_authorization(
6263                        acp::ToolCall::new(tool_call_id, label)
6264                            .kind(acp::ToolKind::Edit)
6265                            .into(),
6266                        PermissionOptions::Flat(vec![acp::PermissionOption::new(
6267                            option_id,
6268                            "Allow",
6269                            acp::PermissionOptionKind::AllowOnce,
6270                        )]),
6271                        cx,
6272                    )
6273                    .unwrap()
6274            })
6275        })
6276    }
6277
6278    #[gpui::test]
6279    async fn test_conversation_multiple_tool_calls_fifo_ordering(cx: &mut TestAppContext) {
6280        init_test(cx);
6281
6282        let fs = FakeFs::new(cx.executor());
6283        let project = Project::test(fs, [], cx).await;
6284        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6285
6286        let (thread, conversation) = cx.update(|cx| {
6287            let thread =
6288                create_test_acp_thread(None, "session-1", connection.clone(), project.clone(), cx);
6289            let conversation = cx.new(|cx| {
6290                let mut conversation = Conversation::default();
6291                conversation.register_thread(thread.clone(), cx);
6292                conversation
6293            });
6294            (thread, conversation)
6295        });
6296
6297        let _task1 = request_test_tool_authorization(&thread, "tc-1", "allow-1", cx);
6298        let _task2 = request_test_tool_authorization(&thread, "tc-2", "allow-2", cx);
6299
6300        cx.read(|cx| {
6301            let session_id = acp::SessionId::new("session-1");
6302            let (_, tool_call_id, _) = conversation
6303                .read(cx)
6304                .pending_tool_call(&session_id, cx)
6305                .expect("Expected a pending tool call");
6306            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-1"));
6307        });
6308
6309        cx.update(|cx| {
6310            conversation.update(cx, |conversation, cx| {
6311                conversation.authorize_tool_call(
6312                    acp::SessionId::new("session-1"),
6313                    acp::ToolCallId::new("tc-1"),
6314                    SelectedPermissionOutcome::new(
6315                        acp::PermissionOptionId::new("allow-1"),
6316                        acp::PermissionOptionKind::AllowOnce,
6317                    ),
6318                    cx,
6319                );
6320            });
6321        });
6322
6323        cx.run_until_parked();
6324
6325        cx.read(|cx| {
6326            let session_id = acp::SessionId::new("session-1");
6327            let (_, tool_call_id, _) = conversation
6328                .read(cx)
6329                .pending_tool_call(&session_id, cx)
6330                .expect("Expected tc-2 to be pending after tc-1 was authorized");
6331            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-2"));
6332        });
6333
6334        cx.update(|cx| {
6335            conversation.update(cx, |conversation, cx| {
6336                conversation.authorize_tool_call(
6337                    acp::SessionId::new("session-1"),
6338                    acp::ToolCallId::new("tc-2"),
6339                    SelectedPermissionOutcome::new(
6340                        acp::PermissionOptionId::new("allow-2"),
6341                        acp::PermissionOptionKind::AllowOnce,
6342                    ),
6343                    cx,
6344                );
6345            });
6346        });
6347
6348        cx.run_until_parked();
6349
6350        cx.read(|cx| {
6351            let session_id = acp::SessionId::new("session-1");
6352            assert!(
6353                conversation
6354                    .read(cx)
6355                    .pending_tool_call(&session_id, cx)
6356                    .is_none(),
6357                "Expected no pending tool calls after both were authorized"
6358            );
6359        });
6360    }
6361
6362    #[gpui::test]
6363    async fn test_conversation_subagent_scoped_pending_tool_call(cx: &mut TestAppContext) {
6364        init_test(cx);
6365
6366        let fs = FakeFs::new(cx.executor());
6367        let project = Project::test(fs, [], cx).await;
6368        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6369
6370        let (parent_thread, subagent_thread, conversation) = cx.update(|cx| {
6371            let parent_thread =
6372                create_test_acp_thread(None, "parent", connection.clone(), project.clone(), cx);
6373            let subagent_thread = create_test_acp_thread(
6374                Some(acp::SessionId::new("parent")),
6375                "subagent",
6376                connection.clone(),
6377                project.clone(),
6378                cx,
6379            );
6380            let conversation = cx.new(|cx| {
6381                let mut conversation = Conversation::default();
6382                conversation.register_thread(parent_thread.clone(), cx);
6383                conversation.register_thread(subagent_thread.clone(), cx);
6384                conversation
6385            });
6386            (parent_thread, subagent_thread, conversation)
6387        });
6388
6389        let _parent_task =
6390            request_test_tool_authorization(&parent_thread, "parent-tc", "allow-parent", cx);
6391        let _subagent_task =
6392            request_test_tool_authorization(&subagent_thread, "subagent-tc", "allow-subagent", cx);
6393
6394        // Querying with the subagent's session ID returns only the
6395        // subagent's own tool call (subagent path is scoped to its session)
6396        cx.read(|cx| {
6397            let subagent_id = acp::SessionId::new("subagent");
6398            let (session_id, tool_call_id, _) = conversation
6399                .read(cx)
6400                .pending_tool_call(&subagent_id, cx)
6401                .expect("Expected subagent's pending tool call");
6402            assert_eq!(session_id, acp::SessionId::new("subagent"));
6403            assert_eq!(tool_call_id, acp::ToolCallId::new("subagent-tc"));
6404        });
6405
6406        // Querying with the parent's session ID returns the first pending
6407        // request in FIFO order across all sessions
6408        cx.read(|cx| {
6409            let parent_id = acp::SessionId::new("parent");
6410            let (session_id, tool_call_id, _) = conversation
6411                .read(cx)
6412                .pending_tool_call(&parent_id, cx)
6413                .expect("Expected a pending tool call from parent query");
6414            assert_eq!(session_id, acp::SessionId::new("parent"));
6415            assert_eq!(tool_call_id, acp::ToolCallId::new("parent-tc"));
6416        });
6417    }
6418
6419    #[gpui::test]
6420    async fn test_conversation_parent_pending_tool_call_returns_first_across_threads(
6421        cx: &mut TestAppContext,
6422    ) {
6423        init_test(cx);
6424
6425        let fs = FakeFs::new(cx.executor());
6426        let project = Project::test(fs, [], cx).await;
6427        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6428
6429        let (thread_a, thread_b, conversation) = cx.update(|cx| {
6430            let thread_a =
6431                create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx);
6432            let thread_b =
6433                create_test_acp_thread(None, "thread-b", connection.clone(), project.clone(), cx);
6434            let conversation = cx.new(|cx| {
6435                let mut conversation = Conversation::default();
6436                conversation.register_thread(thread_a.clone(), cx);
6437                conversation.register_thread(thread_b.clone(), cx);
6438                conversation
6439            });
6440            (thread_a, thread_b, conversation)
6441        });
6442
6443        let _task_a = request_test_tool_authorization(&thread_a, "tc-a", "allow-a", cx);
6444        let _task_b = request_test_tool_authorization(&thread_b, "tc-b", "allow-b", cx);
6445
6446        // Both threads are non-subagent, so pending_tool_call always returns
6447        // the first entry from permission_requests (FIFO across all sessions)
6448        cx.read(|cx| {
6449            let session_a = acp::SessionId::new("thread-a");
6450            let (session_id, tool_call_id, _) = conversation
6451                .read(cx)
6452                .pending_tool_call(&session_a, cx)
6453                .expect("Expected a pending tool call");
6454            assert_eq!(session_id, acp::SessionId::new("thread-a"));
6455            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
6456        });
6457
6458        // Querying with thread-b also returns thread-a's tool call,
6459        // because non-subagent queries always use permission_requests.first()
6460        cx.read(|cx| {
6461            let session_b = acp::SessionId::new("thread-b");
6462            let (session_id, tool_call_id, _) = conversation
6463                .read(cx)
6464                .pending_tool_call(&session_b, cx)
6465                .expect("Expected a pending tool call from thread-b query");
6466            assert_eq!(
6467                session_id,
6468                acp::SessionId::new("thread-a"),
6469                "Non-subagent queries always return the first pending request in FIFO order"
6470            );
6471            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
6472        });
6473
6474        // After authorizing thread-a's tool call, thread-b's becomes first
6475        cx.update(|cx| {
6476            conversation.update(cx, |conversation, cx| {
6477                conversation.authorize_tool_call(
6478                    acp::SessionId::new("thread-a"),
6479                    acp::ToolCallId::new("tc-a"),
6480                    SelectedPermissionOutcome::new(
6481                        acp::PermissionOptionId::new("allow-a"),
6482                        acp::PermissionOptionKind::AllowOnce,
6483                    ),
6484                    cx,
6485                );
6486            });
6487        });
6488
6489        cx.run_until_parked();
6490
6491        cx.read(|cx| {
6492            let session_b = acp::SessionId::new("thread-b");
6493            let (session_id, tool_call_id, _) = conversation
6494                .read(cx)
6495                .pending_tool_call(&session_b, cx)
6496                .expect("Expected thread-b's tool call after thread-a's was authorized");
6497            assert_eq!(session_id, acp::SessionId::new("thread-b"));
6498            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b"));
6499        });
6500    }
6501
6502    #[gpui::test]
6503    async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) {
6504        init_test(cx);
6505
6506        let (conversation_view, cx) =
6507            setup_conversation_view(StubAgentServer::default_response(), cx).await;
6508
6509        // Add a plain-text message to the queue directly.
6510        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
6511            thread.add_to_queue(
6512                vec![acp::ContentBlock::Text(acp::TextContent::new(
6513                    "queued message".to_string(),
6514                ))],
6515                vec![],
6516                cx,
6517            );
6518            // Main editor must be empty for this path — it is by default, but
6519            // assert to make the precondition explicit.
6520            assert!(thread.message_editor.read(cx).is_empty(cx));
6521            thread.move_queued_message_to_main_editor(0, None, None, window, cx);
6522        });
6523
6524        cx.run_until_parked();
6525
6526        // Queue should now be empty.
6527        let queue_len = active_thread(&conversation_view, cx)
6528            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
6529        assert_eq!(queue_len, 0, "Queue should be empty after move");
6530
6531        // Main editor should contain the queued message text.
6532        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
6533        assert_eq!(
6534            text, "queued message",
6535            "Main editor should contain the moved queued message"
6536        );
6537    }
6538
6539    #[gpui::test]
6540    async fn test_move_queued_message_to_non_empty_main_editor(cx: &mut TestAppContext) {
6541        init_test(cx);
6542
6543        let (conversation_view, cx) =
6544            setup_conversation_view(StubAgentServer::default_response(), cx).await;
6545
6546        // Seed the main editor with existing content.
6547        message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| {
6548            editor.set_message(
6549                vec![acp::ContentBlock::Text(acp::TextContent::new(
6550                    "existing content".to_string(),
6551                ))],
6552                window,
6553                cx,
6554            );
6555        });
6556
6557        // Add a plain-text message to the queue.
6558        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
6559            thread.add_to_queue(
6560                vec![acp::ContentBlock::Text(acp::TextContent::new(
6561                    "queued message".to_string(),
6562                ))],
6563                vec![],
6564                cx,
6565            );
6566            thread.move_queued_message_to_main_editor(0, None, None, window, cx);
6567        });
6568
6569        cx.run_until_parked();
6570
6571        // Queue should now be empty.
6572        let queue_len = active_thread(&conversation_view, cx)
6573            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
6574        assert_eq!(queue_len, 0, "Queue should be empty after move");
6575
6576        // Main editor should contain existing content + separator + queued content.
6577        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
6578        assert_eq!(
6579            text, "existing content\n\nqueued message",
6580            "Main editor should have existing content and queued message separated by two newlines"
6581        );
6582    }
6583
6584    #[gpui::test]
6585    async fn test_close_all_sessions_skips_when_unsupported(cx: &mut TestAppContext) {
6586        init_test(cx);
6587
6588        let fs = FakeFs::new(cx.executor());
6589        let project = Project::test(fs, [], cx).await;
6590        let (multi_workspace, cx) =
6591            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6592        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6593
6594        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
6595        let connection_store =
6596            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
6597
6598        // StubAgentConnection defaults to supports_close_session() -> false
6599        let conversation_view = cx.update(|window, cx| {
6600            cx.new(|cx| {
6601                ConversationView::new(
6602                    Rc::new(StubAgentServer::default_response()),
6603                    connection_store,
6604                    Agent::Custom { id: "Test".into() },
6605                    None,
6606                    None,
6607                    None,
6608                    None,
6609                    workspace.downgrade(),
6610                    project,
6611                    Some(thread_store),
6612                    None,
6613                    window,
6614                    cx,
6615                )
6616            })
6617        });
6618
6619        cx.run_until_parked();
6620
6621        conversation_view.read_with(cx, |view, _cx| {
6622            let connected = view.as_connected().expect("Should be connected");
6623            assert!(
6624                !connected.threads.is_empty(),
6625                "There should be at least one thread"
6626            );
6627            assert!(
6628                !connected.connection.supports_close_session(),
6629                "StubAgentConnection should not support close"
6630            );
6631        });
6632
6633        conversation_view
6634            .update(cx, |view, cx| {
6635                view.as_connected()
6636                    .expect("Should be connected")
6637                    .close_all_sessions(cx)
6638            })
6639            .await;
6640    }
6641
6642    #[gpui::test]
6643    async fn test_close_all_sessions_calls_close_when_supported(cx: &mut TestAppContext) {
6644        init_test(cx);
6645
6646        let (conversation_view, cx) =
6647            setup_conversation_view(StubAgentServer::new(CloseCapableConnection::new()), cx).await;
6648
6649        cx.run_until_parked();
6650
6651        let close_capable = conversation_view.read_with(cx, |view, _cx| {
6652            let connected = view.as_connected().expect("Should be connected");
6653            assert!(
6654                !connected.threads.is_empty(),
6655                "There should be at least one thread"
6656            );
6657            assert!(
6658                connected.connection.supports_close_session(),
6659                "CloseCapableConnection should support close"
6660            );
6661            connected
6662                .connection
6663                .clone()
6664                .into_any()
6665                .downcast::<CloseCapableConnection>()
6666                .expect("Should be CloseCapableConnection")
6667        });
6668
6669        conversation_view
6670            .update(cx, |view, cx| {
6671                view.as_connected()
6672                    .expect("Should be connected")
6673                    .close_all_sessions(cx)
6674            })
6675            .await;
6676
6677        let closed_count = close_capable.closed_sessions.lock().len();
6678        assert!(
6679            closed_count > 0,
6680            "close_session should have been called for each thread"
6681        );
6682    }
6683
6684    #[gpui::test]
6685    async fn test_close_session_returns_error_when_unsupported(cx: &mut TestAppContext) {
6686        init_test(cx);
6687
6688        let (conversation_view, cx) =
6689            setup_conversation_view(StubAgentServer::default_response(), cx).await;
6690
6691        cx.run_until_parked();
6692
6693        let result = conversation_view
6694            .update(cx, |view, cx| {
6695                let connected = view.as_connected().expect("Should be connected");
6696                assert!(
6697                    !connected.connection.supports_close_session(),
6698                    "StubAgentConnection should not support close"
6699                );
6700                let session_id = connected
6701                    .threads
6702                    .keys()
6703                    .next()
6704                    .expect("Should have at least one thread")
6705                    .clone();
6706                connected.connection.clone().close_session(&session_id, cx)
6707            })
6708            .await;
6709
6710        assert!(
6711            result.is_err(),
6712            "close_session should return an error when close is not supported"
6713        );
6714        assert!(
6715            result.unwrap_err().to_string().contains("not supported"),
6716            "Error message should indicate that closing is not supported"
6717        );
6718    }
6719
6720    #[derive(Clone)]
6721    struct CloseCapableConnection {
6722        closed_sessions: Arc<Mutex<Vec<acp::SessionId>>>,
6723    }
6724
6725    impl CloseCapableConnection {
6726        fn new() -> Self {
6727            Self {
6728                closed_sessions: Arc::new(Mutex::new(Vec::new())),
6729            }
6730        }
6731    }
6732
6733    impl AgentConnection for CloseCapableConnection {
6734        fn agent_id(&self) -> AgentId {
6735            AgentId::new("close-capable")
6736        }
6737
6738        fn telemetry_id(&self) -> SharedString {
6739            "close-capable".into()
6740        }
6741
6742        fn new_session(
6743            self: Rc<Self>,
6744            project: Entity<Project>,
6745            work_dirs: PathList,
6746            cx: &mut gpui::App,
6747        ) -> Task<gpui::Result<Entity<AcpThread>>> {
6748            let action_log = cx.new(|_| ActionLog::new(project.clone()));
6749            let thread = cx.new(|cx| {
6750                AcpThread::new(
6751                    None,
6752                    Some("CloseCapableConnection".into()),
6753                    Some(work_dirs),
6754                    self,
6755                    project,
6756                    action_log,
6757                    SessionId::new("close-capable-session"),
6758                    watch::Receiver::constant(
6759                        acp::PromptCapabilities::new()
6760                            .image(true)
6761                            .audio(true)
6762                            .embedded_context(true),
6763                    ),
6764                    cx,
6765                )
6766            });
6767            Task::ready(Ok(thread))
6768        }
6769
6770        fn supports_close_session(&self) -> bool {
6771            true
6772        }
6773
6774        fn close_session(
6775            self: Rc<Self>,
6776            session_id: &acp::SessionId,
6777            _cx: &mut App,
6778        ) -> Task<Result<()>> {
6779            self.closed_sessions.lock().push(session_id.clone());
6780            Task::ready(Ok(()))
6781        }
6782
6783        fn auth_methods(&self) -> &[acp::AuthMethod] {
6784            &[]
6785        }
6786
6787        fn authenticate(
6788            &self,
6789            _method_id: acp::AuthMethodId,
6790            _cx: &mut App,
6791        ) -> Task<gpui::Result<()>> {
6792            Task::ready(Ok(()))
6793        }
6794
6795        fn prompt(
6796            &self,
6797            _id: Option<acp_thread::UserMessageId>,
6798            _params: acp::PromptRequest,
6799            _cx: &mut App,
6800        ) -> Task<gpui::Result<acp::PromptResponse>> {
6801            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
6802        }
6803
6804        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
6805
6806        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
6807            self
6808        }
6809    }
6810}