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.update(cx, |store, cx| store.delete(session_id.clone(), cx));
2550        }
2551    }
2552}
2553
2554fn loading_contents_spinner(size: IconSize) -> AnyElement {
2555    Icon::new(IconName::LoadCircle)
2556        .size(size)
2557        .color(Color::Accent)
2558        .with_rotate_animation(3)
2559        .into_any_element()
2560}
2561
2562fn placeholder_text(agent_name: &str, has_commands: bool) -> String {
2563    if agent_name == agent::ZED_AGENT_ID.as_ref() {
2564        format!("Message the {} — @ to include context", agent_name)
2565    } else if has_commands {
2566        format!(
2567            "Message {} — @ to include context, / for commands",
2568            agent_name
2569        )
2570    } else {
2571        format!("Message {} — @ to include context", agent_name)
2572    }
2573}
2574
2575impl Focusable for ConversationView {
2576    fn focus_handle(&self, cx: &App) -> FocusHandle {
2577        match self.active_thread() {
2578            Some(thread) => thread.read(cx).focus_handle(cx),
2579            None => self.focus_handle.clone(),
2580        }
2581    }
2582}
2583
2584#[cfg(any(test, feature = "test-support"))]
2585impl ConversationView {
2586    /// Expands a tool call so its content is visible.
2587    /// This is primarily useful for visual testing.
2588    pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context<Self>) {
2589        if let Some(active) = self.active_thread() {
2590            active.update(cx, |active, _cx| {
2591                active.expanded_tool_calls.insert(tool_call_id);
2592            });
2593            cx.notify();
2594        }
2595    }
2596
2597    #[cfg(any(test, feature = "test-support"))]
2598    pub fn set_updated_at(&mut self, updated_at: Instant, cx: &mut Context<Self>) {
2599        let Some(connected) = self.as_connected_mut() else {
2600            return;
2601        };
2602
2603        connected.conversation.update(cx, |conversation, _cx| {
2604            conversation.updated_at = Some(updated_at);
2605        });
2606    }
2607}
2608
2609impl Render for ConversationView {
2610    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2611        self.sync_queued_message_editors(window, cx);
2612        let v2_flag = cx.has_flag::<AgentV2FeatureFlag>();
2613
2614        v_flex()
2615            .track_focus(&self.focus_handle)
2616            .size_full()
2617            .bg(cx.theme().colors().panel_background)
2618            .child(match &self.server_state {
2619                ServerState::Loading { .. } => v_flex()
2620                    .flex_1()
2621                    .when(v2_flag, |this| {
2622                        this.size_full().items_center().justify_center().child(
2623                            Label::new("Loading…").color(Color::Muted).with_animation(
2624                                "loading-agent-label",
2625                                Animation::new(Duration::from_secs(2))
2626                                    .repeat()
2627                                    .with_easing(pulsating_between(0.3, 0.7)),
2628                                |label, delta| label.alpha(delta),
2629                            ),
2630                        )
2631                    })
2632                    .into_any(),
2633                ServerState::LoadError { error: e, .. } => v_flex()
2634                    .flex_1()
2635                    .size_full()
2636                    .items_center()
2637                    .justify_end()
2638                    .child(self.render_load_error(e, window, cx))
2639                    .into_any(),
2640                ServerState::Connected(ConnectedServerState {
2641                    connection,
2642                    auth_state:
2643                        AuthState::Unauthenticated {
2644                            description,
2645                            configuration_view,
2646                            pending_auth_method,
2647                            _subscription,
2648                        },
2649                    ..
2650                }) => v_flex()
2651                    .flex_1()
2652                    .size_full()
2653                    .justify_end()
2654                    .child(self.render_auth_required_state(
2655                        connection,
2656                        description.as_ref(),
2657                        configuration_view.as_ref(),
2658                        pending_auth_method.as_ref(),
2659                        window,
2660                        cx,
2661                    ))
2662                    .into_any_element(),
2663                ServerState::Connected(connected) => {
2664                    if let Some(view) = connected.active_view() {
2665                        view.clone().into_any_element()
2666                    } else {
2667                        debug_panic!("This state should never be reached");
2668                        div().into_any_element()
2669                    }
2670                }
2671            })
2672    }
2673}
2674
2675fn plan_label_markdown_style(
2676    status: &acp::PlanEntryStatus,
2677    window: &Window,
2678    cx: &App,
2679) -> MarkdownStyle {
2680    let default_md_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
2681
2682    MarkdownStyle {
2683        base_text_style: TextStyle {
2684            color: cx.theme().colors().text_muted,
2685            strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
2686                Some(gpui::StrikethroughStyle {
2687                    thickness: px(1.),
2688                    color: Some(cx.theme().colors().text_muted.opacity(0.8)),
2689                })
2690            } else {
2691                None
2692            },
2693            ..default_md_style.base_text_style
2694        },
2695        ..default_md_style
2696    }
2697}
2698
2699#[cfg(test)]
2700pub(crate) mod tests {
2701    use acp_thread::{
2702        AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, StubAgentConnection,
2703    };
2704    use action_log::ActionLog;
2705    use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
2706    use agent_client_protocol::SessionId;
2707    use assistant_text_thread::TextThreadStore;
2708    use editor::MultiBufferOffset;
2709    use fs::FakeFs;
2710    use gpui::{EventEmitter, TestAppContext, VisualTestContext};
2711    use parking_lot::Mutex;
2712    use project::Project;
2713    use serde_json::json;
2714    use settings::SettingsStore;
2715    use std::any::Any;
2716    use std::path::{Path, PathBuf};
2717    use std::rc::Rc;
2718    use std::sync::Arc;
2719    use workspace::{Item, MultiWorkspace};
2720
2721    use crate::agent_panel;
2722
2723    use super::*;
2724
2725    #[gpui::test]
2726    async fn test_drop(cx: &mut TestAppContext) {
2727        init_test(cx);
2728
2729        let (conversation_view, _cx) =
2730            setup_conversation_view(StubAgentServer::default_response(), cx).await;
2731        let weak_view = conversation_view.downgrade();
2732        drop(conversation_view);
2733        assert!(!weak_view.is_upgradable());
2734    }
2735
2736    #[gpui::test]
2737    async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) {
2738        init_test(cx);
2739
2740        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2741            panic!("expected prompt from external source to sanitize successfully");
2742        };
2743        let initial_content = AgentInitialContent::FromExternalSource(prompt);
2744
2745        let (conversation_view, cx) = setup_conversation_view_with_initial_content(
2746            StubAgentServer::default_response(),
2747            initial_content,
2748            cx,
2749        )
2750        .await;
2751
2752        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
2753            assert!(view.show_external_source_prompt_warning);
2754            assert_eq!(view.thread.read(cx).entries().len(), 0);
2755            assert_eq!(view.message_editor.read(cx).text(cx), "Write me a script");
2756        });
2757    }
2758
2759    #[gpui::test]
2760    async fn test_external_source_prompt_warning_clears_after_send(cx: &mut TestAppContext) {
2761        init_test(cx);
2762
2763        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
2764            panic!("expected prompt from external source to sanitize successfully");
2765        };
2766        let initial_content = AgentInitialContent::FromExternalSource(prompt);
2767
2768        let (conversation_view, cx) = setup_conversation_view_with_initial_content(
2769            StubAgentServer::default_response(),
2770            initial_content,
2771            cx,
2772        )
2773        .await;
2774
2775        active_thread(&conversation_view, cx)
2776            .update_in(cx, |view, window, cx| view.send(window, cx));
2777        cx.run_until_parked();
2778
2779        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
2780            assert!(!view.show_external_source_prompt_warning);
2781            assert_eq!(view.message_editor.read(cx).text(cx), "");
2782            assert_eq!(view.thread.read(cx).entries().len(), 2);
2783        });
2784    }
2785
2786    #[gpui::test]
2787    async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
2788        init_test(cx);
2789
2790        let (conversation_view, cx) =
2791            setup_conversation_view(StubAgentServer::default_response(), cx).await;
2792
2793        let message_editor = message_editor(&conversation_view, cx);
2794        message_editor.update_in(cx, |editor, window, cx| {
2795            editor.set_text("Hello", window, cx);
2796        });
2797
2798        cx.deactivate_window();
2799
2800        active_thread(&conversation_view, cx)
2801            .update_in(cx, |view, window, cx| view.send(window, cx));
2802
2803        cx.run_until_parked();
2804
2805        assert!(
2806            cx.windows()
2807                .iter()
2808                .any(|window| window.downcast::<AgentNotification>().is_some())
2809        );
2810    }
2811
2812    #[gpui::test]
2813    async fn test_notification_for_error(cx: &mut TestAppContext) {
2814        init_test(cx);
2815
2816        let (conversation_view, cx) =
2817            setup_conversation_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
2818
2819        let message_editor = message_editor(&conversation_view, cx);
2820        message_editor.update_in(cx, |editor, window, cx| {
2821            editor.set_text("Hello", window, cx);
2822        });
2823
2824        cx.deactivate_window();
2825
2826        active_thread(&conversation_view, cx)
2827            .update_in(cx, |view, window, cx| view.send(window, cx));
2828
2829        cx.run_until_parked();
2830
2831        assert!(
2832            cx.windows()
2833                .iter()
2834                .any(|window| window.downcast::<AgentNotification>().is_some())
2835        );
2836    }
2837
2838    #[gpui::test]
2839    async fn test_recent_history_refreshes_when_history_cache_updated(cx: &mut TestAppContext) {
2840        init_test(cx);
2841
2842        let session_a = AgentSessionInfo::new(SessionId::new("session-a"));
2843        let session_b = AgentSessionInfo::new(SessionId::new("session-b"));
2844
2845        // Use a connection that provides a session list so ThreadHistory is created
2846        let (conversation_view, history, cx) = setup_thread_view_with_history(
2847            StubAgentServer::new(SessionHistoryConnection::new(vec![session_a.clone()])),
2848            cx,
2849        )
2850        .await;
2851
2852        // Initially has session_a from the connection's session list
2853        active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
2854            assert_eq!(view.recent_history_entries.len(), 1);
2855            assert_eq!(
2856                view.recent_history_entries[0].session_id,
2857                session_a.session_id
2858            );
2859        });
2860
2861        // Swap to a different session list
2862        let list_b: Rc<dyn AgentSessionList> =
2863            Rc::new(StubSessionList::new(vec![session_b.clone()]));
2864        history.update(cx, |history, cx| {
2865            history.set_session_list(list_b, cx);
2866        });
2867        cx.run_until_parked();
2868
2869        active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
2870            assert_eq!(view.recent_history_entries.len(), 1);
2871            assert_eq!(
2872                view.recent_history_entries[0].session_id,
2873                session_b.session_id
2874            );
2875        });
2876    }
2877
2878    #[gpui::test]
2879    async fn test_new_thread_creation_triggers_session_list_refresh(cx: &mut TestAppContext) {
2880        init_test(cx);
2881
2882        let session = AgentSessionInfo::new(SessionId::new("history-session"));
2883        let (conversation_view, _history, cx) = setup_thread_view_with_history(
2884            StubAgentServer::new(SessionHistoryConnection::new(vec![session.clone()])),
2885            cx,
2886        )
2887        .await;
2888
2889        active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
2890            assert_eq!(view.recent_history_entries.len(), 1);
2891            assert_eq!(
2892                view.recent_history_entries[0].session_id,
2893                session.session_id
2894            );
2895        });
2896    }
2897
2898    #[gpui::test]
2899    async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
2900        init_test(cx);
2901
2902        let fs = FakeFs::new(cx.executor());
2903        let project = Project::test(fs, [], cx).await;
2904        let (multi_workspace, cx) =
2905            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2906        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2907
2908        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
2909        let connection_store =
2910            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
2911
2912        let conversation_view = cx.update(|window, cx| {
2913            cx.new(|cx| {
2914                ConversationView::new(
2915                    Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
2916                    connection_store,
2917                    Agent::Custom { id: "Test".into() },
2918                    Some(SessionId::new("resume-session")),
2919                    None,
2920                    None,
2921                    None,
2922                    workspace.downgrade(),
2923                    project,
2924                    Some(thread_store),
2925                    None,
2926                    window,
2927                    cx,
2928                )
2929            })
2930        });
2931
2932        cx.run_until_parked();
2933
2934        conversation_view.read_with(cx, |view, cx| {
2935            let state = view.active_thread().unwrap();
2936            assert!(state.read(cx).resumed_without_history);
2937            assert_eq!(state.read(cx).list_state.item_count(), 0);
2938        });
2939    }
2940
2941    #[gpui::test]
2942    async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) {
2943        init_test(cx);
2944
2945        let fs = FakeFs::new(cx.executor());
2946        fs.insert_tree(
2947            "/project",
2948            json!({
2949                "subdir": {
2950                    "file.txt": "hello"
2951                }
2952            }),
2953        )
2954        .await;
2955        let project = Project::test(fs, [Path::new("/project")], cx).await;
2956        let (multi_workspace, cx) =
2957            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2958        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2959
2960        let connection = CwdCapturingConnection::new();
2961        let captured_cwd = connection.captured_work_dirs.clone();
2962
2963        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
2964        let connection_store =
2965            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
2966
2967        let _conversation_view = cx.update(|window, cx| {
2968            cx.new(|cx| {
2969                ConversationView::new(
2970                    Rc::new(StubAgentServer::new(connection)),
2971                    connection_store,
2972                    Agent::Custom { id: "Test".into() },
2973                    Some(SessionId::new("session-1")),
2974                    Some(PathList::new(&[PathBuf::from("/project/subdir")])),
2975                    None,
2976                    None,
2977                    workspace.downgrade(),
2978                    project,
2979                    Some(thread_store),
2980                    None,
2981                    window,
2982                    cx,
2983                )
2984            })
2985        });
2986
2987        cx.run_until_parked();
2988
2989        assert_eq!(
2990            captured_cwd.lock().as_ref().unwrap(),
2991            &PathList::new(&[Path::new("/project/subdir")]),
2992            "Should use session cwd when it's inside the project"
2993        );
2994    }
2995
2996    #[gpui::test]
2997    async fn test_refusal_handling(cx: &mut TestAppContext) {
2998        init_test(cx);
2999
3000        let (conversation_view, cx) =
3001            setup_conversation_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
3002
3003        let message_editor = message_editor(&conversation_view, cx);
3004        message_editor.update_in(cx, |editor, window, cx| {
3005            editor.set_text("Do something harmful", window, cx);
3006        });
3007
3008        active_thread(&conversation_view, cx)
3009            .update_in(cx, |view, window, cx| view.send(window, cx));
3010
3011        cx.run_until_parked();
3012
3013        // Check that the refusal error is set
3014        conversation_view.read_with(cx, |thread_view, cx| {
3015            let state = thread_view.active_thread().unwrap();
3016            assert!(
3017                matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)),
3018                "Expected refusal error to be set"
3019            );
3020        });
3021    }
3022
3023    #[gpui::test]
3024    async fn test_connect_failure_transitions_to_load_error(cx: &mut TestAppContext) {
3025        init_test(cx);
3026
3027        let (conversation_view, cx) = setup_conversation_view(FailingAgentServer, cx).await;
3028
3029        conversation_view.read_with(cx, |view, cx| {
3030            let title = view.title(cx);
3031            assert_eq!(
3032                title.as_ref(),
3033                "Error Loading Codex CLI",
3034                "Tab title should show the agent name with an error prefix"
3035            );
3036            match &view.server_state {
3037                ServerState::LoadError {
3038                    error: LoadError::Other(msg),
3039                    ..
3040                } => {
3041                    assert!(
3042                        msg.contains("Invalid gzip header"),
3043                        "Error callout should contain the underlying extraction error, got: {msg}"
3044                    );
3045                }
3046                other => panic!(
3047                    "Expected LoadError::Other, got: {}",
3048                    match other {
3049                        ServerState::Loading(_) => "Loading (stuck!)",
3050                        ServerState::LoadError { .. } => "LoadError (wrong variant)",
3051                        ServerState::Connected(_) => "Connected",
3052                    }
3053                ),
3054            }
3055        });
3056    }
3057
3058    #[gpui::test]
3059    async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) {
3060        init_test(cx);
3061
3062        let connection = AuthGatedAgentConnection::new();
3063        let (conversation_view, cx) =
3064            setup_conversation_view(StubAgentServer::new(connection), cx).await;
3065
3066        // When new_session returns AuthRequired, the server should transition
3067        // to Connected + Unauthenticated rather than getting stuck in Loading.
3068        conversation_view.read_with(cx, |view, _cx| {
3069            let connected = view
3070                .as_connected()
3071                .expect("Should be in Connected state even though auth is required");
3072            assert!(
3073                !connected.auth_state.is_ok(),
3074                "Auth state should be Unauthenticated"
3075            );
3076            assert!(
3077                connected.active_id.is_none(),
3078                "There should be no active thread since no session was created"
3079            );
3080            assert!(
3081                connected.threads.is_empty(),
3082                "There should be no threads since no session was created"
3083            );
3084        });
3085
3086        conversation_view.read_with(cx, |view, _cx| {
3087            assert!(
3088                view.active_thread().is_none(),
3089                "active_thread() should be None when unauthenticated without a session"
3090            );
3091        });
3092
3093        // Authenticate using the real authenticate flow on ConnectionView.
3094        // This calls connection.authenticate(), which flips the internal flag,
3095        // then on success triggers reset() -> new_session() which now succeeds.
3096        conversation_view.update_in(cx, |view, window, cx| {
3097            view.authenticate(
3098                acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID),
3099                window,
3100                cx,
3101            );
3102        });
3103        cx.run_until_parked();
3104
3105        // After auth, the server should have an active thread in the Ok state.
3106        conversation_view.read_with(cx, |view, cx| {
3107            let connected = view
3108                .as_connected()
3109                .expect("Should still be in Connected state after auth");
3110            assert!(connected.auth_state.is_ok(), "Auth state should be Ok");
3111            assert!(
3112                connected.active_id.is_some(),
3113                "There should be an active thread after successful auth"
3114            );
3115            assert_eq!(
3116                connected.threads.len(),
3117                1,
3118                "There should be exactly one thread"
3119            );
3120
3121            let active = view
3122                .active_thread()
3123                .expect("active_thread() should return the new thread");
3124            assert!(
3125                active.read(cx).thread_error.is_none(),
3126                "The new thread should have no errors"
3127            );
3128        });
3129    }
3130
3131    #[gpui::test]
3132    async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
3133        init_test(cx);
3134
3135        let tool_call_id = acp::ToolCallId::new("1");
3136        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label")
3137            .kind(acp::ToolKind::Edit)
3138            .content(vec!["hi".into()]);
3139        let connection =
3140            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
3141                tool_call_id,
3142                PermissionOptions::Flat(vec![acp::PermissionOption::new(
3143                    "1",
3144                    "Allow",
3145                    acp::PermissionOptionKind::AllowOnce,
3146                )]),
3147            )]));
3148
3149        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
3150
3151        let (conversation_view, cx) =
3152            setup_conversation_view(StubAgentServer::new(connection), cx).await;
3153
3154        let message_editor = message_editor(&conversation_view, cx);
3155        message_editor.update_in(cx, |editor, window, cx| {
3156            editor.set_text("Hello", window, cx);
3157        });
3158
3159        cx.deactivate_window();
3160
3161        active_thread(&conversation_view, cx)
3162            .update_in(cx, |view, window, cx| view.send(window, cx));
3163
3164        cx.run_until_parked();
3165
3166        assert!(
3167            cx.windows()
3168                .iter()
3169                .any(|window| window.downcast::<AgentNotification>().is_some())
3170        );
3171    }
3172
3173    #[gpui::test]
3174    async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) {
3175        init_test(cx);
3176
3177        let (conversation_view, cx) =
3178            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3179
3180        add_to_workspace(conversation_view.clone(), cx);
3181
3182        let message_editor = message_editor(&conversation_view, cx);
3183
3184        message_editor.update_in(cx, |editor, window, cx| {
3185            editor.set_text("Hello", window, cx);
3186        });
3187
3188        // Window is active (don't deactivate), but panel will be hidden
3189        // Note: In the test environment, the panel is not actually added to the dock,
3190        // so is_agent_panel_hidden will return true
3191
3192        active_thread(&conversation_view, cx)
3193            .update_in(cx, |view, window, cx| view.send(window, cx));
3194
3195        cx.run_until_parked();
3196
3197        // Should show notification because window is active but panel is hidden
3198        assert!(
3199            cx.windows()
3200                .iter()
3201                .any(|window| window.downcast::<AgentNotification>().is_some()),
3202            "Expected notification when panel is hidden"
3203        );
3204    }
3205
3206    #[gpui::test]
3207    async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) {
3208        init_test(cx);
3209
3210        let (conversation_view, cx) =
3211            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3212
3213        let message_editor = message_editor(&conversation_view, cx);
3214        message_editor.update_in(cx, |editor, window, cx| {
3215            editor.set_text("Hello", window, cx);
3216        });
3217
3218        // Deactivate window - should show notification regardless of setting
3219        cx.deactivate_window();
3220
3221        active_thread(&conversation_view, cx)
3222            .update_in(cx, |view, window, cx| view.send(window, cx));
3223
3224        cx.run_until_parked();
3225
3226        // Should still show notification when window is inactive (existing behavior)
3227        assert!(
3228            cx.windows()
3229                .iter()
3230                .any(|window| window.downcast::<AgentNotification>().is_some()),
3231            "Expected notification when window is inactive"
3232        );
3233    }
3234
3235    #[gpui::test]
3236    async fn test_notification_when_workspace_is_background_in_multi_workspace(
3237        cx: &mut TestAppContext,
3238    ) {
3239        init_test(cx);
3240
3241        // Enable multi-workspace feature flag and init globals needed by AgentPanel
3242        let fs = FakeFs::new(cx.executor());
3243
3244        cx.update(|cx| {
3245            cx.update_flags(true, vec!["agent-v2".to_string()]);
3246            agent::ThreadStore::init_global(cx);
3247            language_model::LanguageModelRegistry::test(cx);
3248            <dyn Fs>::set_global(fs.clone(), cx);
3249        });
3250
3251        let project1 = Project::test(fs.clone(), [], cx).await;
3252
3253        // Create a MultiWorkspace window with one workspace
3254        let multi_workspace_handle =
3255            cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
3256
3257        // Get workspace 1 (the initial workspace)
3258        let workspace1 = multi_workspace_handle
3259            .read_with(cx, |mw, _cx| mw.workspace().clone())
3260            .unwrap();
3261
3262        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
3263
3264        workspace1.update_in(cx, |workspace, window, cx| {
3265            let text_thread_store =
3266                cx.new(|cx| TextThreadStore::fake(workspace.project().clone(), cx));
3267            let panel =
3268                cx.new(|cx| crate::AgentPanel::new(workspace, text_thread_store, None, window, cx));
3269            workspace.add_panel(panel, window, cx);
3270
3271            // Open the dock and activate the agent panel so it's visible
3272            workspace.focus_panel::<crate::AgentPanel>(window, cx);
3273        });
3274
3275        cx.run_until_parked();
3276
3277        cx.read(|cx| {
3278            assert!(
3279                crate::AgentPanel::is_visible(&workspace1, cx),
3280                "AgentPanel should be visible in workspace1's dock"
3281            );
3282        });
3283
3284        // Set up thread view in workspace 1
3285        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3286        let connection_store =
3287            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
3288
3289        let agent = StubAgentServer::default_response();
3290        let conversation_view = cx.update(|window, cx| {
3291            cx.new(|cx| {
3292                ConversationView::new(
3293                    Rc::new(agent),
3294                    connection_store,
3295                    Agent::Custom { id: "Test".into() },
3296                    None,
3297                    None,
3298                    None,
3299                    None,
3300                    workspace1.downgrade(),
3301                    project1.clone(),
3302                    Some(thread_store),
3303                    None,
3304                    window,
3305                    cx,
3306                )
3307            })
3308        });
3309        cx.run_until_parked();
3310
3311        let message_editor = message_editor(&conversation_view, cx);
3312        message_editor.update_in(cx, |editor, window, cx| {
3313            editor.set_text("Hello", window, cx);
3314        });
3315
3316        // Create a second workspace and switch to it.
3317        // This makes workspace1 the "background" workspace.
3318        let project2 = Project::test(fs, [], cx).await;
3319        multi_workspace_handle
3320            .update(cx, |mw, window, cx| {
3321                mw.test_add_workspace(project2, window, cx);
3322            })
3323            .unwrap();
3324
3325        cx.run_until_parked();
3326
3327        // Verify workspace1 is no longer the active workspace
3328        multi_workspace_handle
3329            .read_with(cx, |mw, _cx| {
3330                assert_eq!(mw.active_workspace_index(), 1);
3331                assert_ne!(mw.workspace(), &workspace1);
3332            })
3333            .unwrap();
3334
3335        // Window is active, agent panel is visible in workspace1, but workspace1
3336        // is in the background. The notification should show because the user
3337        // can't actually see the agent panel.
3338        active_thread(&conversation_view, cx)
3339            .update_in(cx, |view, window, cx| view.send(window, cx));
3340
3341        cx.run_until_parked();
3342
3343        assert!(
3344            cx.windows()
3345                .iter()
3346                .any(|window| window.downcast::<AgentNotification>().is_some()),
3347            "Expected notification when workspace is in background within MultiWorkspace"
3348        );
3349
3350        // Also verify: clicking "View Panel" should switch to workspace1.
3351        cx.windows()
3352            .iter()
3353            .find_map(|window| window.downcast::<AgentNotification>())
3354            .unwrap()
3355            .update(cx, |window, _, cx| window.accept(cx))
3356            .unwrap();
3357
3358        cx.run_until_parked();
3359
3360        multi_workspace_handle
3361            .read_with(cx, |mw, _cx| {
3362                assert_eq!(
3363                    mw.workspace(),
3364                    &workspace1,
3365                    "Expected workspace1 to become the active workspace after accepting notification"
3366                );
3367            })
3368            .unwrap();
3369    }
3370
3371    #[gpui::test]
3372    async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
3373        init_test(cx);
3374
3375        // Set notify_when_agent_waiting to Never
3376        cx.update(|cx| {
3377            AgentSettings::override_global(
3378                AgentSettings {
3379                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
3380                    ..AgentSettings::get_global(cx).clone()
3381                },
3382                cx,
3383            );
3384        });
3385
3386        let (conversation_view, cx) =
3387            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3388
3389        let message_editor = message_editor(&conversation_view, cx);
3390        message_editor.update_in(cx, |editor, window, cx| {
3391            editor.set_text("Hello", window, cx);
3392        });
3393
3394        // Window is active
3395
3396        active_thread(&conversation_view, cx)
3397            .update_in(cx, |view, window, cx| view.send(window, cx));
3398
3399        cx.run_until_parked();
3400
3401        // Should NOT show notification because notify_when_agent_waiting is Never
3402        assert!(
3403            !cx.windows()
3404                .iter()
3405                .any(|window| window.downcast::<AgentNotification>().is_some()),
3406            "Expected no notification when notify_when_agent_waiting is Never"
3407        );
3408    }
3409
3410    #[gpui::test]
3411    async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
3412        init_test(cx);
3413
3414        let (conversation_view, cx) =
3415            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3416
3417        let weak_view = conversation_view.downgrade();
3418
3419        let message_editor = message_editor(&conversation_view, cx);
3420        message_editor.update_in(cx, |editor, window, cx| {
3421            editor.set_text("Hello", window, cx);
3422        });
3423
3424        cx.deactivate_window();
3425
3426        active_thread(&conversation_view, cx)
3427            .update_in(cx, |view, window, cx| view.send(window, cx));
3428
3429        cx.run_until_parked();
3430
3431        // Verify notification is shown
3432        assert!(
3433            cx.windows()
3434                .iter()
3435                .any(|window| window.downcast::<AgentNotification>().is_some()),
3436            "Expected notification to be shown"
3437        );
3438
3439        // Drop the thread view (simulating navigation to a new thread)
3440        drop(conversation_view);
3441        drop(message_editor);
3442        // Trigger an update to flush effects, which will call release_dropped_entities
3443        cx.update(|_window, _cx| {});
3444        cx.run_until_parked();
3445
3446        // Verify the entity was actually released
3447        assert!(
3448            !weak_view.is_upgradable(),
3449            "Thread view entity should be released after dropping"
3450        );
3451
3452        // The notification should be automatically closed via on_release
3453        assert!(
3454            !cx.windows()
3455                .iter()
3456                .any(|window| window.downcast::<AgentNotification>().is_some()),
3457            "Notification should be closed when thread view is dropped"
3458        );
3459    }
3460
3461    async fn setup_conversation_view(
3462        agent: impl AgentServer + 'static,
3463        cx: &mut TestAppContext,
3464    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
3465        let (conversation_view, _history, cx) =
3466            setup_conversation_view_with_history_and_initial_content(agent, None, cx).await;
3467        (conversation_view, cx)
3468    }
3469
3470    async fn setup_thread_view_with_history(
3471        agent: impl AgentServer + 'static,
3472        cx: &mut TestAppContext,
3473    ) -> (
3474        Entity<ConversationView>,
3475        Entity<ThreadHistory>,
3476        &mut VisualTestContext,
3477    ) {
3478        let (conversation_view, history, cx) =
3479            setup_conversation_view_with_history_and_initial_content(agent, None, cx).await;
3480        (conversation_view, history.expect("Missing history"), cx)
3481    }
3482
3483    async fn setup_conversation_view_with_initial_content(
3484        agent: impl AgentServer + 'static,
3485        initial_content: AgentInitialContent,
3486        cx: &mut TestAppContext,
3487    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
3488        let (conversation_view, _history, cx) =
3489            setup_conversation_view_with_history_and_initial_content(
3490                agent,
3491                Some(initial_content),
3492                cx,
3493            )
3494            .await;
3495        (conversation_view, cx)
3496    }
3497
3498    async fn setup_conversation_view_with_history_and_initial_content(
3499        agent: impl AgentServer + 'static,
3500        initial_content: Option<AgentInitialContent>,
3501        cx: &mut TestAppContext,
3502    ) -> (
3503        Entity<ConversationView>,
3504        Option<Entity<ThreadHistory>>,
3505        &mut VisualTestContext,
3506    ) {
3507        let fs = FakeFs::new(cx.executor());
3508        let project = Project::test(fs, [], cx).await;
3509        let (multi_workspace, cx) =
3510            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3511        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3512
3513        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
3514        let connection_store =
3515            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
3516
3517        let agent_key = Agent::Custom { id: "Test".into() };
3518
3519        let conversation_view = cx.update(|window, cx| {
3520            cx.new(|cx| {
3521                ConversationView::new(
3522                    Rc::new(agent),
3523                    connection_store.clone(),
3524                    agent_key.clone(),
3525                    None,
3526                    None,
3527                    None,
3528                    initial_content,
3529                    workspace.downgrade(),
3530                    project,
3531                    Some(thread_store),
3532                    None,
3533                    window,
3534                    cx,
3535                )
3536            })
3537        });
3538        cx.run_until_parked();
3539
3540        let history = cx.update(|_window, cx| {
3541            connection_store
3542                .read(cx)
3543                .entry(&agent_key)
3544                .and_then(|e| e.read(cx).history().cloned())
3545        });
3546
3547        (conversation_view, history, cx)
3548    }
3549
3550    fn add_to_workspace(conversation_view: Entity<ConversationView>, cx: &mut VisualTestContext) {
3551        let workspace =
3552            conversation_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
3553
3554        workspace
3555            .update_in(cx, |workspace, window, cx| {
3556                workspace.add_item_to_active_pane(
3557                    Box::new(cx.new(|_| ThreadViewItem(conversation_view.clone()))),
3558                    None,
3559                    true,
3560                    window,
3561                    cx,
3562                );
3563            })
3564            .unwrap();
3565    }
3566
3567    struct ThreadViewItem(Entity<ConversationView>);
3568
3569    impl Item for ThreadViewItem {
3570        type Event = ();
3571
3572        fn include_in_nav_history() -> bool {
3573            false
3574        }
3575
3576        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
3577            "Test".into()
3578        }
3579    }
3580
3581    impl EventEmitter<()> for ThreadViewItem {}
3582
3583    impl Focusable for ThreadViewItem {
3584        fn focus_handle(&self, cx: &App) -> FocusHandle {
3585            self.0.read(cx).focus_handle(cx)
3586        }
3587    }
3588
3589    impl Render for ThreadViewItem {
3590        fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3591            // Render the title editor in the element tree too. In the real app
3592            // it is part of the agent panel
3593            let title_editor = self
3594                .0
3595                .read(cx)
3596                .active_thread()
3597                .map(|t| t.read(cx).title_editor.clone());
3598
3599            v_flex().children(title_editor).child(self.0.clone())
3600        }
3601    }
3602
3603    pub(crate) struct StubAgentServer<C> {
3604        connection: C,
3605    }
3606
3607    impl<C> StubAgentServer<C> {
3608        pub(crate) fn new(connection: C) -> Self {
3609            Self { connection }
3610        }
3611    }
3612
3613    impl StubAgentServer<StubAgentConnection> {
3614        pub(crate) fn default_response() -> Self {
3615            let conn = StubAgentConnection::new();
3616            conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3617                acp::ContentChunk::new("Default response".into()),
3618            )]);
3619            Self::new(conn)
3620        }
3621    }
3622
3623    impl<C> AgentServer for StubAgentServer<C>
3624    where
3625        C: 'static + AgentConnection + Send + Clone,
3626    {
3627        fn logo(&self) -> ui::IconName {
3628            ui::IconName::ZedAgent
3629        }
3630
3631        fn agent_id(&self) -> AgentId {
3632            "Test".into()
3633        }
3634
3635        fn connect(
3636            &self,
3637            _delegate: AgentServerDelegate,
3638            _project: Entity<Project>,
3639            _cx: &mut App,
3640        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3641            Task::ready(Ok(Rc::new(self.connection.clone())))
3642        }
3643
3644        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3645            self
3646        }
3647    }
3648
3649    struct FailingAgentServer;
3650
3651    impl AgentServer for FailingAgentServer {
3652        fn logo(&self) -> ui::IconName {
3653            ui::IconName::AiOpenAi
3654        }
3655
3656        fn agent_id(&self) -> AgentId {
3657            AgentId::new("Codex CLI")
3658        }
3659
3660        fn connect(
3661            &self,
3662            _delegate: AgentServerDelegate,
3663            _project: Entity<Project>,
3664            _cx: &mut App,
3665        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
3666            Task::ready(Err(anyhow!(
3667                "extracting downloaded asset for \
3668                 https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\
3669                 codex-acp-0.9.4-aarch64-pc-windows-msvc.zip: \
3670                 failed to iterate over archive: Invalid gzip header"
3671            )))
3672        }
3673
3674        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3675            self
3676        }
3677    }
3678
3679    #[derive(Clone)]
3680    struct StubSessionList {
3681        sessions: Vec<AgentSessionInfo>,
3682    }
3683
3684    impl StubSessionList {
3685        fn new(sessions: Vec<AgentSessionInfo>) -> Self {
3686            Self { sessions }
3687        }
3688    }
3689
3690    impl AgentSessionList for StubSessionList {
3691        fn list_sessions(
3692            &self,
3693            _request: AgentSessionListRequest,
3694            _cx: &mut App,
3695        ) -> Task<anyhow::Result<AgentSessionListResponse>> {
3696            Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
3697        }
3698
3699        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3700            self
3701        }
3702    }
3703
3704    #[derive(Clone)]
3705    struct SessionHistoryConnection {
3706        sessions: Vec<AgentSessionInfo>,
3707    }
3708
3709    impl SessionHistoryConnection {
3710        fn new(sessions: Vec<AgentSessionInfo>) -> Self {
3711            Self { sessions }
3712        }
3713    }
3714
3715    fn build_test_thread(
3716        connection: Rc<dyn AgentConnection>,
3717        project: Entity<Project>,
3718        name: &'static str,
3719        session_id: SessionId,
3720        cx: &mut App,
3721    ) -> Entity<AcpThread> {
3722        let action_log = cx.new(|_| ActionLog::new(project.clone()));
3723        cx.new(|cx| {
3724            AcpThread::new(
3725                None,
3726                Some(name.into()),
3727                None,
3728                connection,
3729                project,
3730                action_log,
3731                session_id,
3732                watch::Receiver::constant(
3733                    acp::PromptCapabilities::new()
3734                        .image(true)
3735                        .audio(true)
3736                        .embedded_context(true),
3737                ),
3738                cx,
3739            )
3740        })
3741    }
3742
3743    impl AgentConnection for SessionHistoryConnection {
3744        fn agent_id(&self) -> AgentId {
3745            AgentId::new("history-connection")
3746        }
3747
3748        fn telemetry_id(&self) -> SharedString {
3749            "history-connection".into()
3750        }
3751
3752        fn new_session(
3753            self: Rc<Self>,
3754            project: Entity<Project>,
3755            _work_dirs: PathList,
3756            cx: &mut App,
3757        ) -> Task<anyhow::Result<Entity<AcpThread>>> {
3758            let thread = build_test_thread(
3759                self,
3760                project,
3761                "SessionHistoryConnection",
3762                SessionId::new("history-session"),
3763                cx,
3764            );
3765            Task::ready(Ok(thread))
3766        }
3767
3768        fn supports_load_session(&self) -> bool {
3769            true
3770        }
3771
3772        fn session_list(&self, _cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
3773            Some(Rc::new(StubSessionList::new(self.sessions.clone())))
3774        }
3775
3776        fn auth_methods(&self) -> &[acp::AuthMethod] {
3777            &[]
3778        }
3779
3780        fn authenticate(
3781            &self,
3782            _method_id: acp::AuthMethodId,
3783            _cx: &mut App,
3784        ) -> Task<anyhow::Result<()>> {
3785            Task::ready(Ok(()))
3786        }
3787
3788        fn prompt(
3789            &self,
3790            _id: Option<acp_thread::UserMessageId>,
3791            _params: acp::PromptRequest,
3792            _cx: &mut App,
3793        ) -> Task<anyhow::Result<acp::PromptResponse>> {
3794            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
3795        }
3796
3797        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
3798
3799        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3800            self
3801        }
3802    }
3803
3804    #[derive(Clone)]
3805    struct ResumeOnlyAgentConnection;
3806
3807    impl AgentConnection for ResumeOnlyAgentConnection {
3808        fn agent_id(&self) -> AgentId {
3809            AgentId::new("resume-only")
3810        }
3811
3812        fn telemetry_id(&self) -> SharedString {
3813            "resume-only".into()
3814        }
3815
3816        fn new_session(
3817            self: Rc<Self>,
3818            project: Entity<Project>,
3819            _work_dirs: PathList,
3820            cx: &mut gpui::App,
3821        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3822            let thread = build_test_thread(
3823                self,
3824                project,
3825                "ResumeOnlyAgentConnection",
3826                SessionId::new("new-session"),
3827                cx,
3828            );
3829            Task::ready(Ok(thread))
3830        }
3831
3832        fn supports_resume_session(&self) -> bool {
3833            true
3834        }
3835
3836        fn resume_session(
3837            self: Rc<Self>,
3838            session_id: acp::SessionId,
3839            project: Entity<Project>,
3840            _work_dirs: PathList,
3841            _title: Option<SharedString>,
3842            cx: &mut App,
3843        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3844            let thread =
3845                build_test_thread(self, project, "ResumeOnlyAgentConnection", session_id, cx);
3846            Task::ready(Ok(thread))
3847        }
3848
3849        fn auth_methods(&self) -> &[acp::AuthMethod] {
3850            &[]
3851        }
3852
3853        fn authenticate(
3854            &self,
3855            _method_id: acp::AuthMethodId,
3856            _cx: &mut App,
3857        ) -> Task<gpui::Result<()>> {
3858            Task::ready(Ok(()))
3859        }
3860
3861        fn prompt(
3862            &self,
3863            _id: Option<acp_thread::UserMessageId>,
3864            _params: acp::PromptRequest,
3865            _cx: &mut App,
3866        ) -> Task<gpui::Result<acp::PromptResponse>> {
3867            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
3868        }
3869
3870        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
3871
3872        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3873            self
3874        }
3875    }
3876
3877    /// Simulates an agent that requires authentication before a session can be
3878    /// created. `new_session` returns `AuthRequired` until `authenticate` is
3879    /// called with the correct method, after which sessions are created normally.
3880    #[derive(Clone)]
3881    struct AuthGatedAgentConnection {
3882        authenticated: Arc<Mutex<bool>>,
3883        auth_method: acp::AuthMethod,
3884    }
3885
3886    impl AuthGatedAgentConnection {
3887        const AUTH_METHOD_ID: &str = "test-login";
3888
3889        fn new() -> Self {
3890            Self {
3891                authenticated: Arc::new(Mutex::new(false)),
3892                auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
3893                    Self::AUTH_METHOD_ID,
3894                    "Test Login",
3895                )),
3896            }
3897        }
3898    }
3899
3900    impl AgentConnection for AuthGatedAgentConnection {
3901        fn agent_id(&self) -> AgentId {
3902            AgentId::new("auth-gated")
3903        }
3904
3905        fn telemetry_id(&self) -> SharedString {
3906            "auth-gated".into()
3907        }
3908
3909        fn new_session(
3910            self: Rc<Self>,
3911            project: Entity<Project>,
3912            work_dirs: PathList,
3913            cx: &mut gpui::App,
3914        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3915            if !*self.authenticated.lock() {
3916                return Task::ready(Err(acp_thread::AuthRequired::new()
3917                    .with_description("Sign in to continue".to_string())
3918                    .into()));
3919            }
3920
3921            let session_id = acp::SessionId::new("auth-gated-session");
3922            let action_log = cx.new(|_| ActionLog::new(project.clone()));
3923            Task::ready(Ok(cx.new(|cx| {
3924                AcpThread::new(
3925                    None,
3926                    None,
3927                    Some(work_dirs),
3928                    self,
3929                    project,
3930                    action_log,
3931                    session_id,
3932                    watch::Receiver::constant(
3933                        acp::PromptCapabilities::new()
3934                            .image(true)
3935                            .audio(true)
3936                            .embedded_context(true),
3937                    ),
3938                    cx,
3939                )
3940            })))
3941        }
3942
3943        fn auth_methods(&self) -> &[acp::AuthMethod] {
3944            std::slice::from_ref(&self.auth_method)
3945        }
3946
3947        fn authenticate(
3948            &self,
3949            method_id: acp::AuthMethodId,
3950            _cx: &mut App,
3951        ) -> Task<gpui::Result<()>> {
3952            if &method_id == self.auth_method.id() {
3953                *self.authenticated.lock() = true;
3954                Task::ready(Ok(()))
3955            } else {
3956                Task::ready(Err(anyhow::anyhow!("Unknown auth method")))
3957            }
3958        }
3959
3960        fn prompt(
3961            &self,
3962            _id: Option<acp_thread::UserMessageId>,
3963            _params: acp::PromptRequest,
3964            _cx: &mut App,
3965        ) -> Task<gpui::Result<acp::PromptResponse>> {
3966            unimplemented!()
3967        }
3968
3969        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
3970            unimplemented!()
3971        }
3972
3973        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3974            self
3975        }
3976    }
3977
3978    #[derive(Clone)]
3979    struct SaboteurAgentConnection;
3980
3981    impl AgentConnection for SaboteurAgentConnection {
3982        fn agent_id(&self) -> AgentId {
3983            AgentId::new("saboteur")
3984        }
3985
3986        fn telemetry_id(&self) -> SharedString {
3987            "saboteur".into()
3988        }
3989
3990        fn new_session(
3991            self: Rc<Self>,
3992            project: Entity<Project>,
3993            work_dirs: PathList,
3994            cx: &mut gpui::App,
3995        ) -> Task<gpui::Result<Entity<AcpThread>>> {
3996            Task::ready(Ok(cx.new(|cx| {
3997                let action_log = cx.new(|_| ActionLog::new(project.clone()));
3998                AcpThread::new(
3999                    None,
4000                    None,
4001                    Some(work_dirs),
4002                    self,
4003                    project,
4004                    action_log,
4005                    SessionId::new("test"),
4006                    watch::Receiver::constant(
4007                        acp::PromptCapabilities::new()
4008                            .image(true)
4009                            .audio(true)
4010                            .embedded_context(true),
4011                    ),
4012                    cx,
4013                )
4014            })))
4015        }
4016
4017        fn auth_methods(&self) -> &[acp::AuthMethod] {
4018            &[]
4019        }
4020
4021        fn authenticate(
4022            &self,
4023            _method_id: acp::AuthMethodId,
4024            _cx: &mut App,
4025        ) -> Task<gpui::Result<()>> {
4026            unimplemented!()
4027        }
4028
4029        fn prompt(
4030            &self,
4031            _id: Option<acp_thread::UserMessageId>,
4032            _params: acp::PromptRequest,
4033            _cx: &mut App,
4034        ) -> Task<gpui::Result<acp::PromptResponse>> {
4035            Task::ready(Err(anyhow::anyhow!("Error prompting")))
4036        }
4037
4038        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4039            unimplemented!()
4040        }
4041
4042        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4043            self
4044        }
4045    }
4046
4047    /// Simulates a model which always returns a refusal response
4048    #[derive(Clone)]
4049    struct RefusalAgentConnection;
4050
4051    impl AgentConnection for RefusalAgentConnection {
4052        fn agent_id(&self) -> AgentId {
4053            AgentId::new("refusal")
4054        }
4055
4056        fn telemetry_id(&self) -> SharedString {
4057            "refusal".into()
4058        }
4059
4060        fn new_session(
4061            self: Rc<Self>,
4062            project: Entity<Project>,
4063            work_dirs: PathList,
4064            cx: &mut gpui::App,
4065        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4066            Task::ready(Ok(cx.new(|cx| {
4067                let action_log = cx.new(|_| ActionLog::new(project.clone()));
4068                AcpThread::new(
4069                    None,
4070                    None,
4071                    Some(work_dirs),
4072                    self,
4073                    project,
4074                    action_log,
4075                    SessionId::new("test"),
4076                    watch::Receiver::constant(
4077                        acp::PromptCapabilities::new()
4078                            .image(true)
4079                            .audio(true)
4080                            .embedded_context(true),
4081                    ),
4082                    cx,
4083                )
4084            })))
4085        }
4086
4087        fn auth_methods(&self) -> &[acp::AuthMethod] {
4088            &[]
4089        }
4090
4091        fn authenticate(
4092            &self,
4093            _method_id: acp::AuthMethodId,
4094            _cx: &mut App,
4095        ) -> Task<gpui::Result<()>> {
4096            unimplemented!()
4097        }
4098
4099        fn prompt(
4100            &self,
4101            _id: Option<acp_thread::UserMessageId>,
4102            _params: acp::PromptRequest,
4103            _cx: &mut App,
4104        ) -> Task<gpui::Result<acp::PromptResponse>> {
4105            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal)))
4106        }
4107
4108        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
4109            unimplemented!()
4110        }
4111
4112        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4113            self
4114        }
4115    }
4116
4117    #[derive(Clone)]
4118    struct CwdCapturingConnection {
4119        captured_work_dirs: Arc<Mutex<Option<PathList>>>,
4120    }
4121
4122    impl CwdCapturingConnection {
4123        fn new() -> Self {
4124            Self {
4125                captured_work_dirs: Arc::new(Mutex::new(None)),
4126            }
4127        }
4128    }
4129
4130    impl AgentConnection for CwdCapturingConnection {
4131        fn agent_id(&self) -> AgentId {
4132            AgentId::new("cwd-capturing")
4133        }
4134
4135        fn telemetry_id(&self) -> SharedString {
4136            "cwd-capturing".into()
4137        }
4138
4139        fn new_session(
4140            self: Rc<Self>,
4141            project: Entity<Project>,
4142            work_dirs: PathList,
4143            cx: &mut gpui::App,
4144        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4145            *self.captured_work_dirs.lock() = Some(work_dirs.clone());
4146            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4147            let thread = cx.new(|cx| {
4148                AcpThread::new(
4149                    None,
4150                    None,
4151                    Some(work_dirs),
4152                    self.clone(),
4153                    project,
4154                    action_log,
4155                    SessionId::new("new-session"),
4156                    watch::Receiver::constant(
4157                        acp::PromptCapabilities::new()
4158                            .image(true)
4159                            .audio(true)
4160                            .embedded_context(true),
4161                    ),
4162                    cx,
4163                )
4164            });
4165            Task::ready(Ok(thread))
4166        }
4167
4168        fn supports_load_session(&self) -> bool {
4169            true
4170        }
4171
4172        fn load_session(
4173            self: Rc<Self>,
4174            session_id: acp::SessionId,
4175            project: Entity<Project>,
4176            work_dirs: PathList,
4177            _title: Option<SharedString>,
4178            cx: &mut App,
4179        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4180            *self.captured_work_dirs.lock() = Some(work_dirs.clone());
4181            let action_log = cx.new(|_| ActionLog::new(project.clone()));
4182            let thread = cx.new(|cx| {
4183                AcpThread::new(
4184                    None,
4185                    None,
4186                    Some(work_dirs),
4187                    self.clone(),
4188                    project,
4189                    action_log,
4190                    session_id,
4191                    watch::Receiver::constant(
4192                        acp::PromptCapabilities::new()
4193                            .image(true)
4194                            .audio(true)
4195                            .embedded_context(true),
4196                    ),
4197                    cx,
4198                )
4199            });
4200            Task::ready(Ok(thread))
4201        }
4202
4203        fn auth_methods(&self) -> &[acp::AuthMethod] {
4204            &[]
4205        }
4206
4207        fn authenticate(
4208            &self,
4209            _method_id: acp::AuthMethodId,
4210            _cx: &mut App,
4211        ) -> Task<gpui::Result<()>> {
4212            Task::ready(Ok(()))
4213        }
4214
4215        fn prompt(
4216            &self,
4217            _id: Option<acp_thread::UserMessageId>,
4218            _params: acp::PromptRequest,
4219            _cx: &mut App,
4220        ) -> Task<gpui::Result<acp::PromptResponse>> {
4221            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4222        }
4223
4224        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4225
4226        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4227            self
4228        }
4229    }
4230
4231    pub(crate) fn init_test(cx: &mut TestAppContext) {
4232        cx.update(|cx| {
4233            let settings_store = SettingsStore::test(cx);
4234            cx.set_global(settings_store);
4235            SidebarThreadMetadataStore::init_global(cx);
4236            theme::init(theme::LoadThemes::JustBase, cx);
4237            editor::init(cx);
4238            agent_panel::init(cx);
4239            release_channel::init(semver::Version::new(0, 0, 0), cx);
4240            prompt_store::init(cx)
4241        });
4242    }
4243
4244    fn active_thread(
4245        conversation_view: &Entity<ConversationView>,
4246        cx: &TestAppContext,
4247    ) -> Entity<ThreadView> {
4248        cx.read(|cx| {
4249            conversation_view
4250                .read(cx)
4251                .active_thread()
4252                .expect("No active thread")
4253                .clone()
4254        })
4255    }
4256
4257    fn message_editor(
4258        conversation_view: &Entity<ConversationView>,
4259        cx: &TestAppContext,
4260    ) -> Entity<MessageEditor> {
4261        let thread = active_thread(conversation_view, cx);
4262        cx.read(|cx| thread.read(cx).message_editor.clone())
4263    }
4264
4265    #[gpui::test]
4266    async fn test_rewind_views(cx: &mut TestAppContext) {
4267        init_test(cx);
4268
4269        let fs = FakeFs::new(cx.executor());
4270        fs.insert_tree(
4271            "/project",
4272            json!({
4273                "test1.txt": "old content 1",
4274                "test2.txt": "old content 2"
4275            }),
4276        )
4277        .await;
4278        let project = Project::test(fs, [Path::new("/project")], cx).await;
4279        let (multi_workspace, cx) =
4280            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4281        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4282
4283        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4284        let connection_store =
4285            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4286
4287        let connection = Rc::new(StubAgentConnection::new());
4288        let conversation_view = cx.update(|window, cx| {
4289            cx.new(|cx| {
4290                ConversationView::new(
4291                    Rc::new(StubAgentServer::new(connection.as_ref().clone())),
4292                    connection_store,
4293                    Agent::Custom { id: "Test".into() },
4294                    None,
4295                    None,
4296                    None,
4297                    None,
4298                    workspace.downgrade(),
4299                    project.clone(),
4300                    Some(thread_store.clone()),
4301                    None,
4302                    window,
4303                    cx,
4304                )
4305            })
4306        });
4307
4308        cx.run_until_parked();
4309
4310        let thread = conversation_view
4311            .read_with(cx, |view, cx| {
4312                view.active_thread().map(|r| r.read(cx).thread.clone())
4313            })
4314            .unwrap();
4315
4316        // First user message
4317        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4318            acp::ToolCall::new("tool1", "Edit file 1")
4319                .kind(acp::ToolKind::Edit)
4320                .status(acp::ToolCallStatus::Completed)
4321                .content(vec![acp::ToolCallContent::Diff(
4322                    acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"),
4323                )]),
4324        )]);
4325
4326        thread
4327            .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
4328            .await
4329            .unwrap();
4330        cx.run_until_parked();
4331
4332        thread.read_with(cx, |thread, _cx| {
4333            assert_eq!(thread.entries().len(), 2);
4334        });
4335
4336        conversation_view.read_with(cx, |view, cx| {
4337            let entry_view_state = view
4338                .active_thread()
4339                .map(|active| active.read(cx).entry_view_state.clone())
4340                .unwrap();
4341            entry_view_state.read_with(cx, |entry_view_state, _| {
4342                assert!(
4343                    entry_view_state
4344                        .entry(0)
4345                        .unwrap()
4346                        .message_editor()
4347                        .is_some()
4348                );
4349                assert!(entry_view_state.entry(1).unwrap().has_content());
4350            });
4351        });
4352
4353        // Second user message
4354        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
4355            acp::ToolCall::new("tool2", "Edit file 2")
4356                .kind(acp::ToolKind::Edit)
4357                .status(acp::ToolCallStatus::Completed)
4358                .content(vec![acp::ToolCallContent::Diff(
4359                    acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"),
4360                )]),
4361        )]);
4362
4363        thread
4364            .update(cx, |thread, cx| thread.send_raw("Another one", cx))
4365            .await
4366            .unwrap();
4367        cx.run_until_parked();
4368
4369        let second_user_message_id = thread.read_with(cx, |thread, _| {
4370            assert_eq!(thread.entries().len(), 4);
4371            let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
4372                panic!();
4373            };
4374            user_message.id.clone().unwrap()
4375        });
4376
4377        conversation_view.read_with(cx, |view, cx| {
4378            let entry_view_state = view
4379                .active_thread()
4380                .unwrap()
4381                .read(cx)
4382                .entry_view_state
4383                .clone();
4384            entry_view_state.read_with(cx, |entry_view_state, _| {
4385                assert!(
4386                    entry_view_state
4387                        .entry(0)
4388                        .unwrap()
4389                        .message_editor()
4390                        .is_some()
4391                );
4392                assert!(entry_view_state.entry(1).unwrap().has_content());
4393                assert!(
4394                    entry_view_state
4395                        .entry(2)
4396                        .unwrap()
4397                        .message_editor()
4398                        .is_some()
4399                );
4400                assert!(entry_view_state.entry(3).unwrap().has_content());
4401            });
4402        });
4403
4404        // Rewind to first message
4405        thread
4406            .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
4407            .await
4408            .unwrap();
4409
4410        cx.run_until_parked();
4411
4412        thread.read_with(cx, |thread, _| {
4413            assert_eq!(thread.entries().len(), 2);
4414        });
4415
4416        conversation_view.read_with(cx, |view, cx| {
4417            let active = view.active_thread().unwrap();
4418            active
4419                .read(cx)
4420                .entry_view_state
4421                .read_with(cx, |entry_view_state, _| {
4422                    assert!(
4423                        entry_view_state
4424                            .entry(0)
4425                            .unwrap()
4426                            .message_editor()
4427                            .is_some()
4428                    );
4429                    assert!(entry_view_state.entry(1).unwrap().has_content());
4430
4431                    // Old views should be dropped
4432                    assert!(entry_view_state.entry(2).is_none());
4433                    assert!(entry_view_state.entry(3).is_none());
4434                });
4435        });
4436    }
4437
4438    #[gpui::test]
4439    async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
4440        init_test(cx);
4441
4442        let connection = StubAgentConnection::new();
4443
4444        // Each user prompt will result in a user message entry plus an agent message entry.
4445        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4446            acp::ContentChunk::new("Response 1".into()),
4447        )]);
4448
4449        let (conversation_view, cx) =
4450            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4451
4452        let thread = conversation_view
4453            .read_with(cx, |view, cx| {
4454                view.active_thread().map(|r| r.read(cx).thread.clone())
4455            })
4456            .unwrap();
4457
4458        thread
4459            .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
4460            .await
4461            .unwrap();
4462        cx.run_until_parked();
4463
4464        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4465            acp::ContentChunk::new("Response 2".into()),
4466        )]);
4467
4468        thread
4469            .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
4470            .await
4471            .unwrap();
4472        cx.run_until_parked();
4473
4474        // Move somewhere else first so we're not trivially already on the last user prompt.
4475        active_thread(&conversation_view, cx).update(cx, |view, cx| {
4476            view.scroll_to_top(cx);
4477        });
4478        cx.run_until_parked();
4479
4480        active_thread(&conversation_view, cx).update(cx, |view, cx| {
4481            view.scroll_to_most_recent_user_prompt(cx);
4482            let scroll_top = view.list_state.logical_scroll_top();
4483            // Entries layout is: [User1, Assistant1, User2, Assistant2]
4484            assert_eq!(scroll_top.item_ix, 2);
4485        });
4486    }
4487
4488    #[gpui::test]
4489    async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
4490        cx: &mut TestAppContext,
4491    ) {
4492        init_test(cx);
4493
4494        let (conversation_view, cx) =
4495            setup_conversation_view(StubAgentServer::default_response(), cx).await;
4496
4497        // With no entries, scrolling should be a no-op and must not panic.
4498        active_thread(&conversation_view, cx).update(cx, |view, cx| {
4499            view.scroll_to_most_recent_user_prompt(cx);
4500            let scroll_top = view.list_state.logical_scroll_top();
4501            assert_eq!(scroll_top.item_ix, 0);
4502        });
4503    }
4504
4505    #[gpui::test]
4506    async fn test_message_editing_cancel(cx: &mut TestAppContext) {
4507        init_test(cx);
4508
4509        let connection = StubAgentConnection::new();
4510
4511        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4512            acp::ContentChunk::new("Response".into()),
4513        )]);
4514
4515        let (conversation_view, cx) =
4516            setup_conversation_view(StubAgentServer::new(connection), cx).await;
4517        add_to_workspace(conversation_view.clone(), cx);
4518
4519        let message_editor = message_editor(&conversation_view, cx);
4520        message_editor.update_in(cx, |editor, window, cx| {
4521            editor.set_text("Original message to edit", window, cx);
4522        });
4523        active_thread(&conversation_view, cx)
4524            .update_in(cx, |view, window, cx| view.send(window, cx));
4525
4526        cx.run_until_parked();
4527
4528        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
4529            assert_eq!(
4530                view.active_thread()
4531                    .and_then(|active| active.read(cx).editing_message),
4532                None
4533            );
4534
4535            view.active_thread()
4536                .map(|active| &active.read(cx).entry_view_state)
4537                .as_ref()
4538                .unwrap()
4539                .read(cx)
4540                .entry(0)
4541                .unwrap()
4542                .message_editor()
4543                .unwrap()
4544                .clone()
4545        });
4546
4547        // Focus
4548        cx.focus(&user_message_editor);
4549        conversation_view.read_with(cx, |view, cx| {
4550            assert_eq!(
4551                view.active_thread()
4552                    .and_then(|active| active.read(cx).editing_message),
4553                Some(0)
4554            );
4555        });
4556
4557        // Edit
4558        user_message_editor.update_in(cx, |editor, window, cx| {
4559            editor.set_text("Edited message content", window, cx);
4560        });
4561
4562        // Cancel
4563        user_message_editor.update_in(cx, |_editor, window, cx| {
4564            window.dispatch_action(Box::new(editor::actions::Cancel), cx);
4565        });
4566
4567        conversation_view.read_with(cx, |view, cx| {
4568            assert_eq!(
4569                view.active_thread()
4570                    .and_then(|active| active.read(cx).editing_message),
4571                None
4572            );
4573        });
4574
4575        user_message_editor.read_with(cx, |editor, cx| {
4576            assert_eq!(editor.text(cx), "Original message to edit");
4577        });
4578    }
4579
4580    #[gpui::test]
4581    async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
4582        init_test(cx);
4583
4584        let connection = StubAgentConnection::new();
4585
4586        let (conversation_view, cx) =
4587            setup_conversation_view(StubAgentServer::new(connection), cx).await;
4588        add_to_workspace(conversation_view.clone(), cx);
4589
4590        let message_editor = message_editor(&conversation_view, cx);
4591        message_editor.update_in(cx, |editor, window, cx| {
4592            editor.set_text("", window, cx);
4593        });
4594
4595        let thread = cx.read(|cx| {
4596            conversation_view
4597                .read(cx)
4598                .active_thread()
4599                .unwrap()
4600                .read(cx)
4601                .thread
4602                .clone()
4603        });
4604        let entries_before = cx.read(|cx| thread.read(cx).entries().len());
4605
4606        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
4607            view.send(window, cx);
4608        });
4609        cx.run_until_parked();
4610
4611        let entries_after = cx.read(|cx| thread.read(cx).entries().len());
4612        assert_eq!(
4613            entries_before, entries_after,
4614            "No message should be sent when editor is empty"
4615        );
4616    }
4617
4618    #[gpui::test]
4619    async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
4620        init_test(cx);
4621
4622        let connection = StubAgentConnection::new();
4623
4624        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4625            acp::ContentChunk::new("Response".into()),
4626        )]);
4627
4628        let (conversation_view, cx) =
4629            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4630        add_to_workspace(conversation_view.clone(), cx);
4631
4632        let message_editor = message_editor(&conversation_view, cx);
4633        message_editor.update_in(cx, |editor, window, cx| {
4634            editor.set_text("Original message to edit", window, cx);
4635        });
4636        active_thread(&conversation_view, cx)
4637            .update_in(cx, |view, window, cx| view.send(window, cx));
4638
4639        cx.run_until_parked();
4640
4641        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
4642            assert_eq!(
4643                view.active_thread()
4644                    .and_then(|active| active.read(cx).editing_message),
4645                None
4646            );
4647            assert_eq!(
4648                view.active_thread()
4649                    .unwrap()
4650                    .read(cx)
4651                    .thread
4652                    .read(cx)
4653                    .entries()
4654                    .len(),
4655                2
4656            );
4657
4658            view.active_thread()
4659                .map(|active| &active.read(cx).entry_view_state)
4660                .as_ref()
4661                .unwrap()
4662                .read(cx)
4663                .entry(0)
4664                .unwrap()
4665                .message_editor()
4666                .unwrap()
4667                .clone()
4668        });
4669
4670        // Focus
4671        cx.focus(&user_message_editor);
4672
4673        // Edit
4674        user_message_editor.update_in(cx, |editor, window, cx| {
4675            editor.set_text("Edited message content", window, cx);
4676        });
4677
4678        // Send
4679        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4680            acp::ContentChunk::new("New Response".into()),
4681        )]);
4682
4683        user_message_editor.update_in(cx, |_editor, window, cx| {
4684            window.dispatch_action(Box::new(Chat), cx);
4685        });
4686
4687        cx.run_until_parked();
4688
4689        conversation_view.read_with(cx, |view, cx| {
4690            assert_eq!(
4691                view.active_thread()
4692                    .and_then(|active| active.read(cx).editing_message),
4693                None
4694            );
4695
4696            let entries = view
4697                .active_thread()
4698                .unwrap()
4699                .read(cx)
4700                .thread
4701                .read(cx)
4702                .entries();
4703            assert_eq!(entries.len(), 2);
4704            assert_eq!(
4705                entries[0].to_markdown(cx),
4706                "## User\n\nEdited message content\n\n"
4707            );
4708            assert_eq!(
4709                entries[1].to_markdown(cx),
4710                "## Assistant\n\nNew Response\n\n"
4711            );
4712
4713            let entry_view_state = view
4714                .active_thread()
4715                .map(|active| &active.read(cx).entry_view_state)
4716                .unwrap();
4717            let new_editor = entry_view_state.read_with(cx, |state, _cx| {
4718                assert!(!state.entry(1).unwrap().has_content());
4719                state.entry(0).unwrap().message_editor().unwrap().clone()
4720            });
4721
4722            assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
4723        })
4724    }
4725
4726    #[gpui::test]
4727    async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
4728        init_test(cx);
4729
4730        let connection = StubAgentConnection::new();
4731
4732        let (conversation_view, cx) =
4733            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4734        add_to_workspace(conversation_view.clone(), cx);
4735
4736        let message_editor = message_editor(&conversation_view, cx);
4737        message_editor.update_in(cx, |editor, window, cx| {
4738            editor.set_text("Original message to edit", window, cx);
4739        });
4740        active_thread(&conversation_view, cx)
4741            .update_in(cx, |view, window, cx| view.send(window, cx));
4742
4743        cx.run_until_parked();
4744
4745        let (user_message_editor, session_id) = conversation_view.read_with(cx, |view, cx| {
4746            let thread = view.active_thread().unwrap().read(cx).thread.read(cx);
4747            assert_eq!(thread.entries().len(), 1);
4748
4749            let editor = view
4750                .active_thread()
4751                .map(|active| &active.read(cx).entry_view_state)
4752                .as_ref()
4753                .unwrap()
4754                .read(cx)
4755                .entry(0)
4756                .unwrap()
4757                .message_editor()
4758                .unwrap()
4759                .clone();
4760
4761            (editor, thread.session_id().clone())
4762        });
4763
4764        // Focus
4765        cx.focus(&user_message_editor);
4766
4767        conversation_view.read_with(cx, |view, cx| {
4768            assert_eq!(
4769                view.active_thread()
4770                    .and_then(|active| active.read(cx).editing_message),
4771                Some(0)
4772            );
4773        });
4774
4775        // Edit
4776        user_message_editor.update_in(cx, |editor, window, cx| {
4777            editor.set_text("Edited message content", window, cx);
4778        });
4779
4780        conversation_view.read_with(cx, |view, cx| {
4781            assert_eq!(
4782                view.active_thread()
4783                    .and_then(|active| active.read(cx).editing_message),
4784                Some(0)
4785            );
4786        });
4787
4788        // Finish streaming response
4789        cx.update(|_, cx| {
4790            connection.send_update(
4791                session_id.clone(),
4792                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())),
4793                cx,
4794            );
4795            connection.end_turn(session_id, acp::StopReason::EndTurn);
4796        });
4797
4798        conversation_view.read_with(cx, |view, cx| {
4799            assert_eq!(
4800                view.active_thread()
4801                    .and_then(|active| active.read(cx).editing_message),
4802                Some(0)
4803            );
4804        });
4805
4806        cx.run_until_parked();
4807
4808        // Should still be editing
4809        cx.update(|window, cx| {
4810            assert!(user_message_editor.focus_handle(cx).is_focused(window));
4811            assert_eq!(
4812                conversation_view
4813                    .read(cx)
4814                    .active_thread()
4815                    .and_then(|active| active.read(cx).editing_message),
4816                Some(0)
4817            );
4818            assert_eq!(
4819                user_message_editor.read(cx).text(cx),
4820                "Edited message content"
4821            );
4822        });
4823    }
4824
4825    struct GeneratingThreadSetup {
4826        conversation_view: Entity<ConversationView>,
4827        thread: Entity<AcpThread>,
4828        message_editor: Entity<MessageEditor>,
4829    }
4830
4831    async fn setup_generating_thread(
4832        cx: &mut TestAppContext,
4833    ) -> (GeneratingThreadSetup, &mut VisualTestContext) {
4834        let connection = StubAgentConnection::new();
4835
4836        let (conversation_view, cx) =
4837            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4838        add_to_workspace(conversation_view.clone(), cx);
4839
4840        let message_editor = message_editor(&conversation_view, cx);
4841        message_editor.update_in(cx, |editor, window, cx| {
4842            editor.set_text("Hello", window, cx);
4843        });
4844        active_thread(&conversation_view, cx)
4845            .update_in(cx, |view, window, cx| view.send(window, cx));
4846
4847        let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
4848            let thread = view
4849                .active_thread()
4850                .as_ref()
4851                .unwrap()
4852                .read(cx)
4853                .thread
4854                .clone();
4855            (thread.clone(), thread.read(cx).session_id().clone())
4856        });
4857
4858        cx.run_until_parked();
4859
4860        cx.update(|_, cx| {
4861            connection.send_update(
4862                session_id.clone(),
4863                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
4864                    "Response chunk".into(),
4865                )),
4866                cx,
4867            );
4868        });
4869
4870        cx.run_until_parked();
4871
4872        thread.read_with(cx, |thread, _cx| {
4873            assert_eq!(thread.status(), ThreadStatus::Generating);
4874        });
4875
4876        (
4877            GeneratingThreadSetup {
4878                conversation_view,
4879                thread,
4880                message_editor,
4881            },
4882            cx,
4883        )
4884    }
4885
4886    #[gpui::test]
4887    async fn test_escape_cancels_generation_from_conversation_focus(cx: &mut TestAppContext) {
4888        init_test(cx);
4889
4890        let (setup, cx) = setup_generating_thread(cx).await;
4891
4892        let focus_handle = setup
4893            .conversation_view
4894            .read_with(cx, |view, cx| view.focus_handle(cx));
4895        cx.update(|window, cx| {
4896            window.focus(&focus_handle, cx);
4897        });
4898
4899        setup.conversation_view.update_in(cx, |_, window, cx| {
4900            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
4901        });
4902
4903        cx.run_until_parked();
4904
4905        setup.thread.read_with(cx, |thread, _cx| {
4906            assert_eq!(thread.status(), ThreadStatus::Idle);
4907        });
4908    }
4909
4910    #[gpui::test]
4911    async fn test_escape_cancels_generation_from_editor_focus(cx: &mut TestAppContext) {
4912        init_test(cx);
4913
4914        let (setup, cx) = setup_generating_thread(cx).await;
4915
4916        let editor_focus_handle = setup
4917            .message_editor
4918            .read_with(cx, |editor, cx| editor.focus_handle(cx));
4919        cx.update(|window, cx| {
4920            window.focus(&editor_focus_handle, cx);
4921        });
4922
4923        setup.message_editor.update_in(cx, |_, window, cx| {
4924            window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx);
4925        });
4926
4927        cx.run_until_parked();
4928
4929        setup.thread.read_with(cx, |thread, _cx| {
4930            assert_eq!(thread.status(), ThreadStatus::Idle);
4931        });
4932    }
4933
4934    #[gpui::test]
4935    async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) {
4936        init_test(cx);
4937
4938        let (conversation_view, cx) =
4939            setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
4940        add_to_workspace(conversation_view.clone(), cx);
4941
4942        let thread = conversation_view.read_with(cx, |view, cx| {
4943            view.active_thread().unwrap().read(cx).thread.clone()
4944        });
4945
4946        thread.read_with(cx, |thread, _cx| {
4947            assert_eq!(thread.status(), ThreadStatus::Idle);
4948        });
4949
4950        let focus_handle = conversation_view.read_with(cx, |view, _cx| view.focus_handle.clone());
4951        cx.update(|window, cx| {
4952            window.focus(&focus_handle, cx);
4953        });
4954
4955        conversation_view.update_in(cx, |_, window, cx| {
4956            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
4957        });
4958
4959        cx.run_until_parked();
4960
4961        thread.read_with(cx, |thread, _cx| {
4962            assert_eq!(thread.status(), ThreadStatus::Idle);
4963        });
4964    }
4965
4966    #[gpui::test]
4967    async fn test_interrupt(cx: &mut TestAppContext) {
4968        init_test(cx);
4969
4970        let connection = StubAgentConnection::new();
4971
4972        let (conversation_view, cx) =
4973            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4974        add_to_workspace(conversation_view.clone(), cx);
4975
4976        let message_editor = message_editor(&conversation_view, cx);
4977        message_editor.update_in(cx, |editor, window, cx| {
4978            editor.set_text("Message 1", window, cx);
4979        });
4980        active_thread(&conversation_view, cx)
4981            .update_in(cx, |view, window, cx| view.send(window, cx));
4982
4983        let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
4984            let thread = view.active_thread().unwrap().read(cx).thread.clone();
4985
4986            (thread.clone(), thread.read(cx).session_id().clone())
4987        });
4988
4989        cx.run_until_parked();
4990
4991        cx.update(|_, cx| {
4992            connection.send_update(
4993                session_id.clone(),
4994                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
4995                    "Message 1 resp".into(),
4996                )),
4997                cx,
4998            );
4999        });
5000
5001        cx.run_until_parked();
5002
5003        thread.read_with(cx, |thread, cx| {
5004            assert_eq!(
5005                thread.to_markdown(cx),
5006                indoc::indoc! {"
5007                        ## User
5008
5009                        Message 1
5010
5011                        ## Assistant
5012
5013                        Message 1 resp
5014
5015                    "}
5016            )
5017        });
5018
5019        message_editor.update_in(cx, |editor, window, cx| {
5020            editor.set_text("Message 2", window, cx);
5021        });
5022        active_thread(&conversation_view, cx)
5023            .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx));
5024
5025        cx.update(|_, cx| {
5026            // Simulate a response sent after beginning to cancel
5027            connection.send_update(
5028                session_id.clone(),
5029                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())),
5030                cx,
5031            );
5032        });
5033
5034        cx.run_until_parked();
5035
5036        // Last Message 1 response should appear before Message 2
5037        thread.read_with(cx, |thread, cx| {
5038            assert_eq!(
5039                thread.to_markdown(cx),
5040                indoc::indoc! {"
5041                        ## User
5042
5043                        Message 1
5044
5045                        ## Assistant
5046
5047                        Message 1 response
5048
5049                        ## User
5050
5051                        Message 2
5052
5053                    "}
5054            )
5055        });
5056
5057        cx.update(|_, cx| {
5058            connection.send_update(
5059                session_id.clone(),
5060                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5061                    "Message 2 response".into(),
5062                )),
5063                cx,
5064            );
5065            connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5066        });
5067
5068        cx.run_until_parked();
5069
5070        thread.read_with(cx, |thread, cx| {
5071            assert_eq!(
5072                thread.to_markdown(cx),
5073                indoc::indoc! {"
5074                        ## User
5075
5076                        Message 1
5077
5078                        ## Assistant
5079
5080                        Message 1 response
5081
5082                        ## User
5083
5084                        Message 2
5085
5086                        ## Assistant
5087
5088                        Message 2 response
5089
5090                    "}
5091            )
5092        });
5093    }
5094
5095    #[gpui::test]
5096    async fn test_message_editing_insert_selections(cx: &mut TestAppContext) {
5097        init_test(cx);
5098
5099        let connection = StubAgentConnection::new();
5100        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5101            acp::ContentChunk::new("Response".into()),
5102        )]);
5103
5104        let (conversation_view, cx) =
5105            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5106        add_to_workspace(conversation_view.clone(), cx);
5107
5108        let message_editor = message_editor(&conversation_view, cx);
5109        message_editor.update_in(cx, |editor, window, cx| {
5110            editor.set_text("Original message to edit", window, cx)
5111        });
5112        active_thread(&conversation_view, cx)
5113            .update_in(cx, |view, window, cx| view.send(window, cx));
5114        cx.run_until_parked();
5115
5116        let user_message_editor = conversation_view.read_with(cx, |conversation_view, cx| {
5117            conversation_view
5118                .active_thread()
5119                .map(|active| &active.read(cx).entry_view_state)
5120                .as_ref()
5121                .unwrap()
5122                .read(cx)
5123                .entry(0)
5124                .expect("Should have at least one entry")
5125                .message_editor()
5126                .expect("Should have message editor")
5127                .clone()
5128        });
5129
5130        cx.focus(&user_message_editor);
5131        conversation_view.read_with(cx, |view, cx| {
5132            assert_eq!(
5133                view.active_thread()
5134                    .and_then(|active| active.read(cx).editing_message),
5135                Some(0)
5136            );
5137        });
5138
5139        // Ensure to edit the focused message before proceeding otherwise, since
5140        // its content is not different from what was sent, focus will be lost.
5141        user_message_editor.update_in(cx, |editor, window, cx| {
5142            editor.set_text("Original message to edit with ", window, cx)
5143        });
5144
5145        // Create a simple buffer with some text so we can create a selection
5146        // that will then be added to the message being edited.
5147        let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
5148            (
5149                conversation_view.workspace.clone(),
5150                conversation_view.project.clone(),
5151            )
5152        });
5153        let buffer = project.update(cx, |project, cx| {
5154            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5155        });
5156
5157        workspace
5158            .update_in(cx, |workspace, window, cx| {
5159                let editor = cx.new(|cx| {
5160                    let mut editor =
5161                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5162
5163                    editor.change_selections(Default::default(), window, cx, |selections| {
5164                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5165                    });
5166
5167                    editor
5168                });
5169                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5170            })
5171            .unwrap();
5172
5173        conversation_view.update_in(cx, |view, window, cx| {
5174            assert_eq!(
5175                view.active_thread()
5176                    .and_then(|active| active.read(cx).editing_message),
5177                Some(0)
5178            );
5179            view.insert_selections(window, cx);
5180        });
5181
5182        user_message_editor.read_with(cx, |editor, cx| {
5183            let text = editor.editor().read(cx).text(cx);
5184            let expected_text = String::from("Original message to edit with selection ");
5185
5186            assert_eq!(text, expected_text);
5187        });
5188    }
5189
5190    #[gpui::test]
5191    async fn test_insert_selections(cx: &mut TestAppContext) {
5192        init_test(cx);
5193
5194        let connection = StubAgentConnection::new();
5195        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5196            acp::ContentChunk::new("Response".into()),
5197        )]);
5198
5199        let (conversation_view, cx) =
5200            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5201        add_to_workspace(conversation_view.clone(), cx);
5202
5203        let message_editor = message_editor(&conversation_view, cx);
5204        message_editor.update_in(cx, |editor, window, cx| {
5205            editor.set_text("Can you review this snippet ", window, cx)
5206        });
5207
5208        // Create a simple buffer with some text so we can create a selection
5209        // that will then be added to the message being edited.
5210        let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
5211            (
5212                conversation_view.workspace.clone(),
5213                conversation_view.project.clone(),
5214            )
5215        });
5216        let buffer = project.update(cx, |project, cx| {
5217            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
5218        });
5219
5220        workspace
5221            .update_in(cx, |workspace, window, cx| {
5222                let editor = cx.new(|cx| {
5223                    let mut editor =
5224                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
5225
5226                    editor.change_selections(Default::default(), window, cx, |selections| {
5227                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
5228                    });
5229
5230                    editor
5231                });
5232                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
5233            })
5234            .unwrap();
5235
5236        conversation_view.update_in(cx, |view, window, cx| {
5237            assert_eq!(
5238                view.active_thread()
5239                    .and_then(|active| active.read(cx).editing_message),
5240                None
5241            );
5242            view.insert_selections(window, cx);
5243        });
5244
5245        message_editor.read_with(cx, |editor, cx| {
5246            let text = editor.text(cx);
5247            let expected_txt = String::from("Can you review this snippet selection ");
5248
5249            assert_eq!(text, expected_txt);
5250        })
5251    }
5252
5253    #[gpui::test]
5254    async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
5255        init_test(cx);
5256
5257        let tool_call_id = acp::ToolCallId::new("terminal-1");
5258        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`")
5259            .kind(acp::ToolKind::Edit);
5260
5261        let permission_options = ToolPermissionContext::new(
5262            TerminalTool::NAME,
5263            vec!["cargo build --release".to_string()],
5264        )
5265        .build_permission_options();
5266
5267        let connection =
5268            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5269                tool_call_id.clone(),
5270                permission_options,
5271            )]));
5272
5273        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5274
5275        let (conversation_view, cx) =
5276            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5277
5278        // Disable notifications to avoid popup windows
5279        cx.update(|_window, cx| {
5280            AgentSettings::override_global(
5281                AgentSettings {
5282                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5283                    ..AgentSettings::get_global(cx).clone()
5284                },
5285                cx,
5286            );
5287        });
5288
5289        let message_editor = message_editor(&conversation_view, cx);
5290        message_editor.update_in(cx, |editor, window, cx| {
5291            editor.set_text("Run cargo build", window, cx);
5292        });
5293
5294        active_thread(&conversation_view, cx)
5295            .update_in(cx, |view, window, cx| view.send(window, cx));
5296
5297        cx.run_until_parked();
5298
5299        // Verify the tool call is in WaitingForConfirmation state with the expected options
5300        conversation_view.read_with(cx, |conversation_view, cx| {
5301            let thread = conversation_view
5302                .active_thread()
5303                .expect("Thread should exist")
5304                .read(cx)
5305                .thread
5306                .clone();
5307            let thread = thread.read(cx);
5308
5309            let tool_call = thread.entries().iter().find_map(|entry| {
5310                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5311                    Some(call)
5312                } else {
5313                    None
5314                }
5315            });
5316
5317            assert!(tool_call.is_some(), "Expected a tool call entry");
5318            let tool_call = tool_call.unwrap();
5319
5320            // Verify it's waiting for confirmation
5321            assert!(
5322                matches!(
5323                    tool_call.status,
5324                    acp_thread::ToolCallStatus::WaitingForConfirmation { .. }
5325                ),
5326                "Expected WaitingForConfirmation status, got {:?}",
5327                tool_call.status
5328            );
5329
5330            // Verify the options count (granularity options only, no separate Deny option)
5331            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5332                &tool_call.status
5333            {
5334                let PermissionOptions::Dropdown(choices) = options else {
5335                    panic!("Expected dropdown permission options");
5336                };
5337
5338                assert_eq!(
5339                    choices.len(),
5340                    3,
5341                    "Expected 3 permission options (granularity only)"
5342                );
5343
5344                // Verify specific button labels (now using neutral names)
5345                let labels: Vec<&str> = choices
5346                    .iter()
5347                    .map(|choice| choice.allow.name.as_ref())
5348                    .collect();
5349                assert!(
5350                    labels.contains(&"Always for terminal"),
5351                    "Missing 'Always for terminal' option"
5352                );
5353                assert!(
5354                    labels.contains(&"Always for `cargo build` commands"),
5355                    "Missing pattern option"
5356                );
5357                assert!(
5358                    labels.contains(&"Only this time"),
5359                    "Missing 'Only this time' option"
5360                );
5361            }
5362        });
5363    }
5364
5365    #[gpui::test]
5366    async fn test_tool_permission_buttons_edit_file_with_path_pattern(cx: &mut TestAppContext) {
5367        init_test(cx);
5368
5369        let tool_call_id = acp::ToolCallId::new("edit-file-1");
5370        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`")
5371            .kind(acp::ToolKind::Edit);
5372
5373        let permission_options =
5374            ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()])
5375                .build_permission_options();
5376
5377        let connection =
5378            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5379                tool_call_id.clone(),
5380                permission_options,
5381            )]));
5382
5383        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5384
5385        let (conversation_view, cx) =
5386            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5387
5388        // Disable notifications
5389        cx.update(|_window, cx| {
5390            AgentSettings::override_global(
5391                AgentSettings {
5392                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5393                    ..AgentSettings::get_global(cx).clone()
5394                },
5395                cx,
5396            );
5397        });
5398
5399        let message_editor = message_editor(&conversation_view, cx);
5400        message_editor.update_in(cx, |editor, window, cx| {
5401            editor.set_text("Edit the main file", window, cx);
5402        });
5403
5404        active_thread(&conversation_view, cx)
5405            .update_in(cx, |view, window, cx| view.send(window, cx));
5406
5407        cx.run_until_parked();
5408
5409        // Verify the options
5410        conversation_view.read_with(cx, |conversation_view, cx| {
5411            let thread = conversation_view
5412                .active_thread()
5413                .expect("Thread should exist")
5414                .read(cx)
5415                .thread
5416                .clone();
5417            let thread = thread.read(cx);
5418
5419            let tool_call = thread.entries().iter().find_map(|entry| {
5420                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5421                    Some(call)
5422                } else {
5423                    None
5424                }
5425            });
5426
5427            assert!(tool_call.is_some(), "Expected a tool call entry");
5428            let tool_call = tool_call.unwrap();
5429
5430            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5431                &tool_call.status
5432            {
5433                let PermissionOptions::Dropdown(choices) = options else {
5434                    panic!("Expected dropdown permission options");
5435                };
5436
5437                let labels: Vec<&str> = choices
5438                    .iter()
5439                    .map(|choice| choice.allow.name.as_ref())
5440                    .collect();
5441                assert!(
5442                    labels.contains(&"Always for edit file"),
5443                    "Missing 'Always for edit file' option"
5444                );
5445                assert!(
5446                    labels.contains(&"Always for `src/`"),
5447                    "Missing path pattern option"
5448                );
5449            } else {
5450                panic!("Expected WaitingForConfirmation status");
5451            }
5452        });
5453    }
5454
5455    #[gpui::test]
5456    async fn test_tool_permission_buttons_fetch_with_domain_pattern(cx: &mut TestAppContext) {
5457        init_test(cx);
5458
5459        let tool_call_id = acp::ToolCallId::new("fetch-1");
5460        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Fetch `https://docs.rs/gpui`")
5461            .kind(acp::ToolKind::Fetch);
5462
5463        let permission_options =
5464            ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()])
5465                .build_permission_options();
5466
5467        let connection =
5468            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5469                tool_call_id.clone(),
5470                permission_options,
5471            )]));
5472
5473        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5474
5475        let (conversation_view, cx) =
5476            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5477
5478        // Disable notifications
5479        cx.update(|_window, cx| {
5480            AgentSettings::override_global(
5481                AgentSettings {
5482                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5483                    ..AgentSettings::get_global(cx).clone()
5484                },
5485                cx,
5486            );
5487        });
5488
5489        let message_editor = message_editor(&conversation_view, cx);
5490        message_editor.update_in(cx, |editor, window, cx| {
5491            editor.set_text("Fetch the docs", window, cx);
5492        });
5493
5494        active_thread(&conversation_view, cx)
5495            .update_in(cx, |view, window, cx| view.send(window, cx));
5496
5497        cx.run_until_parked();
5498
5499        // Verify the options
5500        conversation_view.read_with(cx, |conversation_view, cx| {
5501            let thread = conversation_view
5502                .active_thread()
5503                .expect("Thread should exist")
5504                .read(cx)
5505                .thread
5506                .clone();
5507            let thread = thread.read(cx);
5508
5509            let tool_call = thread.entries().iter().find_map(|entry| {
5510                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5511                    Some(call)
5512                } else {
5513                    None
5514                }
5515            });
5516
5517            assert!(tool_call.is_some(), "Expected a tool call entry");
5518            let tool_call = tool_call.unwrap();
5519
5520            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5521                &tool_call.status
5522            {
5523                let PermissionOptions::Dropdown(choices) = options else {
5524                    panic!("Expected dropdown permission options");
5525                };
5526
5527                let labels: Vec<&str> = choices
5528                    .iter()
5529                    .map(|choice| choice.allow.name.as_ref())
5530                    .collect();
5531                assert!(
5532                    labels.contains(&"Always for fetch"),
5533                    "Missing 'Always for fetch' option"
5534                );
5535                assert!(
5536                    labels.contains(&"Always for `docs.rs`"),
5537                    "Missing domain pattern option"
5538                );
5539            } else {
5540                panic!("Expected WaitingForConfirmation status");
5541            }
5542        });
5543    }
5544
5545    #[gpui::test]
5546    async fn test_tool_permission_buttons_without_pattern(cx: &mut TestAppContext) {
5547        init_test(cx);
5548
5549        let tool_call_id = acp::ToolCallId::new("terminal-no-pattern-1");
5550        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `./deploy.sh --production`")
5551            .kind(acp::ToolKind::Edit);
5552
5553        // No pattern button since ./deploy.sh doesn't match the alphanumeric pattern
5554        let permission_options = ToolPermissionContext::new(
5555            TerminalTool::NAME,
5556            vec!["./deploy.sh --production".to_string()],
5557        )
5558        .build_permission_options();
5559
5560        let connection =
5561            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5562                tool_call_id.clone(),
5563                permission_options,
5564            )]));
5565
5566        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5567
5568        let (conversation_view, cx) =
5569            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5570
5571        // Disable notifications
5572        cx.update(|_window, cx| {
5573            AgentSettings::override_global(
5574                AgentSettings {
5575                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5576                    ..AgentSettings::get_global(cx).clone()
5577                },
5578                cx,
5579            );
5580        });
5581
5582        let message_editor = message_editor(&conversation_view, cx);
5583        message_editor.update_in(cx, |editor, window, cx| {
5584            editor.set_text("Run the deploy script", window, cx);
5585        });
5586
5587        active_thread(&conversation_view, cx)
5588            .update_in(cx, |view, window, cx| view.send(window, cx));
5589
5590        cx.run_until_parked();
5591
5592        // Verify only 2 options (no pattern button when command doesn't match pattern)
5593        conversation_view.read_with(cx, |conversation_view, cx| {
5594            let thread = conversation_view
5595                .active_thread()
5596                .expect("Thread should exist")
5597                .read(cx)
5598                .thread
5599                .clone();
5600            let thread = thread.read(cx);
5601
5602            let tool_call = thread.entries().iter().find_map(|entry| {
5603                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
5604                    Some(call)
5605                } else {
5606                    None
5607                }
5608            });
5609
5610            assert!(tool_call.is_some(), "Expected a tool call entry");
5611            let tool_call = tool_call.unwrap();
5612
5613            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
5614                &tool_call.status
5615            {
5616                let PermissionOptions::Dropdown(choices) = options else {
5617                    panic!("Expected dropdown permission options");
5618                };
5619
5620                assert_eq!(
5621                    choices.len(),
5622                    2,
5623                    "Expected 2 permission options (no pattern option)"
5624                );
5625
5626                let labels: Vec<&str> = choices
5627                    .iter()
5628                    .map(|choice| choice.allow.name.as_ref())
5629                    .collect();
5630                assert!(
5631                    labels.contains(&"Always for terminal"),
5632                    "Missing 'Always for terminal' option"
5633                );
5634                assert!(
5635                    labels.contains(&"Only this time"),
5636                    "Missing 'Only this time' option"
5637                );
5638                // Should NOT contain a pattern option
5639                assert!(
5640                    !labels.iter().any(|l| l.contains("commands")),
5641                    "Should not have pattern option"
5642                );
5643            } else {
5644                panic!("Expected WaitingForConfirmation status");
5645            }
5646        });
5647    }
5648
5649    #[gpui::test]
5650    async fn test_authorize_tool_call_action_triggers_authorization(cx: &mut TestAppContext) {
5651        init_test(cx);
5652
5653        let tool_call_id = acp::ToolCallId::new("action-test-1");
5654        let tool_call =
5655            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit);
5656
5657        let permission_options =
5658            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo test".to_string()])
5659                .build_permission_options();
5660
5661        let connection =
5662            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5663                tool_call_id.clone(),
5664                permission_options,
5665            )]));
5666
5667        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5668
5669        let (conversation_view, cx) =
5670            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5671        add_to_workspace(conversation_view.clone(), cx);
5672
5673        cx.update(|_window, cx| {
5674            AgentSettings::override_global(
5675                AgentSettings {
5676                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5677                    ..AgentSettings::get_global(cx).clone()
5678                },
5679                cx,
5680            );
5681        });
5682
5683        let message_editor = message_editor(&conversation_view, cx);
5684        message_editor.update_in(cx, |editor, window, cx| {
5685            editor.set_text("Run tests", window, cx);
5686        });
5687
5688        active_thread(&conversation_view, cx)
5689            .update_in(cx, |view, window, cx| view.send(window, cx));
5690
5691        cx.run_until_parked();
5692
5693        // Verify tool call is waiting for confirmation
5694        conversation_view.read_with(cx, |conversation_view, cx| {
5695            let tool_call = conversation_view.pending_tool_call(cx);
5696            assert!(
5697                tool_call.is_some(),
5698                "Expected a tool call waiting for confirmation"
5699            );
5700        });
5701
5702        // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection)
5703        conversation_view.update_in(cx, |_, window, cx| {
5704            window.dispatch_action(
5705                crate::AuthorizeToolCall {
5706                    tool_call_id: "action-test-1".to_string(),
5707                    option_id: "allow".to_string(),
5708                    option_kind: "AllowOnce".to_string(),
5709                }
5710                .boxed_clone(),
5711                cx,
5712            );
5713        });
5714
5715        cx.run_until_parked();
5716
5717        // Verify tool call is no longer waiting for confirmation (was authorized)
5718        conversation_view.read_with(cx, |conversation_view, cx| {
5719            let tool_call = conversation_view.pending_tool_call(cx);
5720            assert!(
5721                tool_call.is_none(),
5722                "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action"
5723            );
5724        });
5725    }
5726
5727    #[gpui::test]
5728    async fn test_authorize_tool_call_action_with_pattern_option(cx: &mut TestAppContext) {
5729        init_test(cx);
5730
5731        let tool_call_id = acp::ToolCallId::new("pattern-action-test-1");
5732        let tool_call =
5733            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
5734
5735        let permission_options =
5736            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
5737                .build_permission_options();
5738
5739        let connection =
5740            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5741                tool_call_id.clone(),
5742                permission_options.clone(),
5743            )]));
5744
5745        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5746
5747        let (conversation_view, cx) =
5748            setup_conversation_view(StubAgentServer::new(connection), cx).await;
5749        add_to_workspace(conversation_view.clone(), cx);
5750
5751        cx.update(|_window, cx| {
5752            AgentSettings::override_global(
5753                AgentSettings {
5754                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5755                    ..AgentSettings::get_global(cx).clone()
5756                },
5757                cx,
5758            );
5759        });
5760
5761        let message_editor = message_editor(&conversation_view, cx);
5762        message_editor.update_in(cx, |editor, window, cx| {
5763            editor.set_text("Install dependencies", window, cx);
5764        });
5765
5766        active_thread(&conversation_view, cx)
5767            .update_in(cx, |view, window, cx| view.send(window, cx));
5768
5769        cx.run_until_parked();
5770
5771        // Find the pattern option ID (the choice with non-empty sub_patterns)
5772        let pattern_option = match &permission_options {
5773            PermissionOptions::Dropdown(choices) => choices
5774                .iter()
5775                .find(|choice| !choice.sub_patterns.is_empty())
5776                .map(|choice| &choice.allow)
5777                .expect("Should have a pattern option for npm command"),
5778            _ => panic!("Expected dropdown permission options"),
5779        };
5780
5781        // Dispatch action with the pattern option (simulating "Always allow `npm` commands")
5782        conversation_view.update_in(cx, |_, window, cx| {
5783            window.dispatch_action(
5784                crate::AuthorizeToolCall {
5785                    tool_call_id: "pattern-action-test-1".to_string(),
5786                    option_id: pattern_option.option_id.0.to_string(),
5787                    option_kind: "AllowAlways".to_string(),
5788                }
5789                .boxed_clone(),
5790                cx,
5791            );
5792        });
5793
5794        cx.run_until_parked();
5795
5796        // Verify tool call was authorized
5797        conversation_view.read_with(cx, |conversation_view, cx| {
5798            let tool_call = conversation_view.pending_tool_call(cx);
5799            assert!(
5800                tool_call.is_none(),
5801                "Tool call should be authorized after selecting pattern option"
5802            );
5803        });
5804    }
5805
5806    #[gpui::test]
5807    async fn test_granularity_selection_updates_state(cx: &mut TestAppContext) {
5808        init_test(cx);
5809
5810        let tool_call_id = acp::ToolCallId::new("granularity-test-1");
5811        let tool_call =
5812            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit);
5813
5814        let permission_options =
5815            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
5816                .build_permission_options();
5817
5818        let connection =
5819            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5820                tool_call_id.clone(),
5821                permission_options.clone(),
5822            )]));
5823
5824        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5825
5826        let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
5827        add_to_workspace(thread_view.clone(), cx);
5828
5829        cx.update(|_window, cx| {
5830            AgentSettings::override_global(
5831                AgentSettings {
5832                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5833                    ..AgentSettings::get_global(cx).clone()
5834                },
5835                cx,
5836            );
5837        });
5838
5839        let message_editor = message_editor(&thread_view, cx);
5840        message_editor.update_in(cx, |editor, window, cx| {
5841            editor.set_text("Build the project", window, cx);
5842        });
5843
5844        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5845
5846        cx.run_until_parked();
5847
5848        // Verify default granularity is the last option (index 2 = "Only this time")
5849        thread_view.read_with(cx, |thread_view, cx| {
5850            let state = thread_view.active_thread().unwrap();
5851            let selected = state.read(cx).permission_selections.get(&tool_call_id);
5852            assert!(
5853                selected.is_none(),
5854                "Should have no selection initially (defaults to last)"
5855            );
5856        });
5857
5858        // Select the first option (index 0 = "Always for terminal")
5859        thread_view.update_in(cx, |_, window, cx| {
5860            window.dispatch_action(
5861                crate::SelectPermissionGranularity {
5862                    tool_call_id: "granularity-test-1".to_string(),
5863                    index: 0,
5864                }
5865                .boxed_clone(),
5866                cx,
5867            );
5868        });
5869
5870        cx.run_until_parked();
5871
5872        // Verify the selection was updated
5873        thread_view.read_with(cx, |thread_view, cx| {
5874            let state = thread_view.active_thread().unwrap();
5875            let selected = state.read(cx).permission_selections.get(&tool_call_id);
5876            assert_eq!(
5877                selected.and_then(|s| s.choice_index()),
5878                Some(0),
5879                "Should have selected index 0"
5880            );
5881        });
5882    }
5883
5884    #[gpui::test]
5885    async fn test_allow_button_uses_selected_granularity(cx: &mut TestAppContext) {
5886        init_test(cx);
5887
5888        let tool_call_id = acp::ToolCallId::new("allow-granularity-test-1");
5889        let tool_call =
5890            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
5891
5892        let permission_options =
5893            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
5894                .build_permission_options();
5895
5896        // Verify we have the expected options
5897        let PermissionOptions::Dropdown(choices) = &permission_options else {
5898            panic!("Expected dropdown permission options");
5899        };
5900
5901        assert_eq!(choices.len(), 3);
5902        assert!(
5903            choices[0]
5904                .allow
5905                .option_id
5906                .0
5907                .contains("always_allow:terminal")
5908        );
5909        assert!(
5910            choices[1]
5911                .allow
5912                .option_id
5913                .0
5914                .contains("always_allow:terminal")
5915        );
5916        assert!(!choices[1].sub_patterns.is_empty());
5917        assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
5918
5919        let connection =
5920            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5921                tool_call_id.clone(),
5922                permission_options.clone(),
5923            )]));
5924
5925        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
5926
5927        let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
5928        add_to_workspace(thread_view.clone(), cx);
5929
5930        cx.update(|_window, cx| {
5931            AgentSettings::override_global(
5932                AgentSettings {
5933                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5934                    ..AgentSettings::get_global(cx).clone()
5935                },
5936                cx,
5937            );
5938        });
5939
5940        let message_editor = message_editor(&thread_view, cx);
5941        message_editor.update_in(cx, |editor, window, cx| {
5942            editor.set_text("Install dependencies", window, cx);
5943        });
5944
5945        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
5946
5947        cx.run_until_parked();
5948
5949        // Select the pattern option (index 1 = "Always for `npm` commands")
5950        thread_view.update_in(cx, |_, window, cx| {
5951            window.dispatch_action(
5952                crate::SelectPermissionGranularity {
5953                    tool_call_id: "allow-granularity-test-1".to_string(),
5954                    index: 1,
5955                }
5956                .boxed_clone(),
5957                cx,
5958            );
5959        });
5960
5961        cx.run_until_parked();
5962
5963        // Simulate clicking the Allow button by dispatching AllowOnce action
5964        // which should use the selected granularity
5965        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
5966            view.allow_once(&AllowOnce, window, cx)
5967        });
5968
5969        cx.run_until_parked();
5970
5971        // Verify tool call was authorized
5972        thread_view.read_with(cx, |thread_view, cx| {
5973            let tool_call = thread_view.pending_tool_call(cx);
5974            assert!(
5975                tool_call.is_none(),
5976                "Tool call should be authorized after Allow with pattern granularity"
5977            );
5978        });
5979    }
5980
5981    #[gpui::test]
5982    async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) {
5983        init_test(cx);
5984
5985        let tool_call_id = acp::ToolCallId::new("deny-granularity-test-1");
5986        let tool_call =
5987            acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit);
5988
5989        let permission_options =
5990            ToolPermissionContext::new(TerminalTool::NAME, vec!["git push".to_string()])
5991                .build_permission_options();
5992
5993        let connection =
5994            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
5995                tool_call_id.clone(),
5996                permission_options.clone(),
5997            )]));
5998
5999        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
6000
6001        let (conversation_view, cx) =
6002            setup_conversation_view(StubAgentServer::new(connection), cx).await;
6003        add_to_workspace(conversation_view.clone(), cx);
6004
6005        cx.update(|_window, cx| {
6006            AgentSettings::override_global(
6007                AgentSettings {
6008                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
6009                    ..AgentSettings::get_global(cx).clone()
6010                },
6011                cx,
6012            );
6013        });
6014
6015        let message_editor = message_editor(&conversation_view, cx);
6016        message_editor.update_in(cx, |editor, window, cx| {
6017            editor.set_text("Push changes", window, cx);
6018        });
6019
6020        active_thread(&conversation_view, cx)
6021            .update_in(cx, |view, window, cx| view.send(window, cx));
6022
6023        cx.run_until_parked();
6024
6025        // Use default granularity (last option = "Only this time")
6026        // Simulate clicking the Deny button
6027        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
6028            view.reject_once(&RejectOnce, window, cx)
6029        });
6030
6031        cx.run_until_parked();
6032
6033        // Verify tool call was rejected (no longer waiting for confirmation)
6034        conversation_view.read_with(cx, |conversation_view, cx| {
6035            let tool_call = conversation_view.pending_tool_call(cx);
6036            assert!(
6037                tool_call.is_none(),
6038                "Tool call should be rejected after Deny"
6039            );
6040        });
6041    }
6042
6043    #[gpui::test]
6044    async fn test_option_id_transformation_for_allow() {
6045        let permission_options = ToolPermissionContext::new(
6046            TerminalTool::NAME,
6047            vec!["cargo build --release".to_string()],
6048        )
6049        .build_permission_options();
6050
6051        let PermissionOptions::Dropdown(choices) = permission_options else {
6052            panic!("Expected dropdown permission options");
6053        };
6054
6055        let allow_ids: Vec<String> = choices
6056            .iter()
6057            .map(|choice| choice.allow.option_id.0.to_string())
6058            .collect();
6059
6060        assert!(allow_ids.contains(&"allow".to_string()));
6061        assert_eq!(
6062            allow_ids
6063                .iter()
6064                .filter(|id| *id == "always_allow:terminal")
6065                .count(),
6066            2,
6067            "Expected two always_allow:terminal IDs (one whole-tool, one pattern with sub_patterns)"
6068        );
6069    }
6070
6071    #[gpui::test]
6072    async fn test_option_id_transformation_for_deny() {
6073        let permission_options = ToolPermissionContext::new(
6074            TerminalTool::NAME,
6075            vec!["cargo build --release".to_string()],
6076        )
6077        .build_permission_options();
6078
6079        let PermissionOptions::Dropdown(choices) = permission_options else {
6080            panic!("Expected dropdown permission options");
6081        };
6082
6083        let deny_ids: Vec<String> = choices
6084            .iter()
6085            .map(|choice| choice.deny.option_id.0.to_string())
6086            .collect();
6087
6088        assert!(deny_ids.contains(&"deny".to_string()));
6089        assert_eq!(
6090            deny_ids
6091                .iter()
6092                .filter(|id| *id == "always_deny:terminal")
6093                .count(),
6094            2,
6095            "Expected two always_deny:terminal IDs (one whole-tool, one pattern with sub_patterns)"
6096        );
6097    }
6098
6099    #[gpui::test]
6100    async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) {
6101        init_test(cx);
6102
6103        let (conversation_view, cx) =
6104            setup_conversation_view(StubAgentServer::default_response(), cx).await;
6105        add_to_workspace(conversation_view.clone(), cx);
6106
6107        let active = active_thread(&conversation_view, cx);
6108        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6109        let thread = cx.read(|cx| active.read(cx).thread.clone());
6110
6111        title_editor.read_with(cx, |editor, cx| {
6112            assert!(!editor.read_only(cx));
6113        });
6114
6115        cx.focus(&conversation_view);
6116        cx.focus(&title_editor);
6117
6118        cx.dispatch_action(editor::actions::DeleteLine);
6119        cx.simulate_input("My Custom Title");
6120
6121        cx.run_until_parked();
6122
6123        title_editor.read_with(cx, |editor, cx| {
6124            assert_eq!(editor.text(cx), "My Custom Title");
6125        });
6126        thread.read_with(cx, |thread, _cx| {
6127            assert_eq!(thread.title(), Some("My Custom Title".into()));
6128        });
6129    }
6130
6131    #[gpui::test]
6132    async fn test_title_editor_is_read_only_when_set_title_unsupported(cx: &mut TestAppContext) {
6133        init_test(cx);
6134
6135        let (conversation_view, cx) =
6136            setup_conversation_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await;
6137
6138        let active = active_thread(&conversation_view, cx);
6139        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
6140
6141        title_editor.read_with(cx, |editor, cx| {
6142            assert!(
6143                editor.read_only(cx),
6144                "Title editor should be read-only when the connection does not support set_title"
6145            );
6146        });
6147    }
6148
6149    #[gpui::test]
6150    async fn test_max_tokens_error_is_rendered(cx: &mut TestAppContext) {
6151        init_test(cx);
6152
6153        let connection = StubAgentConnection::new();
6154
6155        let (conversation_view, cx) =
6156            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
6157
6158        let message_editor = message_editor(&conversation_view, cx);
6159        message_editor.update_in(cx, |editor, window, cx| {
6160            editor.set_text("Some prompt", window, cx);
6161        });
6162        active_thread(&conversation_view, cx)
6163            .update_in(cx, |view, window, cx| view.send(window, cx));
6164
6165        let session_id = conversation_view.read_with(cx, |view, cx| {
6166            view.active_thread()
6167                .unwrap()
6168                .read(cx)
6169                .thread
6170                .read(cx)
6171                .session_id()
6172                .clone()
6173        });
6174
6175        cx.run_until_parked();
6176
6177        cx.update(|_, _cx| {
6178            connection.end_turn(session_id, acp::StopReason::MaxTokens);
6179        });
6180
6181        cx.run_until_parked();
6182
6183        conversation_view.read_with(cx, |conversation_view, cx| {
6184            let state = conversation_view.active_thread().unwrap();
6185            let error = &state.read(cx).thread_error;
6186            match error {
6187                Some(ThreadError::Other { message, .. }) => {
6188                    assert!(
6189                        message.contains("Max tokens reached"),
6190                        "Expected 'Max tokens reached' error, got: {}",
6191                        message
6192                    );
6193                }
6194                other => panic!(
6195                    "Expected ThreadError::Other with 'Max tokens reached', got: {:?}",
6196                    other.is_some()
6197                ),
6198            }
6199        });
6200    }
6201
6202    fn create_test_acp_thread(
6203        parent_session_id: Option<acp::SessionId>,
6204        session_id: &str,
6205        connection: Rc<dyn AgentConnection>,
6206        project: Entity<Project>,
6207        cx: &mut App,
6208    ) -> Entity<AcpThread> {
6209        let action_log = cx.new(|_| ActionLog::new(project.clone()));
6210        cx.new(|cx| {
6211            AcpThread::new(
6212                parent_session_id,
6213                None,
6214                None,
6215                connection,
6216                project,
6217                action_log,
6218                acp::SessionId::new(session_id),
6219                watch::Receiver::constant(acp::PromptCapabilities::new()),
6220                cx,
6221            )
6222        })
6223    }
6224
6225    fn request_test_tool_authorization(
6226        thread: &Entity<AcpThread>,
6227        tool_call_id: &str,
6228        option_id: &str,
6229        cx: &mut TestAppContext,
6230    ) -> Task<acp_thread::RequestPermissionOutcome> {
6231        let tool_call_id = acp::ToolCallId::new(tool_call_id);
6232        let label = format!("Tool {tool_call_id}");
6233        let option_id = acp::PermissionOptionId::new(option_id);
6234        cx.update(|cx| {
6235            thread.update(cx, |thread, cx| {
6236                thread
6237                    .request_tool_call_authorization(
6238                        acp::ToolCall::new(tool_call_id, label)
6239                            .kind(acp::ToolKind::Edit)
6240                            .into(),
6241                        PermissionOptions::Flat(vec![acp::PermissionOption::new(
6242                            option_id,
6243                            "Allow",
6244                            acp::PermissionOptionKind::AllowOnce,
6245                        )]),
6246                        cx,
6247                    )
6248                    .unwrap()
6249            })
6250        })
6251    }
6252
6253    #[gpui::test]
6254    async fn test_conversation_multiple_tool_calls_fifo_ordering(cx: &mut TestAppContext) {
6255        init_test(cx);
6256
6257        let fs = FakeFs::new(cx.executor());
6258        let project = Project::test(fs, [], cx).await;
6259        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6260
6261        let (thread, conversation) = cx.update(|cx| {
6262            let thread =
6263                create_test_acp_thread(None, "session-1", connection.clone(), project.clone(), cx);
6264            let conversation = cx.new(|cx| {
6265                let mut conversation = Conversation::default();
6266                conversation.register_thread(thread.clone(), cx);
6267                conversation
6268            });
6269            (thread, conversation)
6270        });
6271
6272        let _task1 = request_test_tool_authorization(&thread, "tc-1", "allow-1", cx);
6273        let _task2 = request_test_tool_authorization(&thread, "tc-2", "allow-2", cx);
6274
6275        cx.read(|cx| {
6276            let session_id = acp::SessionId::new("session-1");
6277            let (_, tool_call_id, _) = conversation
6278                .read(cx)
6279                .pending_tool_call(&session_id, cx)
6280                .expect("Expected a pending tool call");
6281            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-1"));
6282        });
6283
6284        cx.update(|cx| {
6285            conversation.update(cx, |conversation, cx| {
6286                conversation.authorize_tool_call(
6287                    acp::SessionId::new("session-1"),
6288                    acp::ToolCallId::new("tc-1"),
6289                    SelectedPermissionOutcome::new(
6290                        acp::PermissionOptionId::new("allow-1"),
6291                        acp::PermissionOptionKind::AllowOnce,
6292                    ),
6293                    cx,
6294                );
6295            });
6296        });
6297
6298        cx.run_until_parked();
6299
6300        cx.read(|cx| {
6301            let session_id = acp::SessionId::new("session-1");
6302            let (_, tool_call_id, _) = conversation
6303                .read(cx)
6304                .pending_tool_call(&session_id, cx)
6305                .expect("Expected tc-2 to be pending after tc-1 was authorized");
6306            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-2"));
6307        });
6308
6309        cx.update(|cx| {
6310            conversation.update(cx, |conversation, cx| {
6311                conversation.authorize_tool_call(
6312                    acp::SessionId::new("session-1"),
6313                    acp::ToolCallId::new("tc-2"),
6314                    SelectedPermissionOutcome::new(
6315                        acp::PermissionOptionId::new("allow-2"),
6316                        acp::PermissionOptionKind::AllowOnce,
6317                    ),
6318                    cx,
6319                );
6320            });
6321        });
6322
6323        cx.run_until_parked();
6324
6325        cx.read(|cx| {
6326            let session_id = acp::SessionId::new("session-1");
6327            assert!(
6328                conversation
6329                    .read(cx)
6330                    .pending_tool_call(&session_id, cx)
6331                    .is_none(),
6332                "Expected no pending tool calls after both were authorized"
6333            );
6334        });
6335    }
6336
6337    #[gpui::test]
6338    async fn test_conversation_subagent_scoped_pending_tool_call(cx: &mut TestAppContext) {
6339        init_test(cx);
6340
6341        let fs = FakeFs::new(cx.executor());
6342        let project = Project::test(fs, [], cx).await;
6343        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6344
6345        let (parent_thread, subagent_thread, conversation) = cx.update(|cx| {
6346            let parent_thread =
6347                create_test_acp_thread(None, "parent", connection.clone(), project.clone(), cx);
6348            let subagent_thread = create_test_acp_thread(
6349                Some(acp::SessionId::new("parent")),
6350                "subagent",
6351                connection.clone(),
6352                project.clone(),
6353                cx,
6354            );
6355            let conversation = cx.new(|cx| {
6356                let mut conversation = Conversation::default();
6357                conversation.register_thread(parent_thread.clone(), cx);
6358                conversation.register_thread(subagent_thread.clone(), cx);
6359                conversation
6360            });
6361            (parent_thread, subagent_thread, conversation)
6362        });
6363
6364        let _parent_task =
6365            request_test_tool_authorization(&parent_thread, "parent-tc", "allow-parent", cx);
6366        let _subagent_task =
6367            request_test_tool_authorization(&subagent_thread, "subagent-tc", "allow-subagent", cx);
6368
6369        // Querying with the subagent's session ID returns only the
6370        // subagent's own tool call (subagent path is scoped to its session)
6371        cx.read(|cx| {
6372            let subagent_id = acp::SessionId::new("subagent");
6373            let (session_id, tool_call_id, _) = conversation
6374                .read(cx)
6375                .pending_tool_call(&subagent_id, cx)
6376                .expect("Expected subagent's pending tool call");
6377            assert_eq!(session_id, acp::SessionId::new("subagent"));
6378            assert_eq!(tool_call_id, acp::ToolCallId::new("subagent-tc"));
6379        });
6380
6381        // Querying with the parent's session ID returns the first pending
6382        // request in FIFO order across all sessions
6383        cx.read(|cx| {
6384            let parent_id = acp::SessionId::new("parent");
6385            let (session_id, tool_call_id, _) = conversation
6386                .read(cx)
6387                .pending_tool_call(&parent_id, cx)
6388                .expect("Expected a pending tool call from parent query");
6389            assert_eq!(session_id, acp::SessionId::new("parent"));
6390            assert_eq!(tool_call_id, acp::ToolCallId::new("parent-tc"));
6391        });
6392    }
6393
6394    #[gpui::test]
6395    async fn test_conversation_parent_pending_tool_call_returns_first_across_threads(
6396        cx: &mut TestAppContext,
6397    ) {
6398        init_test(cx);
6399
6400        let fs = FakeFs::new(cx.executor());
6401        let project = Project::test(fs, [], cx).await;
6402        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
6403
6404        let (thread_a, thread_b, conversation) = cx.update(|cx| {
6405            let thread_a =
6406                create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx);
6407            let thread_b =
6408                create_test_acp_thread(None, "thread-b", connection.clone(), project.clone(), cx);
6409            let conversation = cx.new(|cx| {
6410                let mut conversation = Conversation::default();
6411                conversation.register_thread(thread_a.clone(), cx);
6412                conversation.register_thread(thread_b.clone(), cx);
6413                conversation
6414            });
6415            (thread_a, thread_b, conversation)
6416        });
6417
6418        let _task_a = request_test_tool_authorization(&thread_a, "tc-a", "allow-a", cx);
6419        let _task_b = request_test_tool_authorization(&thread_b, "tc-b", "allow-b", cx);
6420
6421        // Both threads are non-subagent, so pending_tool_call always returns
6422        // the first entry from permission_requests (FIFO across all sessions)
6423        cx.read(|cx| {
6424            let session_a = acp::SessionId::new("thread-a");
6425            let (session_id, tool_call_id, _) = conversation
6426                .read(cx)
6427                .pending_tool_call(&session_a, cx)
6428                .expect("Expected a pending tool call");
6429            assert_eq!(session_id, acp::SessionId::new("thread-a"));
6430            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
6431        });
6432
6433        // Querying with thread-b also returns thread-a's tool call,
6434        // because non-subagent queries always use permission_requests.first()
6435        cx.read(|cx| {
6436            let session_b = acp::SessionId::new("thread-b");
6437            let (session_id, tool_call_id, _) = conversation
6438                .read(cx)
6439                .pending_tool_call(&session_b, cx)
6440                .expect("Expected a pending tool call from thread-b query");
6441            assert_eq!(
6442                session_id,
6443                acp::SessionId::new("thread-a"),
6444                "Non-subagent queries always return the first pending request in FIFO order"
6445            );
6446            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
6447        });
6448
6449        // After authorizing thread-a's tool call, thread-b's becomes first
6450        cx.update(|cx| {
6451            conversation.update(cx, |conversation, cx| {
6452                conversation.authorize_tool_call(
6453                    acp::SessionId::new("thread-a"),
6454                    acp::ToolCallId::new("tc-a"),
6455                    SelectedPermissionOutcome::new(
6456                        acp::PermissionOptionId::new("allow-a"),
6457                        acp::PermissionOptionKind::AllowOnce,
6458                    ),
6459                    cx,
6460                );
6461            });
6462        });
6463
6464        cx.run_until_parked();
6465
6466        cx.read(|cx| {
6467            let session_b = acp::SessionId::new("thread-b");
6468            let (session_id, tool_call_id, _) = conversation
6469                .read(cx)
6470                .pending_tool_call(&session_b, cx)
6471                .expect("Expected thread-b's tool call after thread-a's was authorized");
6472            assert_eq!(session_id, acp::SessionId::new("thread-b"));
6473            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b"));
6474        });
6475    }
6476
6477    #[gpui::test]
6478    async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) {
6479        init_test(cx);
6480
6481        let (conversation_view, cx) =
6482            setup_conversation_view(StubAgentServer::default_response(), cx).await;
6483
6484        // Add a plain-text message to the queue directly.
6485        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
6486            thread.add_to_queue(
6487                vec![acp::ContentBlock::Text(acp::TextContent::new(
6488                    "queued message".to_string(),
6489                ))],
6490                vec![],
6491                cx,
6492            );
6493            // Main editor must be empty for this path — it is by default, but
6494            // assert to make the precondition explicit.
6495            assert!(thread.message_editor.read(cx).is_empty(cx));
6496            thread.move_queued_message_to_main_editor(0, None, None, window, cx);
6497        });
6498
6499        cx.run_until_parked();
6500
6501        // Queue should now be empty.
6502        let queue_len = active_thread(&conversation_view, cx)
6503            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
6504        assert_eq!(queue_len, 0, "Queue should be empty after move");
6505
6506        // Main editor should contain the queued message text.
6507        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
6508        assert_eq!(
6509            text, "queued message",
6510            "Main editor should contain the moved queued message"
6511        );
6512    }
6513
6514    #[gpui::test]
6515    async fn test_move_queued_message_to_non_empty_main_editor(cx: &mut TestAppContext) {
6516        init_test(cx);
6517
6518        let (conversation_view, cx) =
6519            setup_conversation_view(StubAgentServer::default_response(), cx).await;
6520
6521        // Seed the main editor with existing content.
6522        message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| {
6523            editor.set_message(
6524                vec![acp::ContentBlock::Text(acp::TextContent::new(
6525                    "existing content".to_string(),
6526                ))],
6527                window,
6528                cx,
6529            );
6530        });
6531
6532        // Add a plain-text message to the queue.
6533        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
6534            thread.add_to_queue(
6535                vec![acp::ContentBlock::Text(acp::TextContent::new(
6536                    "queued message".to_string(),
6537                ))],
6538                vec![],
6539                cx,
6540            );
6541            thread.move_queued_message_to_main_editor(0, None, None, window, cx);
6542        });
6543
6544        cx.run_until_parked();
6545
6546        // Queue should now be empty.
6547        let queue_len = active_thread(&conversation_view, cx)
6548            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
6549        assert_eq!(queue_len, 0, "Queue should be empty after move");
6550
6551        // Main editor should contain existing content + separator + queued content.
6552        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
6553        assert_eq!(
6554            text, "existing content\n\nqueued message",
6555            "Main editor should have existing content and queued message separated by two newlines"
6556        );
6557    }
6558
6559    #[gpui::test]
6560    async fn test_close_all_sessions_skips_when_unsupported(cx: &mut TestAppContext) {
6561        init_test(cx);
6562
6563        let fs = FakeFs::new(cx.executor());
6564        let project = Project::test(fs, [], cx).await;
6565        let (multi_workspace, cx) =
6566            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6567        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6568
6569        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
6570        let connection_store =
6571            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
6572
6573        // StubAgentConnection defaults to supports_close_session() -> false
6574        let conversation_view = cx.update(|window, cx| {
6575            cx.new(|cx| {
6576                ConversationView::new(
6577                    Rc::new(StubAgentServer::default_response()),
6578                    connection_store,
6579                    Agent::Custom { id: "Test".into() },
6580                    None,
6581                    None,
6582                    None,
6583                    None,
6584                    workspace.downgrade(),
6585                    project,
6586                    Some(thread_store),
6587                    None,
6588                    window,
6589                    cx,
6590                )
6591            })
6592        });
6593
6594        cx.run_until_parked();
6595
6596        conversation_view.read_with(cx, |view, _cx| {
6597            let connected = view.as_connected().expect("Should be connected");
6598            assert!(
6599                !connected.threads.is_empty(),
6600                "There should be at least one thread"
6601            );
6602            assert!(
6603                !connected.connection.supports_close_session(),
6604                "StubAgentConnection should not support close"
6605            );
6606        });
6607
6608        conversation_view
6609            .update(cx, |view, cx| {
6610                view.as_connected()
6611                    .expect("Should be connected")
6612                    .close_all_sessions(cx)
6613            })
6614            .await;
6615    }
6616
6617    #[gpui::test]
6618    async fn test_close_all_sessions_calls_close_when_supported(cx: &mut TestAppContext) {
6619        init_test(cx);
6620
6621        let (conversation_view, cx) =
6622            setup_conversation_view(StubAgentServer::new(CloseCapableConnection::new()), cx).await;
6623
6624        cx.run_until_parked();
6625
6626        let close_capable = conversation_view.read_with(cx, |view, _cx| {
6627            let connected = view.as_connected().expect("Should be connected");
6628            assert!(
6629                !connected.threads.is_empty(),
6630                "There should be at least one thread"
6631            );
6632            assert!(
6633                connected.connection.supports_close_session(),
6634                "CloseCapableConnection should support close"
6635            );
6636            connected
6637                .connection
6638                .clone()
6639                .into_any()
6640                .downcast::<CloseCapableConnection>()
6641                .expect("Should be CloseCapableConnection")
6642        });
6643
6644        conversation_view
6645            .update(cx, |view, cx| {
6646                view.as_connected()
6647                    .expect("Should be connected")
6648                    .close_all_sessions(cx)
6649            })
6650            .await;
6651
6652        let closed_count = close_capable.closed_sessions.lock().len();
6653        assert!(
6654            closed_count > 0,
6655            "close_session should have been called for each thread"
6656        );
6657    }
6658
6659    #[gpui::test]
6660    async fn test_close_session_returns_error_when_unsupported(cx: &mut TestAppContext) {
6661        init_test(cx);
6662
6663        let (conversation_view, cx) =
6664            setup_conversation_view(StubAgentServer::default_response(), cx).await;
6665
6666        cx.run_until_parked();
6667
6668        let result = conversation_view
6669            .update(cx, |view, cx| {
6670                let connected = view.as_connected().expect("Should be connected");
6671                assert!(
6672                    !connected.connection.supports_close_session(),
6673                    "StubAgentConnection should not support close"
6674                );
6675                let session_id = connected
6676                    .threads
6677                    .keys()
6678                    .next()
6679                    .expect("Should have at least one thread")
6680                    .clone();
6681                connected.connection.clone().close_session(&session_id, cx)
6682            })
6683            .await;
6684
6685        assert!(
6686            result.is_err(),
6687            "close_session should return an error when close is not supported"
6688        );
6689        assert!(
6690            result.unwrap_err().to_string().contains("not supported"),
6691            "Error message should indicate that closing is not supported"
6692        );
6693    }
6694
6695    #[derive(Clone)]
6696    struct CloseCapableConnection {
6697        closed_sessions: Arc<Mutex<Vec<acp::SessionId>>>,
6698    }
6699
6700    impl CloseCapableConnection {
6701        fn new() -> Self {
6702            Self {
6703                closed_sessions: Arc::new(Mutex::new(Vec::new())),
6704            }
6705        }
6706    }
6707
6708    impl AgentConnection for CloseCapableConnection {
6709        fn agent_id(&self) -> AgentId {
6710            AgentId::new("close-capable")
6711        }
6712
6713        fn telemetry_id(&self) -> SharedString {
6714            "close-capable".into()
6715        }
6716
6717        fn new_session(
6718            self: Rc<Self>,
6719            project: Entity<Project>,
6720            work_dirs: PathList,
6721            cx: &mut gpui::App,
6722        ) -> Task<gpui::Result<Entity<AcpThread>>> {
6723            let action_log = cx.new(|_| ActionLog::new(project.clone()));
6724            let thread = cx.new(|cx| {
6725                AcpThread::new(
6726                    None,
6727                    Some("CloseCapableConnection".into()),
6728                    Some(work_dirs),
6729                    self,
6730                    project,
6731                    action_log,
6732                    SessionId::new("close-capable-session"),
6733                    watch::Receiver::constant(
6734                        acp::PromptCapabilities::new()
6735                            .image(true)
6736                            .audio(true)
6737                            .embedded_context(true),
6738                    ),
6739                    cx,
6740                )
6741            });
6742            Task::ready(Ok(thread))
6743        }
6744
6745        fn supports_close_session(&self) -> bool {
6746            true
6747        }
6748
6749        fn close_session(
6750            self: Rc<Self>,
6751            session_id: &acp::SessionId,
6752            _cx: &mut App,
6753        ) -> Task<Result<()>> {
6754            self.closed_sessions.lock().push(session_id.clone());
6755            Task::ready(Ok(()))
6756        }
6757
6758        fn auth_methods(&self) -> &[acp::AuthMethod] {
6759            &[]
6760        }
6761
6762        fn authenticate(
6763            &self,
6764            _method_id: acp::AuthMethodId,
6765            _cx: &mut App,
6766        ) -> Task<gpui::Result<()>> {
6767            Task::ready(Ok(()))
6768        }
6769
6770        fn prompt(
6771            &self,
6772            _id: Option<acp_thread::UserMessageId>,
6773            _params: acp::PromptRequest,
6774            _cx: &mut App,
6775        ) -> Task<gpui::Result<acp::PromptResponse>> {
6776            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
6777        }
6778
6779        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
6780
6781        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
6782            self
6783        }
6784    }
6785}