conversation_view.rs

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