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