conversation_view.rs

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