conversation_view.rs

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