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