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