conversation_view.rs

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