Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:51:46.509Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

conversation_view.rs

11149 lines · 406.5 KB · rust
1use acp_thread::{
2    AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk,
3    AuthRequired, ClientUserMessageId, ElicitationEntryId, ElicitationStatus, ElicitationStore,
4    LoadError, MaxOutputTokensError, MentionUri, PermissionOptionChoice, PermissionOptions,
5    PermissionPattern, RetryStatus, SelectedPermissionOutcome, ThreadStatus, ToolCall,
6    ToolCallContent, ToolCallStatus,
7};
8use acp_thread::{AgentConnection, Plan};
9use action_log::{ActionLog, ActionLogTelemetry, DiffStats};
10use agent::{NoModelConfiguredError, ThreadStore};
11use agent_client_protocol::schema::v1 as acp;
12#[cfg(test)]
13use agent_servers::AgentServerDelegate;
14use agent_servers::{AgentServer, GEMINI_TERMINAL_AUTH_METHOD_ID};
15use agent_settings::{AgentProfileId, AgentSettings};
16use anyhow::{Result, anyhow};
17#[cfg(feature = "audio")]
18use audio::{Audio, Sound};
19use buffer_diff::BufferDiff;
20use client::zed_urls;
21use collections::{HashMap, HashSet, IndexMap};
22use editor::scroll::Autoscroll;
23use editor::{
24    Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
25};
26use file_icons::FileIcons;
27use fs::Fs;
28use futures::FutureExt as _;
29use gpui::{
30    Action, Animation, AnimationExt, App, ClickEvent, ClipboardItem, CursorStyle, ElementId, Empty,
31    Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, ObjectFit,
32    PlatformDisplay, ScrollHandle, SharedString, StyledText, Subscription, Task, TextRun,
33    TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop,
34    linear_gradient, list, pulsating_between,
35};
36use language::{Buffer, Language, Rope};
37use language_model::LanguageModelCompletionError;
38use markdown::{
39    CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle,
40};
41use parking_lot::{Mutex, RwLock};
42use project::{AgentId, AgentServerStore, Project, ProjectEntryId, ProjectPath};
43
44use crate::conversation_view::elicitation::{
45    ElicitationCard, ElicitationCardHandlers, ElicitationFormState, should_render_elicitation,
46};
47use crate::message_editor::SessionCapabilities;
48use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image};
49use lru::LruCache;
50use rope::Point;
51use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
52use std::num::NonZeroUsize;
53use std::path::{Path, PathBuf};
54use std::sync::Arc;
55use std::time::Instant;
56use std::{rc::Rc, time::Duration};
57use terminal_view::terminal_panel::TerminalPanel;
58use text::Anchor;
59use theme_settings::{AgentBufferFontSize, AgentUiFontSize};
60use ui::{
61    Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton,
62    DecoratedIcon, DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind,
63    KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
64    right_click_menu,
65};
66use util::{
67    ResultExt, debug_panic, defer,
68    paths::{PathStyle, PathWithPosition},
69    rel_path::RelPath,
70    size::format_file_size,
71    time::duration_alt_display,
72};
73use workspace::{
74    CollaboratorId, MultiWorkspace, NewTerminal, PathList, Workspace, path_link::sanitize_path_text,
75};
76use zed_actions::agent::{Chat, ToggleModelSelector};
77
78use super::config_options::ConfigOptionsView;
79use super::entry_view_state::EntryViewState;
80use crate::ModeSelector;
81use crate::ModelSelectorPopover;
82use crate::agent_connection_store::{
83    AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore,
84};
85use crate::agent_diff::AgentDiff;
86use crate::completion_provider::{AgentContextSelection, AvailableSkill};
87use crate::entry_view_state::{EntryViewEvent, ViewEvent};
88use crate::message_editor::{InputAttempt, MessageEditor, MessageEditorEvent};
89use crate::profile_selector::{ProfileProvider, ProfileSelector};
90
91use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
92use crate::ui::{AgentNotification, AgentNotificationEvent};
93use crate::{
94    Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AgentPanelEvent, AllowAlways, AllowOnce,
95    AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector,
96    CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread,
97    OpenAddContextMenu, OpenAgentDiff, RejectAll, RejectOnce, RemoveFirstQueuedMessage,
98    ScrollOutputLineDown, ScrollOutputLineUp, ScrollOutputPageDown, ScrollOutputPageUp,
99    ScrollOutputToBottom, ScrollOutputToNextMessage, ScrollOutputToPreviousMessage,
100    ScrollOutputToTop, SendImmediately, SendNextQueuedMessage, ToggleFastMode,
101    ToggleProfileSelector, ToggleSteerFirstQueuedMessage, ToggleThinkingEffortMenu,
102    ToggleThinkingMode, UndoLastReject,
103};
104
105const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
106const TOKEN_THRESHOLD: u64 = 250;
107
108pub(crate) const DRAFT_PROMPT_PERSIST_DEBOUNCE: Duration = Duration::from_millis(250);
109
110pub(crate) mod elicitation;
111mod message_queue;
112mod thread_search_bar;
113mod thread_view;
114pub use message_queue::*;
115pub use thread_view::*;
116
117#[derive(Copy, Clone, Debug, PartialEq, Eq)]
118enum ThreadFeedback {
119    Positive,
120    Negative,
121}
122
123#[derive(Debug)]
124pub(crate) enum ThreadError {
125    PaymentRequired,
126    DataRetentionConsentRequired,
127    Refusal,
128    AuthenticationRequired(SharedString),
129    RateLimitExceeded {
130        provider: SharedString,
131    },
132    ServerOverloaded {
133        provider: SharedString,
134    },
135    PromptTooLarge,
136    NoCredentials {
137        provider: SharedString,
138    },
139    StreamError {
140        provider: SharedString,
141    },
142    AuthenticationFailed {
143        provider: SharedString,
144    },
145    PermissionDenied {
146        provider: SharedString,
147        message: Option<SharedString>,
148    },
149    RequestFailed,
150    MaxOutputTokens,
151    NoModelSelected,
152    ApiError {
153        provider: SharedString,
154    },
155    Other {
156        message: SharedString,
157        acp_error_code: Option<SharedString>,
158    },
159}
160
161impl From<anyhow::Error> for ThreadError {
162    fn from(error: anyhow::Error) -> Self {
163        if error.is::<MaxOutputTokensError>() {
164            Self::MaxOutputTokens
165        } else if error.is::<NoModelConfiguredError>() {
166            Self::NoModelSelected
167        } else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
168            && acp_error.code == acp::ErrorCode::AuthRequired
169        {
170            Self::AuthenticationRequired(acp_error.message.clone().into())
171        } else if let Some(lm_error) = error.downcast_ref::<LanguageModelCompletionError>() {
172            use LanguageModelCompletionError::*;
173            match lm_error {
174                RateLimitExceeded { provider, .. } => Self::RateLimitExceeded {
175                    provider: provider.to_string().into(),
176                },
177                ServerOverloaded { provider, .. } | ApiInternalServerError { provider, .. } => {
178                    Self::ServerOverloaded {
179                        provider: provider.to_string().into(),
180                    }
181                }
182                PromptTooLarge { .. } => Self::PromptTooLarge,
183                PaymentRequired => Self::PaymentRequired,
184                NoApiKey { provider } => Self::NoCredentials {
185                    provider: provider.to_string().into(),
186                },
187                StreamEndedUnexpectedly { provider }
188                | ApiReadResponseError { provider, .. }
189                | DeserializeResponse { provider, .. }
190                | HttpSend { provider, .. } => Self::StreamError {
191                    provider: provider.to_string().into(),
192                },
193                AuthenticationError { provider, .. } => Self::AuthenticationFailed {
194                    provider: provider.to_string().into(),
195                },
196                PermissionError { provider, message } => Self::PermissionDenied {
197                    provider: provider.to_string().into(),
198                    message: Some(message.clone().into()),
199                },
200                UpstreamProviderError { .. } => Self::RequestFailed,
201                DataRetentionConsentRequired { .. } => Self::DataRetentionConsentRequired,
202                BadRequestFormat { provider, .. }
203                | HttpResponseError { provider, .. }
204                | ApiEndpointNotFound { provider } => Self::ApiError {
205                    provider: provider.to_string().into(),
206                },
207                _ => {
208                    let message: SharedString = format!("{:#}", error).into();
209                    Self::Other {
210                        message,
211                        acp_error_code: None,
212                    }
213                }
214            }
215        } else {
216            let message: SharedString = format!("{:#}", error).into();
217
218            // Extract ACP error code if available
219            let acp_error_code = error
220                .downcast_ref::<acp::Error>()
221                .map(|acp_error| SharedString::from(acp_error.code.to_string()));
222
223            Self::Other {
224                message,
225                acp_error_code,
226            }
227        }
228    }
229}
230
231impl ProfileProvider for Entity<agent::Thread> {
232    fn profile_id(&self, cx: &App) -> AgentProfileId {
233        self.read(cx).profile().clone()
234    }
235
236    fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
237        self.update(cx, |thread, cx| {
238            // Apply the profile and let the thread swap to its default model.
239            thread.set_profile(profile_id, cx);
240        });
241    }
242
243    fn profiles_supported(&self, cx: &App) -> bool {
244        self.read(cx)
245            .model()
246            .is_some_and(|model| model.supports_tools())
247    }
248
249    fn model_selected(&self, cx: &App) -> bool {
250        self.read(cx).model().is_some()
251    }
252}
253
254#[derive(Default)]
255pub(crate) struct Conversation {
256    threads: HashMap<acp::SessionId, Entity<AcpThread>>,
257    permission_requests: IndexMap<acp::SessionId, Vec<acp::ToolCallId>>,
258    elicitation_requests: IndexMap<acp::SessionId, Vec<ElicitationEntryId>>,
259    subscriptions: Vec<Subscription>,
260    updated_at: Option<Instant>,
261}
262
263impl Conversation {
264    pub fn register_thread(&mut self, thread: Entity<AcpThread>, cx: &mut Context<Self>) {
265        let session_id = thread.read(cx).session_id().clone();
266        let subscription = cx.subscribe(&thread, {
267            let session_id = session_id.clone();
268            move |this, _thread, event, _cx| {
269                this.updated_at = Some(Instant::now());
270                match event {
271                    AcpThreadEvent::ToolAuthorizationRequested(id) => {
272                        this.permission_requests
273                            .entry(session_id.clone())
274                            .or_default()
275                            .push(id.clone());
276                    }
277                    AcpThreadEvent::ToolAuthorizationReceived(id) => {
278                        if let Some(tool_calls) = this.permission_requests.get_mut(&session_id) {
279                            tool_calls.retain(|tool_call_id| tool_call_id != id);
280                            if tool_calls.is_empty() {
281                                this.permission_requests.shift_remove(&session_id);
282                            }
283                        }
284                    }
285                    AcpThreadEvent::ElicitationRequested(id) => {
286                        this.elicitation_requests
287                            .entry(session_id.clone())
288                            .or_default()
289                            .push(id.clone());
290                    }
291                    AcpThreadEvent::ElicitationResponded(id) => {
292                        if let Some(elicitations) = this.elicitation_requests.get_mut(&session_id) {
293                            elicitations.retain(|elicitation_id| elicitation_id != id);
294                            if elicitations.is_empty() {
295                                this.elicitation_requests.shift_remove(&session_id);
296                            }
297                        }
298                    }
299                    AcpThreadEvent::NewEntry
300                    | AcpThreadEvent::StatusChanged
301                    | AcpThreadEvent::TitleUpdated
302                    | AcpThreadEvent::TokenUsageUpdated
303                    | AcpThreadEvent::EntryUpdated(_)
304                    | AcpThreadEvent::EntriesRemoved(_)
305                    | AcpThreadEvent::Retry(_)
306                    | AcpThreadEvent::SubagentSpawned(_)
307                    | AcpThreadEvent::Stopped(_)
308                    | AcpThreadEvent::Error
309                    | AcpThreadEvent::LoadError(_)
310                    | AcpThreadEvent::PromptCapabilitiesUpdated
311                    | AcpThreadEvent::Refusal
312                    | AcpThreadEvent::AvailableCommandsUpdated(_)
313                    | AcpThreadEvent::ModeUpdated(_)
314                    | AcpThreadEvent::ConfigOptionsUpdated(_)
315                    | AcpThreadEvent::WorkingDirectoriesUpdated
316                    | AcpThreadEvent::PromptUpdated => {}
317                }
318            }
319        });
320        self.subscriptions.push(subscription);
321        self.threads.insert(session_id, thread);
322    }
323
324    pub fn permission_options_for_tool_call<'a>(
325        &'a self,
326        session_id: &acp::SessionId,
327        tool_call_id: acp::ToolCallId,
328        cx: &'a App,
329    ) -> Option<&'a PermissionOptions> {
330        let thread = self.threads.get(session_id)?;
331        let (_, tool_call) = thread.read(cx).tool_call(&tool_call_id)?;
332        let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
333            return None;
334        };
335        Some(options)
336    }
337
338    pub fn pending_tool_call<'a>(
339        &'a self,
340        session_id: &acp::SessionId,
341        cx: &'a App,
342    ) -> Option<(acp::SessionId, acp::ToolCallId, &'a PermissionOptions)> {
343        let thread = self.threads.get(session_id)?;
344        let is_subagent = thread.read(cx).parent_session_id().is_some();
345        let (result_session_id, thread, tool_id) = if is_subagent {
346            let id = self.permission_requests.get(session_id)?.iter().next()?;
347            (session_id.clone(), thread, id)
348        } else {
349            let (id, tool_calls) = self.permission_requests.first()?;
350            let thread = self.threads.get(id)?;
351            let tool_id = tool_calls.iter().next()?;
352            (id.clone(), thread, tool_id)
353        };
354        let (_, tool_call) = thread.read(cx).tool_call(tool_id)?;
355
356        let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
357            return None;
358        };
359        Some((result_session_id, tool_id.clone(), options))
360    }
361
362    pub fn subagents_awaiting_permission(&self, cx: &App) -> Vec<(acp::SessionId, usize)> {
363        self.permission_requests
364            .iter()
365            .filter_map(|(session_id, tool_call_ids)| {
366                let thread = self.threads.get(session_id)?;
367                if thread.read(cx).parent_session_id().is_some() && !tool_call_ids.is_empty() {
368                    Some((session_id.clone(), tool_call_ids.len()))
369                } else {
370                    None
371                }
372            })
373            .collect()
374    }
375
376    /// Returns the first pending tool call request for exactly `session_id`.
377    /// Unlike `pending_tool_call`, this does not use the global FIFO pending
378    /// request for non-subagent sessions.
379    pub fn pending_tool_call_for_session(
380        &self,
381        session_id: &acp::SessionId,
382        cx: &App,
383    ) -> Option<acp::ToolCallId> {
384        let thread = self.threads.get(session_id)?;
385        let tool_call_id = self.permission_requests.get(session_id)?.iter().next()?;
386        let (_, tool_call) = thread.read(cx).tool_call(tool_call_id)?;
387        if !matches!(
388            tool_call.status,
389            ToolCallStatus::WaitingForConfirmation { .. }
390        ) {
391            return None;
392        }
393        Some(tool_call_id.clone())
394    }
395
396    pub fn pending_tool_call_count_for_session(&self, session_id: &acp::SessionId) -> usize {
397        self.permission_requests
398            .get(session_id)
399            .map(|tool_call_ids| tool_call_ids.len())
400            .unwrap_or(0)
401    }
402
403    pub fn respond_to_elicitation(
404        &mut self,
405        session_id: acp::SessionId,
406        elicitation_id: ElicitationEntryId,
407        response: acp::CreateElicitationResponse,
408        cx: &mut Context<Self>,
409    ) -> Option<()> {
410        let thread = self.threads.get(&session_id)?.clone();
411        thread.update(cx, |thread, cx| {
412            thread.respond_to_elicitation(&elicitation_id, response, cx);
413        });
414        Some(())
415    }
416
417    pub fn authorize_pending_tool_call(
418        &mut self,
419        session_id: &acp::SessionId,
420        kind: acp::PermissionOptionKind,
421        cx: &mut Context<Self>,
422    ) -> Option<()> {
423        let (authorize_session_id, tool_call_id, options) =
424            self.pending_tool_call(session_id, cx)?;
425        let option = permission_option_for_action(options, kind)?;
426        self.authorize_tool_call(
427            authorize_session_id,
428            tool_call_id,
429            SelectedPermissionOutcome::new(option.option_id.clone(), option.kind),
430            cx,
431        );
432        Some(())
433    }
434
435    pub fn authorize_with_granularity(
436        &mut self,
437        session_id: acp::SessionId,
438        tool_call_id: acp::ToolCallId,
439        selection: Option<&thread_view::PermissionSelection>,
440        is_allow: bool,
441        cx: &mut Context<Self>,
442    ) -> Option<()> {
443        let options =
444            self.permission_options_for_tool_call(&session_id, tool_call_id.clone(), cx)?;
445        let outcome = resolve_outcome_from_selection(options, selection, is_allow)?;
446        self.authorize_tool_call(session_id, tool_call_id, outcome, cx);
447        Some(())
448    }
449
450    pub fn authorize_tool_call(
451        &mut self,
452        session_id: acp::SessionId,
453        tool_call_id: acp::ToolCallId,
454        outcome: SelectedPermissionOutcome,
455        cx: &mut Context<Self>,
456    ) {
457        let Some(thread) = self.threads.get(&session_id) else {
458            return;
459        };
460        let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
461        let session_id = thread.read(cx).session_id().clone();
462
463        telemetry::event!(
464            "Agent Tool Call Authorized",
465            agent = agent_telemetry_id,
466            session = session_id,
467            option = outcome.option_kind
468        );
469
470        thread.update(cx, |thread, cx| {
471            thread.authorize_tool_call(tool_call_id, outcome, cx);
472        });
473        cx.notify();
474    }
475
476    fn set_work_dirs(&mut self, work_dirs: PathList, cx: &mut Context<Self>) {
477        for thread in self.threads.values() {
478            thread.update(cx, |thread, cx| {
479                thread.set_work_dirs(work_dirs.clone(), cx);
480            });
481        }
482    }
483}
484
485pub(crate) struct RootThreadUpdated;
486
487impl EventEmitter<RootThreadUpdated> for ConversationView {}
488
489fn permission_option_for_action(
490    options: &PermissionOptions,
491    kind: acp::PermissionOptionKind,
492) -> Option<&acp::PermissionOption> {
493    if kind == acp::PermissionOptionKind::AllowAlways
494        && let PermissionOptions::Flat(options) = options
495        && let Some(option) = options.iter().find(|option| {
496            option.option_id.0.as_ref() == acp_thread::SandboxPermission::AllowAlways.as_id()
497        })
498    {
499        return Some(option);
500    }
501
502    options.first_option_of_kind(kind)
503}
504
505pub struct StateChange;
506
507impl EventEmitter<StateChange> for ConversationView {}
508
509fn resolve_outcome_from_selection(
510    options: &PermissionOptions,
511    selection: Option<&thread_view::PermissionSelection>,
512    is_allow: bool,
513) -> Option<SelectedPermissionOutcome> {
514    let choices = match options {
515        PermissionOptions::Dropdown(choices) => choices.as_slice(),
516        PermissionOptions::DropdownWithPatterns { choices, .. } => choices.as_slice(),
517        PermissionOptions::Flat(_) => {
518            let kind = if is_allow {
519                acp::PermissionOptionKind::AllowOnce
520            } else {
521                acp::PermissionOptionKind::RejectOnce
522            };
523            let option = options.first_option_of_kind(kind)?;
524            return Some(SelectedPermissionOutcome::new(
525                option.option_id.clone(),
526                option.kind,
527            ));
528        }
529    };
530
531    // When in per-command pattern mode, use the checked patterns.
532    if let Some(thread_view::PermissionSelection::SelectedPatterns(checked)) = selection {
533        if let Some(outcome) = options.build_outcome_for_checked_patterns(checked, is_allow) {
534            return Some(outcome);
535        }
536    }
537
538    // Use the selected granularity choice ("Always for terminal" or "Only this time").
539    let selected_index = selection
540        .and_then(|s| s.choice_index())
541        .unwrap_or_else(|| choices.len().saturating_sub(1));
542    let selected_choice = choices.get(selected_index).or(choices.last())?;
543    Some(selected_choice.build_outcome(is_allow))
544}
545
546fn affects_thread_metadata(event: &AcpThreadEvent) -> bool {
547    match event {
548        AcpThreadEvent::NewEntry
549        | AcpThreadEvent::TitleUpdated
550        | AcpThreadEvent::ToolAuthorizationRequested(_)
551        | AcpThreadEvent::ToolAuthorizationReceived(_)
552        | AcpThreadEvent::ElicitationRequested(_)
553        | AcpThreadEvent::ElicitationResponded(_)
554        | AcpThreadEvent::Stopped(_)
555        | AcpThreadEvent::Error
556        | AcpThreadEvent::LoadError(_)
557        | AcpThreadEvent::Refusal
558        | AcpThreadEvent::WorkingDirectoriesUpdated => true,
559        // --
560        AcpThreadEvent::EntryUpdated(_)
561        | AcpThreadEvent::StatusChanged
562        | AcpThreadEvent::EntriesRemoved(_)
563        | AcpThreadEvent::Retry(_)
564        | AcpThreadEvent::TokenUsageUpdated
565        | AcpThreadEvent::PromptCapabilitiesUpdated
566        | AcpThreadEvent::AvailableCommandsUpdated(_)
567        | AcpThreadEvent::ModeUpdated(_)
568        | AcpThreadEvent::ConfigOptionsUpdated(_)
569        | AcpThreadEvent::SubagentSpawned(_)
570        | AcpThreadEvent::PromptUpdated => false,
571    }
572}
573
574pub enum AcpServerViewEvent {
575    ActiveThreadChanged,
576}
577
578impl EventEmitter<AcpServerViewEvent> for ConversationView {}
579
580pub struct ConversationView {
581    agent: Rc<dyn AgentServer>,
582    connection_store: Entity<AgentConnectionStore>,
583    connection_key: Agent,
584    agent_server_store: Entity<AgentServerStore>,
585    workspace: WeakEntity<Workspace>,
586    project: Entity<Project>,
587    thread_store: Option<Entity<ThreadStore>>,
588    pub(crate) thread_id: ThreadId,
589    pub(crate) root_session_id: Option<acp::SessionId>,
590    server_state: ServerState,
591    focus_handle: FocusHandle,
592    notifications: Vec<WindowHandle<AgentNotification>>,
593    notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
594    auth_task: Option<Task<()>>,
595    loading_status: Option<SharedString>,
596    /// When settings change, use this to see if the theme has changed (which
597    /// causes mermaid diagrams to re-render).
598    last_theme_id: Option<String>,
599    draft_prompt_persist_task: Option<Task<()>>,
600    /// Cache + worktree snapshot for resolving paths in markdown code spans.
601    /// Shared with the child [`ThreadView`] when one is constructed.
602    pub(crate) code_span_resolver: AgentCodeSpanResolver,
603    request_elicitation_form_states: HashMap<ElicitationEntryId, ElicitationFormState>,
604    _subscriptions: Vec<Subscription>,
605}
606
607impl ConversationView {
608    pub fn has_auth_methods(&self) -> bool {
609        self.as_connected().map_or(false, |connected| {
610            !connected.connection.auth_methods().is_empty()
611        })
612    }
613
614    pub fn supports_logout(&self) -> bool {
615        self.as_connected().is_some_and(|connected| {
616            connected.auth_state.is_ok() && connected.connection.supports_logout()
617        })
618    }
619
620    pub fn active_thread(&self) -> Option<&Entity<ThreadView>> {
621        match &self.server_state {
622            ServerState::Connected(connected) => connected.active_view(),
623            _ => None,
624        }
625    }
626
627    pub fn pending_tool_call<'a>(
628        &'a self,
629        cx: &'a App,
630    ) -> Option<(acp::SessionId, acp::ToolCallId, &'a PermissionOptions)> {
631        let session_id = self.active_thread()?.read(cx).session_id.clone();
632        self.as_connected()?
633            .conversation
634            .read(cx)
635            .pending_tool_call(&session_id, cx)
636    }
637
638    pub fn root_thread_has_pending_tool_call(&self, cx: &App) -> bool {
639        let Some(root_thread) = self.root_thread_view() else {
640            return false;
641        };
642        let root_session_id = root_thread.read(cx).thread.read(cx).session_id().clone();
643        self.as_connected().is_some_and(|connected| {
644            connected
645                .conversation
646                .read(cx)
647                .pending_tool_call(&root_session_id, cx)
648                .is_some()
649        })
650    }
651
652    pub(crate) fn root_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
653        self.root_thread_view()
654            .map(|view| view.read(cx).thread.clone())
655    }
656
657    pub fn root_thread_view(&self) -> Option<Entity<ThreadView>> {
658        self.root_session_id
659            .as_ref()
660            .and_then(|id| self.thread_view(id))
661    }
662
663    pub fn thread_view(&self, session_id: &acp::SessionId) -> Option<Entity<ThreadView>> {
664        let connected = self.as_connected()?;
665        connected.threads.get(session_id).cloned()
666    }
667
668    pub fn as_connected(&self) -> Option<&ConnectedServerState> {
669        match &self.server_state {
670            ServerState::Connected(connected) => Some(connected),
671            _ => None,
672        }
673    }
674
675    pub fn as_connected_mut(&mut self) -> Option<&mut ConnectedServerState> {
676        match &mut self.server_state {
677            ServerState::Connected(connected) => Some(connected),
678            _ => None,
679        }
680    }
681
682    pub fn updated_at(&self, cx: &App) -> Option<Instant> {
683        self.as_connected()
684            .and_then(|connected| connected.conversation.read(cx).updated_at)
685    }
686
687    pub fn navigate_to_thread(
688        &mut self,
689        session_id: acp::SessionId,
690        window: &mut Window,
691        cx: &mut Context<Self>,
692    ) {
693        let Some(connected) = self.as_connected_mut() else {
694            return;
695        };
696
697        connected.navigate_to_thread(session_id);
698        if let Some(view) = self.active_thread() {
699            view.focus_handle(cx).focus(window, cx);
700        }
701        cx.emit(AcpServerViewEvent::ActiveThreadChanged);
702        cx.notify();
703    }
704
705    pub fn set_work_dirs(&mut self, work_dirs: PathList, cx: &mut Context<Self>) {
706        if let Some(connected) = self.as_connected() {
707            connected.conversation.update(cx, |conversation, cx| {
708                conversation.set_work_dirs(work_dirs.clone(), cx);
709            });
710        }
711    }
712}
713
714enum ServerState {
715    Loading {
716        _loading: Entity<LoadingView>,
717        connection: Option<Rc<dyn AgentConnection>>,
718        _request_elicitation_subscription: Option<Subscription>,
719    },
720    LoadError {
721        error: LoadError,
722    },
723    Connected(ConnectedServerState),
724}
725
726// current -> Entity
727// hashmap of threads, current becomes session_id
728pub struct ConnectedServerState {
729    auth_state: AuthState,
730    active_id: Option<acp::SessionId>,
731    pub(crate) threads: HashMap<acp::SessionId, Entity<ThreadView>>,
732    connection: Rc<dyn AgentConnection>,
733    conversation: Entity<Conversation>,
734    _connection_entry_subscription: Subscription,
735    _request_elicitation_subscription: Option<Subscription>,
736}
737
738enum AuthState {
739    Ok,
740    Unauthenticated {
741        description: Option<Entity<Markdown>>,
742        pending_auth_method: Option<acp::AuthMethodId>,
743    },
744}
745
746impl AuthState {
747    pub fn is_ok(&self) -> bool {
748        matches!(self, Self::Ok)
749    }
750}
751
752struct LoadingView {
753    _load_task: Task<()>,
754}
755
756impl ConnectedServerState {
757    pub fn active_view(&self) -> Option<&Entity<ThreadView>> {
758        self.active_id.as_ref().and_then(|id| self.threads.get(id))
759    }
760
761    pub fn has_thread_error(&self, cx: &App) -> bool {
762        self.active_view()
763            .map_or(false, |view| view.read(cx).thread_error.is_some())
764    }
765
766    pub fn navigate_to_thread(&mut self, session_id: acp::SessionId) {
767        if self.threads.contains_key(&session_id) {
768            self.active_id = Some(session_id);
769        }
770    }
771
772    pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> {
773        let tasks = self.threads.values().filter_map(|view| {
774            if self.connection.supports_close_session() {
775                let session_id = view.read(cx).thread.read(cx).session_id().clone();
776                Some(self.connection.clone().close_session(&session_id, cx))
777            } else {
778                None
779            }
780        });
781        let task = futures::future::join_all(tasks);
782        cx.background_spawn(async move {
783            task.await;
784        })
785    }
786}
787
788impl ConversationView {
789    pub fn new(
790        agent: Rc<dyn AgentServer>,
791        connection_store: Entity<AgentConnectionStore>,
792        connection_key: Agent,
793        resume_session_id: Option<acp::SessionId>,
794        thread_id: Option<ThreadId>,
795        work_dirs: Option<PathList>,
796        title: Option<SharedString>,
797        initial_content: Option<AgentInitialContent>,
798        workspace: WeakEntity<Workspace>,
799        project: Entity<Project>,
800        thread_store: Option<Entity<ThreadStore>>,
801        source: AgentThreadSource,
802        window: &mut Window,
803        cx: &mut Context<Self>,
804    ) -> Self {
805        let agent_server_store = project.read(cx).agent_server_store().clone();
806        let code_span_resolver = AgentCodeSpanResolver::new(&project.downgrade(), cx);
807        let mut subscriptions = vec![
808            cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
809            cx.observe_global_in::<SettingsStore>(window, Self::invalidate_mermaid_caches),
810            cx.observe_global_in::<AgentUiFontSize>(window, Self::agent_ui_font_size_changed),
811            cx.observe_global_in::<AgentBufferFontSize>(window, Self::agent_ui_font_size_changed),
812            cx.subscribe_in(
813                &agent_server_store,
814                window,
815                Self::handle_agent_servers_updated,
816            ),
817        ];
818        subscriptions.push(cx.subscribe(&project, {
819            let resolver = code_span_resolver.clone();
820            move |_this: &mut Self, _project, event: &project::Event, cx| {
821                if matches!(
822                    event,
823                    project::Event::WorktreeAdded(_)
824                        | project::Event::WorktreeRemoved(_)
825                        | project::Event::WorktreeUpdatedEntries(_, _)
826                ) {
827                    resolver.clear_cache();
828                    cx.notify();
829                }
830            }
831        }));
832
833        cx.on_release(|this, cx| {
834            this.request_elicitation_form_states.clear();
835            if let Some(connected) = this.as_connected() {
836                connected.close_all_sessions(cx).detach();
837            }
838            for window in this.notifications.drain(..) {
839                window
840                    .update(cx, |_, window, _| {
841                        window.remove_window();
842                    })
843                    .ok();
844            }
845        })
846        .detach();
847
848        let thread_id = thread_id.unwrap_or_else(ThreadId::new);
849
850        Self {
851            agent: agent.clone(),
852            connection_store: connection_store.clone(),
853            connection_key: connection_key.clone(),
854            agent_server_store,
855            workspace,
856            project: project.clone(),
857            thread_store,
858            thread_id,
859            root_session_id: resume_session_id.clone(),
860            server_state: Self::initial_state(
861                agent.clone(),
862                connection_store,
863                connection_key,
864                resume_session_id,
865                work_dirs,
866                title,
867                project,
868                initial_content,
869                source,
870                window,
871                cx,
872            ),
873            notifications: Vec::new(),
874            notification_subscriptions: HashMap::default(),
875            auth_task: None,
876            loading_status: None,
877            last_theme_id: Some(cx.theme().id.clone()),
878            draft_prompt_persist_task: None,
879            code_span_resolver,
880            request_elicitation_form_states: HashMap::default(),
881            _subscriptions: subscriptions,
882            focus_handle: cx.focus_handle(),
883        }
884    }
885
886    fn set_server_state(&mut self, state: ServerState, cx: &mut Context<Self>) {
887        let previous_request_elicitation_connection = self.request_elicitation_connection();
888        let next_request_elicitation_connection =
889            Self::request_elicitation_connection_for_state(&state);
890
891        if let Some(connected) = self.as_connected() {
892            connected.close_all_sessions(cx).detach();
893        }
894
895        if let Some(connection) = previous_request_elicitation_connection
896            && !next_request_elicitation_connection
897                .as_ref()
898                .is_some_and(|next_connection| Rc::ptr_eq(&connection, next_connection))
899        {
900            self.request_elicitation_form_states.clear();
901        }
902
903        self.server_state = state;
904        cx.emit(StateChange);
905        cx.emit(AcpServerViewEvent::ActiveThreadChanged);
906        if matches!(&self.server_state, ServerState::Connected(_)) {
907            cx.emit(RootThreadUpdated);
908        }
909        cx.notify();
910    }
911
912    fn request_elicitation_subscription(
913        connection: &Rc<dyn AgentConnection>,
914        cx: &mut Context<Self>,
915    ) -> Option<Subscription> {
916        let store = connection.request_elicitations()?;
917        Some(cx.observe(&store, |this, _store, cx| {
918            if let Some(active_thread) = this.active_thread().cloned() {
919                active_thread.update(cx, |_thread, cx| cx.notify());
920            }
921            cx.notify();
922        }))
923    }
924
925    fn request_elicitation_connection(&self) -> Option<Rc<dyn AgentConnection>> {
926        Self::request_elicitation_connection_for_state(&self.server_state)
927    }
928
929    fn active_thread_renders_request_elicitations(&self) -> bool {
930        match &self.server_state {
931            ServerState::Connected(connected) => {
932                connected.auth_state.is_ok() && connected.active_view().is_some()
933            }
934            _ => false,
935        }
936    }
937
938    fn request_elicitation_connection_for_state(
939        state: &ServerState,
940    ) -> Option<Rc<dyn AgentConnection>> {
941        match state {
942            ServerState::Loading {
943                connection: Some(connection),
944                ..
945            } => Some(connection.clone()),
946            ServerState::Connected(connected) => Some(connected.connection.clone()),
947            ServerState::Loading {
948                connection: None, ..
949            }
950            | ServerState::LoadError { .. } => None,
951        }
952    }
953
954    fn request_elicitation_store(&self) -> Option<Entity<ElicitationStore>> {
955        self.request_elicitation_connection()?
956            .request_elicitations()
957    }
958
959    fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
960        let (resume_session_id, work_dirs, title) = self
961            .root_thread_view()
962            .map(|thread_view| {
963                let tv = thread_view.read(cx);
964                let thread = tv.thread.read(cx);
965                (
966                    Some(thread.session_id().clone()),
967                    thread.work_dirs().cloned(),
968                    thread.title(),
969                )
970            })
971            .unwrap_or_else(|| {
972                let session_id = self.root_session_id.clone();
973                let (work_dirs, title) = session_id
974                    .as_ref()
975                    .and_then(|id| {
976                        let store = ThreadMetadataStore::try_global(cx)?;
977                        let entry = store.read(cx).entry_by_session(id)?;
978                        Some((Some(entry.folder_paths().clone()), entry.title()))
979                    })
980                    .unwrap_or((None, None));
981                (session_id, work_dirs, title)
982            });
983
984        self.clear_resolved_request_elicitations(cx);
985        self.loading_status = None;
986
987        let state = Self::initial_state(
988            self.agent.clone(),
989            self.connection_store.clone(),
990            self.connection_key.clone(),
991            resume_session_id,
992            work_dirs,
993            title,
994            self.project.clone(),
995            None,
996            AgentThreadSource::AgentPanel,
997            window,
998            cx,
999        );
1000        self.set_server_state(state, cx);
1001
1002        if let Some(view) = self.root_thread_view() {
1003            view.update(cx, |this, cx| {
1004                this.message_editor.update(cx, |editor, cx| {
1005                    editor.set_session_capabilities(this.session_capabilities.clone(), cx);
1006                });
1007            });
1008        }
1009        cx.notify();
1010    }
1011
1012    fn initial_state(
1013        agent: Rc<dyn AgentServer>,
1014        connection_store: Entity<AgentConnectionStore>,
1015        connection_key: Agent,
1016        resume_session_id: Option<acp::SessionId>,
1017        work_dirs: Option<PathList>,
1018        title: Option<SharedString>,
1019        project: Entity<Project>,
1020        initial_content: Option<AgentInitialContent>,
1021        source: AgentThreadSource,
1022        window: &mut Window,
1023        cx: &mut Context<Self>,
1024    ) -> ServerState {
1025        // OMEGA-DELTA-0035. The first-party agent is the router over the native
1026        // server now, so "is this the native agent?" cannot be a bare downcast:
1027        // a wrapped native agent would read as external and be refused in a
1028        // shared project.
1029        if project.read(cx).is_via_collab() && !crate::omega_router::is_native_agent_server(&agent)
1030        {
1031            return ServerState::LoadError {
1032                error: LoadError::Other(
1033                    "External agents are not yet supported in shared projects.".into(),
1034                ),
1035            };
1036        }
1037        let session_work_dirs = work_dirs.unwrap_or_else(|| project.read(cx).default_path_list(cx));
1038
1039        let connection_entry = connection_store.update(cx, |store, cx| {
1040            store.request_connection(connection_key, agent.clone(), cx)
1041        });
1042
1043        let connection_entry_subscription =
1044            cx.subscribe(&connection_entry, |this, _entry, event, cx| match event {
1045                AgentConnectionEntryEvent::NewVersionAvailable(version) => {
1046                    if let Some(thread) = this.root_thread_view() {
1047                        thread.update(cx, |thread, cx| {
1048                            thread.new_server_version_available = Some(version.clone());
1049                            cx.notify();
1050                        });
1051                    }
1052                }
1053                AgentConnectionEntryEvent::LoadingStatusChanged(status) => {
1054                    this.loading_status = status.clone();
1055                    cx.notify();
1056                }
1057            });
1058
1059        let connect_result = connection_entry.read(cx).wait_for_connection();
1060
1061        let side = crate::agent_sidebar_side(cx);
1062        let thread_location = "current_worktree";
1063
1064        let load_task = cx.spawn_in(window, async move |this, cx| {
1065            let connection = match connect_result.await {
1066                Ok(AgentConnectedState { connection, .. }) => connection,
1067                Err(err) => {
1068                    this.update_in(cx, |this, window, cx| {
1069                        this.handle_load_error(err, window, cx);
1070                        cx.notify();
1071                    })
1072                    .log_err();
1073                    return;
1074                }
1075            };
1076
1077            this.update_in(cx, |this, _window, cx| {
1078                let request_elicitation_subscription =
1079                    Self::request_elicitation_subscription(&connection, cx);
1080                if let ServerState::Loading {
1081                    connection: loading_connection,
1082                    _request_elicitation_subscription,
1083                    ..
1084                } = &mut this.server_state
1085                {
1086                    *loading_connection = Some(connection.clone());
1087                    *_request_elicitation_subscription = request_elicitation_subscription;
1088                    cx.notify();
1089                }
1090            })
1091            .log_err();
1092
1093            telemetry::event!(
1094                "Agent Thread Started",
1095                agent = connection.telemetry_id(),
1096                source = source.as_str(),
1097                side = side,
1098                thread_location = thread_location
1099            );
1100
1101            let mut resumed_without_history = false;
1102            let result = if let Some(session_id) = resume_session_id.clone() {
1103                cx.update(|_, cx| {
1104                    if connection.supports_load_session() {
1105                        connection.clone().load_session(
1106                            session_id,
1107                            project.clone(),
1108                            session_work_dirs,
1109                            title,
1110                            cx,
1111                        )
1112                    } else if connection.supports_resume_session() {
1113                        resumed_without_history = true;
1114                        connection.clone().resume_session(
1115                            session_id,
1116                            project.clone(),
1117                            session_work_dirs,
1118                            title,
1119                            cx,
1120                        )
1121                    } else {
1122                        Task::ready(Err(anyhow!(LoadError::Other(
1123                            "Loading or resuming sessions is not supported by this agent.".into()
1124                        ))))
1125                    }
1126                })
1127                .log_err()
1128            } else {
1129                cx.update(|_, cx| {
1130                    connection
1131                        .clone()
1132                        .new_session(project.clone(), session_work_dirs, cx)
1133                })
1134                .log_err()
1135            };
1136
1137            let Some(result) = result else {
1138                return;
1139            };
1140
1141            let result = match result.await {
1142                Err(e) => match e.downcast::<acp_thread::AuthRequired>() {
1143                    Ok(err) => {
1144                        cx.update(|window, cx| {
1145                            Self::handle_auth_required(this, err, connection, window, cx)
1146                        })
1147                        .log_err();
1148                        return;
1149                    }
1150                    Err(err) => Err(err),
1151                },
1152                Ok(thread) => Ok(thread),
1153            };
1154
1155            this.update_in(cx, |this, window, cx| {
1156                match result {
1157                    Ok(thread) => {
1158                        this.clear_resolved_request_elicitations_for_connection(&connection, cx);
1159                        let root_session_id = thread.read(cx).session_id().clone();
1160
1161                        let conversation = cx.new(|cx| {
1162                            let mut conversation = Conversation::default();
1163                            conversation.register_thread(thread.clone(), cx);
1164                            conversation
1165                        });
1166
1167                        let current = this.new_thread_view(
1168                            thread,
1169                            conversation.clone(),
1170                            resumed_without_history,
1171                            initial_content,
1172                            window,
1173                            cx,
1174                        );
1175
1176                        if this.focus_handle.contains_focused(window, cx) {
1177                            current
1178                                .read(cx)
1179                                .message_editor
1180                                .focus_handle(cx)
1181                                .focus(window, cx);
1182                        }
1183
1184                        this.root_session_id = Some(root_session_id.clone());
1185                        let request_elicitation_subscription =
1186                            Self::request_elicitation_subscription(&connection, cx);
1187                        this.set_server_state(
1188                            ServerState::Connected(ConnectedServerState {
1189                                connection,
1190                                auth_state: AuthState::Ok,
1191                                active_id: Some(root_session_id.clone()),
1192                                threads: HashMap::from_iter([(root_session_id, current)]),
1193                                conversation,
1194                                _connection_entry_subscription: connection_entry_subscription,
1195                                _request_elicitation_subscription: request_elicitation_subscription,
1196                            }),
1197                            cx,
1198                        );
1199                    }
1200                    Err(err) => {
1201                        this.handle_load_error(
1202                            LoadError::Other(err.to_string().into()),
1203                            window,
1204                            cx,
1205                        );
1206                    }
1207                };
1208            })
1209            .log_err();
1210        });
1211
1212        let loading_view = cx.new(|_cx| LoadingView {
1213            _load_task: load_task,
1214        });
1215
1216        ServerState::Loading {
1217            _loading: loading_view,
1218            connection: None,
1219            _request_elicitation_subscription: None,
1220        }
1221    }
1222
1223    fn new_thread_view(
1224        &self,
1225        thread: Entity<AcpThread>,
1226        conversation: Entity<Conversation>,
1227        resumed_without_history: bool,
1228        initial_content: Option<AgentInitialContent>,
1229        window: &mut Window,
1230        cx: &mut Context<Self>,
1231    ) -> Entity<ThreadView> {
1232        let agent_id = self.agent.agent_id();
1233        let connection = thread.read(cx).connection().clone();
1234        let session_id = thread.read(cx).session_id().clone();
1235        let available_skills = connection
1236            .clone()
1237            .downcast::<agent::NativeAgentConnection>()
1238            .map(|native_connection| native_available_skills(&native_connection, &session_id, cx))
1239            .unwrap_or_default();
1240        let omega_steer_capability = if connection
1241            .clone()
1242            .downcast::<crate::omega_exo_connection::ExoHarnessConnection>()
1243            .is_some()
1244        {
1245            omega_front_door::SteerCapability::CannotSteer
1246        } else {
1247            omega_front_door::SteerCapability::Unknown
1248        };
1249        let session_capabilities = Arc::new(RwLock::new(
1250            SessionCapabilities::new(
1251                thread.read(cx).prompt_capabilities(),
1252                thread.read(cx).available_commands().to_vec(),
1253                available_skills,
1254            )
1255            .with_omega_steer_capability(omega_steer_capability),
1256        ));
1257
1258        let action_log = thread.read(cx).action_log().clone();
1259
1260        let entry_view_state = cx.new(|_| {
1261            EntryViewState::new(
1262                self.workspace.clone(),
1263                self.project.downgrade(),
1264                self.thread_store.clone(),
1265                session_capabilities.clone(),
1266                self.agent.agent_id(),
1267            )
1268        });
1269
1270        let count = thread.read(cx).entries().len();
1271        let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0));
1272        list_state.set_follow_mode(gpui::FollowMode::Tail);
1273
1274        entry_view_state.update(cx, |view_state, cx| {
1275            for ix in 0..count {
1276                view_state.sync_entry(ix, &thread, window, cx);
1277            }
1278            list_state.splice_focusable(
1279                0..0,
1280                (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
1281            );
1282        });
1283
1284        if let Some(scroll_position) = thread.read(cx).ui_scroll_position() {
1285            list_state.scroll_to(scroll_position);
1286        } else {
1287            list_state.scroll_to_end();
1288        }
1289
1290        AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx);
1291
1292        let connection = thread.read(cx).connection().clone();
1293        let session_id = thread.read(cx).session_id().clone();
1294
1295        // Check for config options first
1296        // Config options take precedence over legacy mode/model selectors
1297        let config_options_provider = connection.session_config_options(&session_id, cx);
1298
1299        let config_options_view;
1300        let mode_selector;
1301        let model_selector;
1302        if let Some(config_options) = config_options_provider {
1303            // Use config options - don't create mode_selector or model_selector
1304            let agent_server = self.agent.clone();
1305            let fs = self.project.read(cx).fs().clone();
1306            config_options_view =
1307                Some(cx.new(|cx| {
1308                    ConfigOptionsView::new(config_options, agent_server, fs, window, cx)
1309                }));
1310            model_selector = None;
1311            mode_selector = None;
1312        } else {
1313            // Fall back to dedicated mode/model selectors
1314            config_options_view = None;
1315            model_selector = connection.model_selector(&session_id).map(|selector| {
1316                cx.new(|cx| {
1317                    ModelSelectorPopover::new(
1318                        selector,
1319                        PopoverMenuHandle::default(),
1320                        self.focus_handle(cx),
1321                        window,
1322                        cx,
1323                    )
1324                })
1325            });
1326
1327            mode_selector = connection
1328                .session_modes(&session_id, cx)
1329                .map(|session_modes| {
1330                    let fs = self.project.read(cx).fs().clone();
1331                    cx.new(|_cx| ModeSelector::new(session_modes, self.agent.clone(), fs))
1332                });
1333        }
1334
1335        let subscriptions = vec![
1336            cx.subscribe_in(&thread, window, Self::handle_thread_event),
1337            cx.observe(&action_log, |_, _, cx| cx.notify()),
1338        ];
1339
1340        let subagent_sessions = thread
1341            .read(cx)
1342            .entries()
1343            .iter()
1344            .filter_map(|entry| match entry {
1345                AgentThreadEntry::ToolCall(call) => call
1346                    .subagent_session_info
1347                    .as_ref()
1348                    .map(|i| i.session_id.clone()),
1349                _ => None,
1350            })
1351            .collect::<Vec<_>>();
1352
1353        if !subagent_sessions.is_empty() {
1354            let parent_session_id = thread.read(cx).session_id().clone();
1355            cx.spawn_in(window, async move |this, cx| {
1356                this.update_in(cx, |this, window, cx| {
1357                    for subagent_id in subagent_sessions {
1358                        this.load_subagent_session(
1359                            subagent_id,
1360                            parent_session_id.clone(),
1361                            window,
1362                            cx,
1363                        );
1364                    }
1365                })
1366            })
1367            .detach();
1368        }
1369
1370        let profile_selector: Option<Rc<agent::NativeAgentConnection>> =
1371            connection.clone().downcast();
1372        let profile_selector = profile_selector
1373            .and_then(|native_connection| native_connection.thread(&session_id, cx))
1374            .map(|native_thread| {
1375                cx.new(|cx| {
1376                    ProfileSelector::new(
1377                        <dyn Fs>::global(cx),
1378                        Arc::new(native_thread),
1379                        self.focus_handle(cx),
1380                        cx,
1381                    )
1382                })
1383            });
1384
1385        let agent_display_name = self
1386            .agent_server_store
1387            .read(cx)
1388            .agent_display_name(&agent_id.clone())
1389            .unwrap_or_else(|| agent_id.0.clone());
1390
1391        let agent_icon = self.agent.logo();
1392        let agent_icon_from_external_svg = self
1393            .agent_server_store
1394            .read(cx)
1395            .agent_icon(&self.agent.agent_id())
1396            .or_else(|| {
1397                project::AgentRegistryStore::try_global(cx).and_then(|store| {
1398                    store
1399                        .read(cx)
1400                        .agent(&self.agent.agent_id())
1401                        .and_then(|a| a.icon_path().cloned())
1402                })
1403            });
1404
1405        let weak = cx.weak_entity();
1406        cx.new(|cx| {
1407            ThreadView::new(
1408                self.thread_id,
1409                thread,
1410                conversation,
1411                weak,
1412                agent_icon,
1413                agent_icon_from_external_svg,
1414                agent_id,
1415                agent_display_name,
1416                self.workspace.clone(),
1417                entry_view_state,
1418                config_options_view,
1419                mode_selector,
1420                model_selector,
1421                profile_selector,
1422                list_state,
1423                session_capabilities,
1424                resumed_without_history,
1425                self.project.downgrade(),
1426                self.code_span_resolver.clone(),
1427                self.thread_store.clone(),
1428                initial_content,
1429                subscriptions,
1430                window,
1431                cx,
1432            )
1433        })
1434    }
1435
1436    fn handle_auth_required(
1437        this: WeakEntity<Self>,
1438        err: AuthRequired,
1439        connection: Rc<dyn AgentConnection>,
1440        window: &mut Window,
1441        cx: &mut App,
1442    ) {
1443        this.update(cx, |this, cx| {
1444            let description = err
1445                .description
1446                .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx)));
1447            let auth_state = AuthState::Unauthenticated {
1448                pending_auth_method: None,
1449                description,
1450            };
1451            if let Some(connected) = this.as_connected_mut() {
1452                connected.auth_state = auth_state;
1453                cx.emit(StateChange);
1454                if let Some(view) = connected.active_view()
1455                    && view
1456                        .read(cx)
1457                        .message_editor
1458                        .focus_handle(cx)
1459                        .is_focused(window)
1460                {
1461                    this.focus_handle.focus(window, cx)
1462                }
1463            } else {
1464                let request_elicitation_subscription =
1465                    Self::request_elicitation_subscription(&connection, cx);
1466                this.set_server_state(
1467                    ServerState::Connected(ConnectedServerState {
1468                        auth_state,
1469                        active_id: None,
1470                        threads: HashMap::default(),
1471                        connection,
1472                        conversation: cx.new(|_cx| Conversation::default()),
1473                        _connection_entry_subscription: Subscription::new(|| {}),
1474                        _request_elicitation_subscription: request_elicitation_subscription,
1475                    }),
1476                    cx,
1477                );
1478            }
1479            cx.notify();
1480        })
1481        .ok();
1482    }
1483
1484    fn handle_load_error(&mut self, err: LoadError, window: &mut Window, cx: &mut Context<Self>) {
1485        if let Some(view) = self.root_thread_view() {
1486            if view
1487                .read(cx)
1488                .message_editor
1489                .focus_handle(cx)
1490                .is_focused(window)
1491            {
1492                self.focus_handle.focus(window, cx)
1493            }
1494        }
1495        self.emit_load_error_telemetry(&err);
1496        self.set_server_state(ServerState::LoadError { error: err }, cx);
1497    }
1498
1499    fn handle_agent_servers_updated(
1500        &mut self,
1501        _agent_server_store: &Entity<project::AgentServerStore>,
1502        _event: &project::AgentServersUpdated,
1503        window: &mut Window,
1504        cx: &mut Context<Self>,
1505    ) {
1506        // If we're in a LoadError state OR have a thread_error set (which can happen
1507        // when agent.connect() fails during loading), retry loading the thread.
1508        // This handles the case where a thread is restored before authentication completes.
1509        let should_retry = match &self.server_state {
1510            ServerState::Loading { .. } => false,
1511            ServerState::LoadError { .. } => true,
1512            ServerState::Connected(connected) => {
1513                connected.auth_state.is_ok() && connected.has_thread_error(cx)
1514            }
1515        };
1516
1517        if should_retry {
1518            if let Some(active) = self.root_thread_view() {
1519                active.update(cx, |active, cx| {
1520                    active.clear_thread_error(cx);
1521                });
1522            }
1523            self.reset(window, cx);
1524        }
1525    }
1526
1527    pub fn agent_key(&self) -> &Agent {
1528        &self.connection_key
1529    }
1530
1531    pub fn title(&self, cx: &App) -> SharedString {
1532        match &self.server_state {
1533            ServerState::Connected(view) => view
1534                .active_view()
1535                .and_then(|v| v.read(cx).thread.read(cx).title())
1536                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()),
1537            ServerState::Loading { .. } => self
1538                .loading_status
1539                .clone()
1540                .unwrap_or_else(|| "Loading…".into()),
1541            ServerState::LoadError { error, .. } => match error {
1542                LoadError::Unsupported { .. } => {
1543                    format!("Upgrade {}", self.agent.agent_id()).into()
1544                }
1545                LoadError::FailedToInstall(_) => {
1546                    format!("Failed to Install {}", self.agent.agent_id()).into()
1547                }
1548                LoadError::Exited { .. } => format!("{} Exited", self.agent.agent_id()).into(),
1549                // Deliberately not "Error Loading <agent>": the agent loaded.
1550                LoadError::SessionGone => "Conversation No Longer Available".into(),
1551                LoadError::Other(_) => format!("Error Loading {}", self.agent.agent_id()).into(),
1552            },
1553        }
1554    }
1555
1556    pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
1557        if let Some(active) = self.active_thread() {
1558            active.update(cx, |active, cx| {
1559                active.cancel_generation(cx);
1560            });
1561        }
1562    }
1563
1564    pub fn parent_id(&self) -> ThreadId {
1565        self.thread_id
1566    }
1567
1568    pub fn is_loading(&self) -> bool {
1569        matches!(self.server_state, ServerState::Loading { .. })
1570    }
1571
1572    fn handle_thread_event(
1573        &mut self,
1574        thread: &Entity<AcpThread>,
1575        event: &AcpThreadEvent,
1576        window: &mut Window,
1577        cx: &mut Context<Self>,
1578    ) {
1579        let session_id = thread.read(cx).session_id().clone();
1580        let has_thread = self
1581            .as_connected()
1582            .is_some_and(|connected| connected.threads.contains_key(&session_id));
1583        if !has_thread {
1584            return;
1585        };
1586        let is_subagent = thread.read(cx).parent_session_id().is_some();
1587        if !is_subagent && affects_thread_metadata(event) {
1588            cx.emit(RootThreadUpdated);
1589        }
1590        match event {
1591            AcpThreadEvent::StatusChanged => {
1592                if let Some(active) = self.thread_view(&session_id) {
1593                    active.update(cx, |active, cx| {
1594                        active.sync_generating_indicator(cx);
1595                    });
1596                }
1597            }
1598            AcpThreadEvent::NewEntry => {
1599                let len = thread.read(cx).entries().len();
1600                let index = len - 1;
1601                if let Some(active) = self.thread_view(&session_id) {
1602                    let entry_view_state = active.read(cx).entry_view_state.clone();
1603                    let list_state = active.read(cx).list_state.clone();
1604                    entry_view_state.update(cx, |view_state, cx| {
1605                        view_state.sync_entry(index, thread, window, cx);
1606                        list_state.splice_focusable(
1607                            index..index,
1608                            [view_state
1609                                .entry(index)
1610                                .and_then(|entry| entry.focus_handle(cx))],
1611                        );
1612                    });
1613                    active.update(cx, |active, cx| {
1614                        active.sync_elicitation_state_for_entry(index, window, cx);
1615                        active.sync_editor_mode(cx);
1616                        active.sync_generating_indicator(cx);
1617                    });
1618                }
1619            }
1620            AcpThreadEvent::EntryUpdated(index) => {
1621                if let Some(active) = self.thread_view(&session_id) {
1622                    let entry_view_state = active.read(cx).entry_view_state.clone();
1623                    let list_state = active.read(cx).list_state.clone();
1624                    entry_view_state.update(cx, |view_state, cx| {
1625                        view_state.sync_entry(*index, thread, window, cx);
1626                    });
1627                    list_state.remeasure_items(*index..*index + 1);
1628                    active.update(cx, |active, cx| {
1629                        active.sync_elicitation_state_for_entry(*index, window, cx);
1630                        active.auto_expand_streaming_thought(cx);
1631                        active.sync_generating_indicator(cx);
1632                    });
1633                }
1634            }
1635            AcpThreadEvent::EntriesRemoved(range) => {
1636                if let Some(active) = self.thread_view(&session_id) {
1637                    let entry_view_state = active.read(cx).entry_view_state.clone();
1638                    let list_state = active.read(cx).list_state.clone();
1639                    entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone()));
1640                    list_state.splice(range.clone(), 0);
1641                    active.update(cx, |active, cx| {
1642                        active.sync_editor_mode(cx);
1643                    });
1644                }
1645            }
1646            AcpThreadEvent::SubagentSpawned(subagent_session_id) => {
1647                self.load_subagent_session(subagent_session_id.clone(), session_id, window, cx)
1648            }
1649            AcpThreadEvent::ToolAuthorizationRequested(_) => {
1650                self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
1651            }
1652            AcpThreadEvent::ToolAuthorizationReceived(_) => {}
1653            AcpThreadEvent::ElicitationRequested(_) => {
1654                self.notify_with_sound("Waiting for input", IconName::Info, window, cx);
1655            }
1656            AcpThreadEvent::ElicitationResponded(_) => {}
1657            AcpThreadEvent::Retry(retry) => {
1658                if let Some(active) = self.thread_view(&session_id) {
1659                    active.update(cx, |active, _cx| {
1660                        active.thread_retry_status = Some(retry.clone());
1661                    });
1662                }
1663            }
1664            AcpThreadEvent::Stopped(stop_reason) => {
1665                if let Some(active) = self.thread_view(&session_id) {
1666                    let is_generating =
1667                        matches!(thread.read(cx).status(), ThreadStatus::Generating);
1668                    active.update(cx, |active, cx| {
1669                        if !is_generating {
1670                            active.thread_retry_status.take();
1671                            active.clear_auto_expand_tracking(cx);
1672                            if active.list_state.is_following_tail() {
1673                                active.list_state.scroll_to_end();
1674                            }
1675                        }
1676                        active.sync_generating_indicator(cx);
1677                    });
1678                }
1679                if is_subagent {
1680                    if *stop_reason == acp::StopReason::EndTurn {
1681                        thread.update(cx, |thread, cx| {
1682                            thread.mark_as_subagent_output(cx);
1683                        });
1684                    }
1685                    return;
1686                }
1687
1688                let sent_queued_message = if let Some(active) = self.root_thread_view() {
1689                    active.update(cx, |active, cx| {
1690                        // Don't auto-send while the user is editing the next message.
1691                        let is_first_editor_focused = active
1692                            .message_queue
1693                            .first()
1694                            .is_some_and(|entry| entry.editor.focus_handle(cx).is_focused(window));
1695                        if let Some(entry) = active
1696                            .message_queue
1697                            .on_generation_stopped(is_first_editor_focused)
1698                        {
1699                            active.dispatch_queued_entry(entry, window, cx);
1700                            true
1701                        } else {
1702                            false
1703                        }
1704                    })
1705                } else {
1706                    false
1707                };
1708
1709                // Skip notifying when a queued message was just auto-sent: the agent
1710                // is not actually idle and a notification here would fire just before the
1711                // next turn starts.
1712                if !sent_queued_message {
1713                    let used_tools = thread.read(cx).used_tools_since_last_user_message();
1714                    self.notify_with_sound(
1715                        if used_tools {
1716                            "Finished running tools"
1717                        } else {
1718                            "New message"
1719                        },
1720                        IconName::OmegaAssistant,
1721                        window,
1722                        cx,
1723                    );
1724                }
1725            }
1726            AcpThreadEvent::Refusal => {
1727                let error = ThreadError::Refusal;
1728                if let Some(active) = self.thread_view(&session_id) {
1729                    active.update(cx, |active, cx| {
1730                        active.handle_thread_error(error, cx);
1731                        active.thread_retry_status.take();
1732                    });
1733                }
1734                if !is_subagent {
1735                    let model_or_agent_name = self.current_model_name(cx);
1736                    let notification_message =
1737                        format!("{} refused to respond to this request", model_or_agent_name);
1738                    self.notify_with_sound(&notification_message, IconName::Warning, window, cx);
1739                }
1740            }
1741            AcpThreadEvent::Error => {
1742                if let Some(active) = self.thread_view(&session_id) {
1743                    let is_generating =
1744                        matches!(thread.read(cx).status(), ThreadStatus::Generating);
1745                    active.update(cx, |active, cx| {
1746                        if !is_generating {
1747                            active.thread_retry_status.take();
1748                            if active.list_state.is_following_tail() {
1749                                active.list_state.scroll_to_end();
1750                            }
1751                        }
1752                        active.sync_generating_indicator(cx);
1753                    });
1754                }
1755                if !is_subagent {
1756                    self.notify_with_sound(
1757                        "Agent stopped due to an error",
1758                        IconName::Warning,
1759                        window,
1760                        cx,
1761                    );
1762                }
1763            }
1764            AcpThreadEvent::LoadError(error) => {
1765                if let Some(view) = self.root_thread_view() {
1766                    if view
1767                        .read(cx)
1768                        .message_editor
1769                        .focus_handle(cx)
1770                        .is_focused(window)
1771                    {
1772                        self.focus_handle.focus(window, cx)
1773                    }
1774                }
1775                self.set_server_state(
1776                    ServerState::LoadError {
1777                        error: error.clone(),
1778                    },
1779                    cx,
1780                );
1781            }
1782            AcpThreadEvent::TitleUpdated => {
1783                let override_title = ThreadMetadataStore::try_global(cx).and_then(|store| {
1784                    store
1785                        .read(cx)
1786                        .entry(self.thread_id)
1787                        .and_then(|m| m.title_override.clone())
1788                });
1789                let title = override_title.or_else(|| thread.read(cx).title());
1790                if let Some(title) = title
1791                    && let Some(active_thread) = self.thread_view(&session_id)
1792                {
1793                    let title_editor = active_thread.read(cx).title_editor.clone();
1794                    title_editor.update(cx, |editor, cx| {
1795                        if editor.text(cx) != title {
1796                            editor.set_text(title, window, cx);
1797                        }
1798                    });
1799                }
1800                cx.notify();
1801            }
1802            AcpThreadEvent::PromptCapabilitiesUpdated => {
1803                if let Some(active) = self.thread_view(&session_id) {
1804                    active.update(cx, |active, _cx| {
1805                        active
1806                            .session_capabilities
1807                            .write()
1808                            .set_prompt_capabilities(thread.read(_cx).prompt_capabilities());
1809                    });
1810                }
1811            }
1812            AcpThreadEvent::TokenUsageUpdated => {
1813                if let Some(active) = self.thread_view(&session_id) {
1814                    active.update(cx, |active, cx| {
1815                        active.update_turn_tokens(cx);
1816                    });
1817                }
1818            }
1819            AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
1820                if let Some(thread_view) = self.thread_view(&session_id) {
1821                    let available_skills = thread
1822                        .read(cx)
1823                        .connection()
1824                        .clone()
1825                        .downcast::<agent::NativeAgentConnection>()
1826                        .map(|native_connection| {
1827                            native_available_skills(&native_connection, &session_id, cx)
1828                        })
1829                        .unwrap_or_default();
1830                    let has_slash_completions =
1831                        !available_commands.is_empty() || !available_skills.is_empty();
1832
1833                    let agent_display_name = self
1834                        .agent_server_store
1835                        .read(cx)
1836                        .agent_display_name(&self.agent.agent_id())
1837                        .unwrap_or_else(|| self.agent.agent_id().0.to_string().into());
1838
1839                    let new_placeholder =
1840                        placeholder_text(agent_display_name.as_ref(), has_slash_completions);
1841
1842                    thread_view.update(cx, |thread_view, cx| {
1843                        let mut session_capabilities = thread_view.session_capabilities.write();
1844                        session_capabilities.set_available_commands(available_commands.clone());
1845                        session_capabilities.set_available_skills(available_skills);
1846                        thread_view.message_editor.update(cx, |editor, cx| {
1847                            editor.set_placeholder_text(&new_placeholder, window, cx);
1848                        });
1849                    });
1850                }
1851            }
1852            AcpThreadEvent::ModeUpdated(_mode) => {
1853                // The connection keeps track of the mode
1854                cx.notify();
1855            }
1856            AcpThreadEvent::ConfigOptionsUpdated(_) => {
1857                // The watch task in ConfigOptionsView handles rebuilding selectors
1858                cx.notify();
1859            }
1860            AcpThreadEvent::WorkingDirectoriesUpdated => {
1861                cx.notify();
1862            }
1863            AcpThreadEvent::PromptUpdated => {
1864                if !is_subagent && thread.read(cx).is_draft_thread() {
1865                    self.schedule_draft_prompt_persist(cx);
1866                }
1867                cx.notify();
1868            }
1869        }
1870        cx.notify();
1871    }
1872
1873    fn schedule_draft_prompt_persist(&mut self, cx: &mut Context<Self>) {
1874        let thread_id = self.thread_id;
1875        self.draft_prompt_persist_task = Some(cx.spawn(async move |this, cx| {
1876            cx.background_executor()
1877                .timer(DRAFT_PROMPT_PERSIST_DEBOUNCE)
1878                .await;
1879            let persist = this.update(cx, |this, cx| {
1880                let thread = this.root_thread(cx)?;
1881                let thread = thread.read(cx);
1882                if !thread.is_draft_thread() {
1883                    return None;
1884                }
1885                let snapshot: Vec<acp::ContentBlock> = thread
1886                    .draft_prompt()
1887                    .map(|p| p.to_vec())
1888                    .unwrap_or_default();
1889                Some(if snapshot.is_empty() {
1890                    crate::draft_prompt_store::delete(thread_id, cx)
1891                } else {
1892                    crate::draft_prompt_store::write(thread_id, &snapshot, cx)
1893                })
1894            });
1895            if let Ok(Some(persist)) = persist {
1896                persist.await.log_err();
1897            }
1898        }));
1899    }
1900
1901    fn authenticate(
1902        &mut self,
1903        method: acp::AuthMethodId,
1904        window: &mut Window,
1905        cx: &mut Context<Self>,
1906    ) {
1907        let Some(workspace) = self.workspace.upgrade() else {
1908            return;
1909        };
1910        let Some(connected) = self.as_connected_mut() else {
1911            return;
1912        };
1913        let connection = connected.connection.clone();
1914
1915        let AuthState::Unauthenticated {
1916            pending_auth_method,
1917            ..
1918        } = &mut connected.auth_state
1919        else {
1920            return;
1921        };
1922
1923        let agent_telemetry_id = connection.telemetry_id();
1924
1925        if let Some(login_task) = connection.terminal_auth_task(&method, cx) {
1926            pending_auth_method.replace(method.clone());
1927
1928            let project = self.project.clone();
1929            cx.emit(StateChange);
1930            cx.notify();
1931            self.auth_task = Some(cx.spawn_in(window, {
1932                async move |this, cx| {
1933                    let result = async {
1934                        let login = login_task.await?;
1935                        this.update_in(cx, |_this, window, cx| {
1936                            Self::spawn_external_agent_login(
1937                                login,
1938                                workspace,
1939                                project,
1940                                method.clone(),
1941                                false,
1942                                window,
1943                                cx,
1944                            )
1945                        })?
1946                        .await
1947                    }
1948                    .await;
1949
1950                    match &result {
1951                        Ok(_) => telemetry::event!(
1952                            "Authenticate Agent Succeeded",
1953                            agent = agent_telemetry_id
1954                        ),
1955                        Err(_) => {
1956                            telemetry::event!(
1957                                "Authenticate Agent Failed",
1958                                agent = agent_telemetry_id,
1959                            )
1960                        }
1961                    }
1962
1963                    this.update_in(cx, |this, window, cx| {
1964                        if let Err(err) = result {
1965                            this.cancel_request_elicitations(cx);
1966                            if let Some(ConnectedServerState {
1967                                auth_state:
1968                                    AuthState::Unauthenticated {
1969                                        pending_auth_method,
1970                                        ..
1971                                    },
1972                                ..
1973                            }) = this.as_connected_mut()
1974                            {
1975                                pending_auth_method.take();
1976                                cx.emit(StateChange);
1977                            }
1978                            if let Some(active) = this.root_thread_view() {
1979                                active.update(cx, |active, cx| {
1980                                    active.handle_thread_error(err, cx);
1981                                })
1982                            }
1983                        } else {
1984                            this.reset(window, cx);
1985                        }
1986                        this.auth_task.take()
1987                    })
1988                    .ok();
1989                }
1990            }));
1991            return;
1992        }
1993
1994        pending_auth_method.replace(method.clone());
1995
1996        let authenticate = connection.authenticate(method, cx);
1997        cx.emit(StateChange);
1998        cx.notify();
1999        self.auth_task = Some(cx.spawn_in(window, {
2000            async move |this, cx| {
2001                let result = authenticate.await;
2002
2003                match &result {
2004                    Ok(_) => telemetry::event!(
2005                        "Authenticate Agent Succeeded",
2006                        agent = agent_telemetry_id
2007                    ),
2008                    Err(_) => {
2009                        telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,)
2010                    }
2011                }
2012
2013                this.update_in(cx, |this, window, cx| {
2014                    if let Err(err) = result {
2015                        this.cancel_request_elicitations(cx);
2016                        if let Some(ConnectedServerState {
2017                            auth_state:
2018                                AuthState::Unauthenticated {
2019                                    pending_auth_method,
2020                                    ..
2021                                },
2022                            ..
2023                        }) = this.as_connected_mut()
2024                        {
2025                            pending_auth_method.take();
2026                            cx.emit(StateChange);
2027                        }
2028                        if let Some(active) = this.root_thread_view() {
2029                            active.update(cx, |active, cx| active.handle_thread_error(err, cx));
2030                        }
2031                    } else {
2032                        this.reset(window, cx);
2033                    }
2034                    this.auth_task.take()
2035                })
2036                .ok();
2037            }
2038        }));
2039    }
2040
2041    fn load_subagent_session(
2042        &mut self,
2043        subagent_id: acp::SessionId,
2044        parent_session_id: acp::SessionId,
2045        window: &mut Window,
2046        cx: &mut Context<Self>,
2047    ) {
2048        let Some(connected) = self.as_connected() else {
2049            return;
2050        };
2051        if connected.threads.contains_key(&subagent_id)
2052            || !connected.connection.supports_load_session()
2053        {
2054            return;
2055        }
2056        let Some(parent_thread) = connected.threads.get(&parent_session_id) else {
2057            return;
2058        };
2059        let work_dirs = parent_thread
2060            .read(cx)
2061            .thread
2062            .read(cx)
2063            .work_dirs()
2064            .cloned()
2065            .unwrap_or_else(|| self.project.read(cx).default_path_list(cx));
2066
2067        let subagent_thread_task = connected.connection.clone().load_session(
2068            subagent_id,
2069            self.project.clone(),
2070            work_dirs,
2071            None,
2072            cx,
2073        );
2074
2075        cx.spawn_in(window, async move |this, cx| {
2076            let subagent_thread = subagent_thread_task.await?;
2077            this.update_in(cx, |this, window, cx| {
2078                let Some(conversation) = this
2079                    .as_connected()
2080                    .map(|connected| connected.conversation.clone())
2081                else {
2082                    return;
2083                };
2084                let subagent_session_id = subagent_thread.read(cx).session_id().clone();
2085                conversation.update(cx, |conversation, cx| {
2086                    conversation.register_thread(subagent_thread.clone(), cx);
2087                });
2088                let view =
2089                    this.new_thread_view(subagent_thread, conversation, false, None, window, cx);
2090                let Some(connected) = this.as_connected_mut() else {
2091                    return;
2092                };
2093                connected.threads.insert(subagent_session_id, view);
2094            })
2095        })
2096        .detach();
2097    }
2098
2099    fn spawn_external_agent_login(
2100        login: task::SpawnInTerminal,
2101        workspace: Entity<Workspace>,
2102        project: Entity<Project>,
2103        method: acp::AuthMethodId,
2104        previous_attempt: bool,
2105        window: &mut Window,
2106        cx: &mut App,
2107    ) -> Task<Result<()>> {
2108        let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
2109            return Task::ready(Err(anyhow!("Terminal panel is unavailable")));
2110        };
2111
2112        window.spawn(cx, async move |cx| {
2113            let mut task = login.clone();
2114            if let Some(cmd) = &task.command {
2115                // Have "node" command use Zed's managed Node runtime by default
2116                if cmd == "node" {
2117                    let resolved_node_runtime = project.update(cx, |project, cx| {
2118                        let agent_server_store = project.agent_server_store().clone();
2119                        agent_server_store.update(cx, |store, cx| {
2120                            store.node_runtime().map(|node_runtime| {
2121                                cx.background_spawn(async move { node_runtime.binary_path().await })
2122                            })
2123                        })
2124                    });
2125
2126                    if let Some(resolve_task) = resolved_node_runtime {
2127                        if let Ok(node_path) = resolve_task.await {
2128                            task.command = Some(node_path.to_string_lossy().to_string());
2129                        }
2130                    }
2131                }
2132            }
2133            task.shell = task::Shell::WithArguments {
2134                program: task.command.take().expect("login command should be set"),
2135                args: std::mem::take(&mut task.args),
2136                title_override: None,
2137            };
2138
2139            let terminal = terminal_panel
2140                .update_in(cx, |terminal_panel, window, cx| {
2141                    terminal_panel.spawn_task(&task, window, cx)
2142                })?
2143                .await?;
2144
2145            let success_patterns = match method.0.as_ref() {
2146                "claude-login" | GEMINI_TERMINAL_AUTH_METHOD_ID => vec![
2147                    "Login successful".to_string(),
2148                    "Type your message".to_string(),
2149                ],
2150                _ => Vec::new(),
2151            };
2152            if success_patterns.is_empty() {
2153                // No success patterns specified: wait for the process to exit and check exit code
2154                let exit_status = terminal
2155                    .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
2156                    .await;
2157
2158                match exit_status {
2159                    Some(status) if status.success() => Ok(()),
2160                    Some(status) => Err(anyhow!(
2161                        "Login command failed with exit code: {:?}",
2162                        status.code()
2163                    )),
2164                    None => Err(anyhow!("Login command terminated without exit status")),
2165                }
2166            } else {
2167                // Look for specific output patterns to detect successful login
2168                let mut exit_status = terminal
2169                    .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
2170                    .fuse();
2171
2172                let logged_in = cx
2173                    .spawn({
2174                        let terminal = terminal.clone();
2175                        async move |cx| {
2176                            loop {
2177                                cx.background_executor().timer(Duration::from_secs(1)).await;
2178                                let content =
2179                                    terminal.update(cx, |terminal, _cx| terminal.get_content())?;
2180                                if success_patterns
2181                                    .iter()
2182                                    .any(|pattern| content.contains(pattern))
2183                                {
2184                                    return anyhow::Ok(());
2185                                }
2186                            }
2187                        }
2188                    })
2189                    .fuse();
2190                futures::pin_mut!(logged_in);
2191                futures::select_biased! {
2192                    result = logged_in => {
2193                        if let Err(e) = result {
2194                            log::error!("{e}");
2195                            return Err(anyhow!("exited before logging in"));
2196                        }
2197                    }
2198                    _ = exit_status => {
2199                        if !previous_attempt
2200                            && project.read_with(cx, |project, _| project.is_via_remote_server())
2201                            && method.0.as_ref() == GEMINI_TERMINAL_AUTH_METHOD_ID
2202                        {
2203                            return cx
2204                                .update(|window, cx| {
2205                                    Self::spawn_external_agent_login(
2206                                        login,
2207                                        workspace,
2208                                        project.clone(),
2209                                        method,
2210                                        true,
2211                                        window,
2212                                        cx,
2213                                    )
2214                                })?
2215                                .await;
2216                        }
2217                        return Err(anyhow!("exited before logging in"));
2218                    }
2219                }
2220                terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
2221                Ok(())
2222            }
2223        })
2224    }
2225
2226    pub fn has_user_submitted_prompt(&self, cx: &App) -> bool {
2227        self.root_thread_view().is_some_and(|active| {
2228            active
2229                .read(cx)
2230                .thread
2231                .read(cx)
2232                .entries()
2233                .iter()
2234                .any(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
2235        })
2236    }
2237
2238    fn render_auth_required_state(
2239        &self,
2240        connection: &Rc<dyn AgentConnection>,
2241        description: Option<&Entity<Markdown>>,
2242        pending_auth_method: Option<&acp::AuthMethodId>,
2243        window: &mut Window,
2244        cx: &Context<Self>,
2245    ) -> impl IntoElement {
2246        let auth_methods = connection.auth_methods();
2247
2248        let agent_display_name = self
2249            .agent_server_store
2250            .read(cx)
2251            .agent_display_name(&self.agent.agent_id())
2252            .unwrap_or_else(|| self.agent.agent_id().0);
2253
2254        let show_fallback_description =
2255            auth_methods.len() > 1 && description.is_none() && pending_auth_method.is_none();
2256
2257        let auth_buttons = || {
2258            h_flex().justify_end().flex_wrap().gap_1().children(
2259                connection
2260                    .auth_methods()
2261                    .iter()
2262                    .enumerate()
2263                    .rev()
2264                    .map(|(ix, method)| {
2265                        let (method_id, name) = (method.id().0.clone(), method.name().to_string());
2266                        let agent_telemetry_id = connection.telemetry_id();
2267
2268                        Button::new(method_id.clone(), name)
2269                            .label_size(LabelSize::Small)
2270                            .map(|this| {
2271                                if ix == 0 {
2272                                    this.style(ButtonStyle::Tinted(TintColor::Accent))
2273                                } else {
2274                                    this.style(ButtonStyle::Outlined)
2275                                }
2276                            })
2277                            .when_some(method.description(), |this, description| {
2278                                this.tooltip(Tooltip::text(description.to_string()))
2279                            })
2280                            .on_click({
2281                                cx.listener(move |this, _, window, cx| {
2282                                    telemetry::event!(
2283                                        "Authenticate Agent Started",
2284                                        agent = agent_telemetry_id,
2285                                        method = method_id
2286                                    );
2287
2288                                    this.authenticate(
2289                                        acp::AuthMethodId::new(method_id.clone()),
2290                                        window,
2291                                        cx,
2292                                    )
2293                                })
2294                            })
2295                    }),
2296            )
2297        };
2298
2299        if pending_auth_method.is_some() {
2300            return Callout::new()
2301                .icon(IconName::Info)
2302                .title(format!("Authenticating to {}…", agent_display_name))
2303                .actions_slot(
2304                    Icon::new(IconName::ArrowCircle)
2305                        .size(IconSize::Small)
2306                        .color(Color::Muted)
2307                        .with_rotate_animation(2)
2308                        .into_any_element(),
2309                )
2310                .into_any_element();
2311        }
2312
2313        Callout::new()
2314            .icon(IconName::Info)
2315            .title(format!("Authenticate to {}", agent_display_name))
2316            .when(auth_methods.len() == 1, |this| {
2317                this.actions_slot(auth_buttons())
2318            })
2319            .description_slot(
2320                v_flex()
2321                    .text_ui(cx)
2322                    .map(|this| {
2323                        if show_fallback_description {
2324                            this.child(
2325                                Label::new("Choose one of the following authentication options:")
2326                                    .size(LabelSize::Small)
2327                                    .color(Color::Muted),
2328                            )
2329                        } else {
2330                            this.children(description.map(|desc| {
2331                                self.render_markdown(
2332                                    desc.clone(),
2333                                    MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
2334                                    cx,
2335                                )
2336                            }))
2337                        }
2338                    })
2339                    .when(auth_methods.len() > 1, |this| {
2340                        this.gap_1().child(auth_buttons())
2341                    }),
2342            )
2343            .into_any_element()
2344    }
2345
2346    fn sync_request_elicitation_states(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2347        let Some(store) = self.request_elicitation_store() else {
2348            self.request_elicitation_form_states.clear();
2349            return;
2350        };
2351
2352        let elicitations = store
2353            .read(cx)
2354            .elicitations()
2355            .iter()
2356            .map(|elicitation| {
2357                let is_pending = matches!(elicitation.status, ElicitationStatus::Pending { .. });
2358                let schema = match &elicitation.request.mode {
2359                    acp::ElicitationMode::Form(mode) => Some(mode.requested_schema.clone()),
2360                    _ => None,
2361                };
2362                (elicitation.id.clone(), is_pending, schema)
2363            })
2364            .collect::<Vec<_>>();
2365
2366        let known_ids = elicitations
2367            .iter()
2368            .map(|(id, _, _)| id.clone())
2369            .collect::<HashSet<_>>();
2370        self.request_elicitation_form_states
2371            .retain(|id, _| known_ids.contains(id));
2372
2373        for (id, is_pending, schema) in elicitations {
2374            if is_pending
2375                && let Some(schema) = schema
2376                && !self.request_elicitation_form_states.contains_key(&id)
2377            {
2378                self.request_elicitation_form_states
2379                    .insert(id, ElicitationFormState::new(&schema, window, cx));
2380            } else if !is_pending {
2381                self.request_elicitation_form_states.remove(&id);
2382            }
2383        }
2384    }
2385
2386    fn render_request_elicitations(
2387        &self,
2388        connection: &Rc<dyn AgentConnection>,
2389        view: WeakEntity<Self>,
2390        cx: &App,
2391    ) -> Vec<AnyElement> {
2392        let Some(store) = connection.request_elicitations() else {
2393            return Vec::new();
2394        };
2395
2396        let handlers = Self::request_elicitation_card_handlers(view);
2397
2398        store
2399            .read(cx)
2400            .elicitations()
2401            .iter()
2402            .enumerate()
2403            .filter(|(_, elicitation)| should_render_elicitation(elicitation))
2404            .map(|(ix, elicitation)| {
2405                ElicitationCard::new(
2406                    ix,
2407                    elicitation,
2408                    self.request_elicitation_form_states.get(&elicitation.id),
2409                    handlers.clone(),
2410                )
2411                .render(cx)
2412                .into_any_element()
2413            })
2414            .collect()
2415    }
2416
2417    fn request_elicitation_card_handlers(view: WeakEntity<Self>) -> ElicitationCardHandlers {
2418        ElicitationCardHandlers::new(
2419            {
2420                let view = view.clone();
2421                move |elicitation_id, window, cx| {
2422                    view.update(cx, |this, cx| {
2423                        this.submit_request_elicitation(elicitation_id, window, cx);
2424                    })
2425                    .log_err();
2426                }
2427            },
2428            {
2429                let view = view.clone();
2430                move |elicitation_id, window, cx| {
2431                    view.update(cx, |this, cx| {
2432                        this.decline_request_elicitation(elicitation_id, window, cx);
2433                    })
2434                    .log_err();
2435                }
2436            },
2437            {
2438                let view = view.clone();
2439                move |elicitation_id, window, cx| {
2440                    view.update(cx, |this, cx| {
2441                        this.cancel_request_elicitation(elicitation_id, window, cx);
2442                    })
2443                    .log_err();
2444                }
2445            },
2446            {
2447                let view = view.clone();
2448                move |elicitation_id, url, window, cx| {
2449                    cx.open_url(&url);
2450                    view.update(cx, |this, cx| {
2451                        this.submit_request_elicitation(elicitation_id, window, cx);
2452                    })
2453                    .log_err();
2454                }
2455            },
2456            {
2457                let view = view.clone();
2458                move |elicitation_id, field_name, value, cx| {
2459                    view.update(cx, |this, cx| {
2460                        this.update_request_elicitation_form_state(
2461                            &elicitation_id,
2462                            |form| form.set_boolean(&field_name, value),
2463                            cx,
2464                        );
2465                    })
2466                    .log_err();
2467                }
2468            },
2469            {
2470                let view = view.clone();
2471                move |elicitation_id, field_name, value, cx| {
2472                    view.update(cx, |this, cx| {
2473                        this.update_request_elicitation_form_state(
2474                            &elicitation_id,
2475                            |form| form.set_single_select(&field_name, value),
2476                            cx,
2477                        );
2478                    })
2479                    .log_err();
2480                }
2481            },
2482            move |elicitation_id, field_name, value, selected, cx| {
2483                view.update(cx, |this, cx| {
2484                    this.update_request_elicitation_form_state(
2485                        &elicitation_id,
2486                        |form| form.set_multi_select(&field_name, value, selected),
2487                        cx,
2488                    );
2489                })
2490                .log_err();
2491            },
2492        )
2493    }
2494
2495    fn update_request_elicitation_form_state(
2496        &mut self,
2497        elicitation_id: &ElicitationEntryId,
2498        update: impl FnOnce(&mut ElicitationFormState),
2499        cx: &mut Context<Self>,
2500    ) {
2501        if let Some(form) = self.request_elicitation_form_states.get_mut(elicitation_id) {
2502            update(form);
2503            self.notify_request_elicitation_renderers(cx);
2504        }
2505    }
2506
2507    fn notify_request_elicitation_renderers(&self, cx: &mut Context<Self>) {
2508        if let Some(active_thread) = self.active_thread().cloned() {
2509            active_thread.update(cx, |_thread, cx| cx.notify());
2510        }
2511        cx.notify();
2512    }
2513
2514    fn submit_request_elicitation(
2515        &mut self,
2516        elicitation_id: ElicitationEntryId,
2517        _window: &mut Window,
2518        cx: &mut Context<Self>,
2519    ) {
2520        let Some(store) = self.request_elicitation_store() else {
2521            return;
2522        };
2523
2524        let mode = store
2525            .read(cx)
2526            .elicitation(&elicitation_id)
2527            .map(|(_, elicitation)| elicitation.request.mode.clone());
2528        let Some(mode) = mode else {
2529            return;
2530        };
2531
2532        let response = match mode {
2533            acp::ElicitationMode::Form(mode) => {
2534                let Some(state) = self.request_elicitation_form_states.get(&elicitation_id) else {
2535                    return;
2536                };
2537                match state.collect(&mode.requested_schema, cx) {
2538                    Ok(content) => {
2539                        acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
2540                            acp::ElicitationAcceptAction::new().content(content),
2541                        ))
2542                    }
2543                    Err(errors) => {
2544                        self.update_request_elicitation_form_state(
2545                            &elicitation_id,
2546                            |state| state.set_errors(errors),
2547                            cx,
2548                        );
2549                        return;
2550                    }
2551                }
2552            }
2553            acp::ElicitationMode::Url(_) => acp::CreateElicitationResponse::new(
2554                acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()),
2555            ),
2556            _ => return,
2557        };
2558
2559        self.respond_to_request_elicitation(elicitation_id, response, cx);
2560    }
2561
2562    fn decline_request_elicitation(
2563        &mut self,
2564        elicitation_id: ElicitationEntryId,
2565        _window: &mut Window,
2566        cx: &mut Context<Self>,
2567    ) {
2568        self.respond_to_request_elicitation(
2569            elicitation_id,
2570            acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
2571            cx,
2572        );
2573    }
2574
2575    fn cancel_request_elicitation(
2576        &mut self,
2577        elicitation_id: ElicitationEntryId,
2578        _window: &mut Window,
2579        cx: &mut Context<Self>,
2580    ) {
2581        self.respond_to_request_elicitation(
2582            elicitation_id,
2583            acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel),
2584            cx,
2585        );
2586    }
2587
2588    fn respond_to_request_elicitation(
2589        &mut self,
2590        elicitation_id: ElicitationEntryId,
2591        response: acp::CreateElicitationResponse,
2592        cx: &mut Context<Self>,
2593    ) {
2594        self.request_elicitation_form_states.remove(&elicitation_id);
2595        if let Some(store) = self.request_elicitation_store() {
2596            store.update(cx, |store, cx| {
2597                store.respond_to_elicitation(&elicitation_id, response, cx);
2598            });
2599        }
2600        cx.notify();
2601    }
2602
2603    fn cancel_request_elicitations(&mut self, cx: &mut App) {
2604        self.request_elicitation_form_states.clear();
2605        if let Some(store) = self.request_elicitation_store() {
2606            store.update(cx, |store, cx| store.clear(cx));
2607        }
2608    }
2609
2610    fn clear_resolved_request_elicitations(&mut self, cx: &mut App) {
2611        if let Some(connection) = self.request_elicitation_connection() {
2612            self.clear_resolved_request_elicitations_for_connection(&connection, cx);
2613        }
2614    }
2615
2616    fn clear_resolved_request_elicitations_for_connection(
2617        &mut self,
2618        connection: &Rc<dyn AgentConnection>,
2619        cx: &mut App,
2620    ) {
2621        let Some(store) = connection.request_elicitations() else {
2622            return;
2623        };
2624        let cleared_ids = store.update(cx, |store, cx| store.clear_resolved(cx));
2625        for id in cleared_ids {
2626            self.request_elicitation_form_states.remove(&id);
2627        }
2628    }
2629
2630    fn emit_load_error_telemetry(&self, error: &LoadError) {
2631        let error_kind = match error {
2632            LoadError::Unsupported { .. } => "unsupported",
2633            LoadError::FailedToInstall(_) => "failed_to_install",
2634            LoadError::Exited { .. } => "exited",
2635            LoadError::SessionGone => "session_gone",
2636            LoadError::Other(_) => "other",
2637        };
2638
2639        let agent_name = self.agent.agent_id();
2640
2641        telemetry::event!(
2642            "Agent Panel Error Shown",
2643            agent = agent_name,
2644            kind = error_kind,
2645            message = error.to_string(),
2646        );
2647    }
2648
2649    fn render_load_error(
2650        &self,
2651        e: &LoadError,
2652        window: &mut Window,
2653        cx: &mut Context<Self>,
2654    ) -> AnyElement {
2655        let (title, message, action_slot): (_, SharedString, _) = match e {
2656            LoadError::Unsupported {
2657                command: path,
2658                current_version,
2659                minimum_version,
2660            } => {
2661                return self.render_unsupported(path, current_version, minimum_version, window, cx);
2662            }
2663            LoadError::FailedToInstall(msg) => (
2664                "Failed to Install",
2665                msg.into(),
2666                Some(self.create_copy_button(msg.to_string()).into_any_element()),
2667            ),
2668            LoadError::Exited { status, stderr } => {
2669                let mut message = format!("Server exited with status {status}");
2670                if let Some(stderr) = stderr {
2671                    message.push_str("\n");
2672                    message.push_str(stderr);
2673                };
2674                let action_slot = stderr
2675                    .is_some()
2676                    .then(|| self.create_copy_button(message.clone()).into_any_element());
2677                ("Failed to Launch", message.into(), action_slot)
2678            }
2679            // Nothing failed to launch. The agent started fine and does not
2680            // have this conversation any more, which is ordinary once a thread
2681            // outlives the agent's own session store. Say that, and give the
2682            // reader the one action that moves them forward, instead of a
2683            // launch failure carrying a session id they cannot use.
2684            LoadError::SessionGone => {
2685                return Callout::new()
2686                    .severity(Severity::Warning)
2687                    .icon(IconName::Info)
2688                    .title("Conversation No Longer Available")
2689                    .description(
2690                        "The agent no longer has this conversation. Its history is still here to \
2691                         read, and a new thread will pick up where you left off.",
2692                    )
2693                    .actions_slot(
2694                        Button::new("session-gone-new-thread", "New Thread")
2695                            .on_click(|_, window, cx| {
2696                                window.dispatch_action(NewThread.boxed_clone(), cx)
2697                            })
2698                            .into_any_element(),
2699                    )
2700                    .into_any_element();
2701            }
2702            LoadError::Other(msg) => (
2703                "Failed to Launch",
2704                msg.into(),
2705                Some(self.create_copy_button(msg.to_string()).into_any_element()),
2706            ),
2707        };
2708
2709        Callout::new()
2710            .severity(Severity::Error)
2711            .icon(IconName::XCircleFilled)
2712            .title(title)
2713            .description(message)
2714            .actions_slot(div().children(action_slot))
2715            .into_any_element()
2716    }
2717
2718    fn render_unsupported(
2719        &self,
2720        path: &SharedString,
2721        version: &SharedString,
2722        minimum_version: &SharedString,
2723        _window: &mut Window,
2724        cx: &mut Context<Self>,
2725    ) -> AnyElement {
2726        let (heading_label, description_label) = (
2727            format!("Upgrade {} to work with Omega", self.agent.agent_id()),
2728            if version.is_empty() {
2729                format!(
2730                    "Currently using {}, which does not report a valid --version",
2731                    path,
2732                )
2733            } else {
2734                format!(
2735                    "Currently using {}, which is only version {} (need at least {minimum_version})",
2736                    path, version
2737                )
2738            },
2739        );
2740
2741        v_flex()
2742            .w_full()
2743            .p_3p5()
2744            .gap_2p5()
2745            .border_t_1()
2746            .border_color(cx.theme().colors().border)
2747            .bg(linear_gradient(
2748                180.,
2749                linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.),
2750                linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.),
2751            ))
2752            .child(
2753                v_flex().gap_0p5().child(Label::new(heading_label)).child(
2754                    Label::new(description_label)
2755                        .size(LabelSize::Small)
2756                        .color(Color::Muted),
2757                ),
2758            )
2759            .into_any_element()
2760    }
2761
2762    pub(crate) fn as_native_connection(
2763        &self,
2764        cx: &App,
2765    ) -> Option<Rc<agent::NativeAgentConnection>> {
2766        self.root_thread(cx)?
2767            .read(cx)
2768            .connection()
2769            .clone()
2770            .downcast()
2771    }
2772
2773    pub fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
2774        self.as_native_connection(cx)?
2775            .thread(self.root_session_id.as_ref()?, cx)
2776    }
2777
2778    fn render_markdown(
2779        &self,
2780        markdown: Entity<Markdown>,
2781        style: MarkdownStyle,
2782        cx: &App,
2783    ) -> MarkdownElement {
2784        render_agent_markdown(
2785            markdown,
2786            style,
2787            &self.workspace,
2788            &self.code_span_resolver,
2789            cx,
2790        )
2791    }
2792
2793    fn notify_with_sound(
2794        &mut self,
2795        caption: impl Into<SharedString>,
2796        icon: IconName,
2797        window: &mut Window,
2798        cx: &mut Context<Self>,
2799    ) {
2800        #[cfg(feature = "audio")]
2801        self.play_notification_sound(window, cx);
2802        self.show_notification(caption, icon, window, cx);
2803    }
2804
2805    fn is_visible(&self, multi_workspace: &Entity<MultiWorkspace>, cx: &Context<Self>) -> bool {
2806        let Some(workspace) = self.workspace.upgrade() else {
2807            return false;
2808        };
2809
2810        let multi_workspace = multi_workspace.read(cx);
2811        multi_workspace.sidebar_open() && multi_workspace.is_threads_list_view_active(cx)
2812            || multi_workspace.workspace() == &workspace
2813                && self.is_visible_in_agent_panel(&workspace, cx)
2814    }
2815
2816    fn is_visible_in_agent_panel(&self, workspace: &Entity<Workspace>, cx: &Context<Self>) -> bool {
2817        AgentPanel::is_visible(workspace, cx)
2818            && workspace
2819                .read(cx)
2820                .panel::<AgentPanel>(cx)
2821                .is_some_and(|panel| {
2822                    panel
2823                        .read(cx)
2824                        .visible_conversation_view()
2825                        .map(|conversation_view| conversation_view.entity_id())
2826                        == Some(cx.entity_id())
2827                })
2828    }
2829
2830    fn agent_status_visible(&self, window: &Window, cx: &Context<Self>) -> bool {
2831        if !window.is_window_active() {
2832            return false;
2833        }
2834
2835        if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
2836            self.is_visible(&multi_workspace, cx)
2837        } else {
2838            self.workspace
2839                .upgrade()
2840                .is_some_and(|workspace| self.is_visible_in_agent_panel(&workspace, cx))
2841        }
2842    }
2843
2844    #[cfg(feature = "audio")]
2845    fn play_notification_sound(&self, window: &Window, cx: &mut Context<Self>) {
2846        let visible = window.is_window_active()
2847            && if let Some(mw) = window.root::<MultiWorkspace>().flatten() {
2848                self.is_visible(&mw, cx)
2849            } else {
2850                self.workspace
2851                    .upgrade()
2852                    .is_some_and(|workspace| self.is_visible_in_agent_panel(&workspace, cx))
2853            };
2854        let settings = AgentSettings::get_global(cx);
2855        if settings.play_sound_when_agent_done.should_play(visible) {
2856            Audio::play_sound(Sound::AgentDone, cx);
2857        }
2858    }
2859
2860    fn show_notification(
2861        &mut self,
2862        caption: impl Into<SharedString>,
2863        icon: IconName,
2864        window: &mut Window,
2865        cx: &mut Context<Self>,
2866    ) {
2867        if !self.notifications.is_empty() {
2868            return;
2869        }
2870
2871        let settings = AgentSettings::get_global(cx);
2872
2873        let should_notify = !self.agent_status_visible(window, cx);
2874
2875        if !should_notify {
2876            return;
2877        }
2878
2879        let Some(root_thread) = self.root_thread_view() else {
2880            return;
2881        };
2882        let root_thread = root_thread.read(cx).thread.read(cx);
2883        let root_thread_id = self.thread_id;
2884        let root_work_dirs = root_thread.work_dirs().cloned();
2885        let root_title = root_thread.title();
2886
2887        let title = root_title
2888            .clone()
2889            .unwrap_or_else(|| self.agent.agent_id().0);
2890
2891        match settings.notify_when_agent_waiting {
2892            NotifyWhenAgentWaiting::PrimaryScreen => {
2893                window.request_attention();
2894                if let Some(primary) = cx.primary_display() {
2895                    self.pop_up(
2896                        icon,
2897                        caption.into(),
2898                        title,
2899                        root_thread_id,
2900                        root_work_dirs,
2901                        root_title,
2902                        window,
2903                        primary,
2904                        cx,
2905                    );
2906                }
2907            }
2908            NotifyWhenAgentWaiting::AllScreens => {
2909                window.request_attention();
2910                let caption = caption.into();
2911                for screen in cx.displays() {
2912                    self.pop_up(
2913                        icon,
2914                        caption.clone(),
2915                        title.clone(),
2916                        root_thread_id,
2917                        root_work_dirs.clone(),
2918                        root_title.clone(),
2919                        window,
2920                        screen,
2921                        cx,
2922                    );
2923                }
2924            }
2925            NotifyWhenAgentWaiting::Never => {
2926                // Don't show anything
2927            }
2928        }
2929    }
2930
2931    fn pop_up(
2932        &mut self,
2933        icon: IconName,
2934        caption: SharedString,
2935        title: SharedString,
2936        root_thread_id: ThreadId,
2937        root_work_dirs: Option<PathList>,
2938        root_title: Option<SharedString>,
2939        window: &mut Window,
2940        screen: Rc<dyn PlatformDisplay>,
2941        cx: &mut Context<Self>,
2942    ) {
2943        let options = AgentNotification::window_options(screen, cx);
2944
2945        let project_name = self.workspace.upgrade().and_then(|workspace| {
2946            workspace
2947                .read(cx)
2948                .project()
2949                .read(cx)
2950                .visible_worktrees(cx)
2951                .next()
2952                .map(|worktree| worktree.read(cx).root_name_str().to_string())
2953        });
2954
2955        if let Some(screen_window) = cx
2956            .open_window(options, |_window, cx| {
2957                cx.new(|_cx| {
2958                    AgentNotification::new(title.clone(), Some(caption.clone()), icon, project_name)
2959                })
2960            })
2961            .log_err()
2962            && let Some(pop_up) = screen_window.entity(cx).log_err()
2963        {
2964            self.notification_subscriptions
2965                .entry(screen_window)
2966                .or_insert_with(Vec::new)
2967                .push(cx.subscribe_in(&pop_up, window, {
2968                    move |this, _, event, window, cx| match event {
2969                        AgentNotificationEvent::Accepted => {
2970                            let Some(handle) = window.window_handle().downcast::<MultiWorkspace>()
2971                            else {
2972                                log::error!("root view should be a MultiWorkspace");
2973                                return;
2974                            };
2975                            cx.activate(true);
2976
2977                            let workspace_handle = this.workspace.clone();
2978                            let agent = this.connection_key.clone();
2979                            let root_work_dirs = root_work_dirs.clone();
2980                            let root_title = root_title.clone();
2981
2982                            cx.defer(move |cx| {
2983                                handle
2984                                    .update(cx, |multi_workspace, window, cx| {
2985                                        window.activate_window();
2986                                        if let Some(workspace) = workspace_handle.upgrade() {
2987                                            multi_workspace.activate(
2988                                                workspace.clone(),
2989                                                None,
2990                                                window,
2991                                                cx,
2992                                            );
2993                                            workspace.update(cx, |workspace, cx| {
2994                                                workspace.reveal_panel::<AgentPanel>(window, cx);
2995                                                if let Some(panel) =
2996                                                    workspace.panel::<AgentPanel>(cx)
2997                                                {
2998                                                    panel.update(cx, |panel, cx| {
2999                                                        panel.load_agent_thread(
3000                                                            agent.clone(),
3001                                                            root_thread_id,
3002                                                            root_work_dirs.clone(),
3003                                                            root_title.clone(),
3004                                                            true,
3005                                                            AgentThreadSource::AgentPanel,
3006                                                            window,
3007                                                            cx,
3008                                                        );
3009                                                    });
3010                                                }
3011                                                workspace.focus_panel::<AgentPanel>(window, cx);
3012                                            });
3013                                        }
3014                                    })
3015                                    .log_err();
3016                            });
3017
3018                            this.dismiss_notifications(cx);
3019                        }
3020                        AgentNotificationEvent::Dismissed => {
3021                            this.dismiss_notifications(cx);
3022                        }
3023                    }
3024                }));
3025
3026            self.notifications.push(screen_window);
3027
3028            let dismiss_if_visible = {
3029                let pop_up_weak = pop_up.downgrade();
3030                move |this: &ConversationView,
3031                      window: &mut Window,
3032                      cx: &mut Context<ConversationView>| {
3033                    if this.agent_status_visible(window, cx)
3034                        && let Some(pop_up) = pop_up_weak.upgrade()
3035                    {
3036                        pop_up.update(cx, |notification, cx| {
3037                            notification.dismiss(cx);
3038                        });
3039                    }
3040                }
3041            };
3042
3043            let subscriptions = self
3044                .notification_subscriptions
3045                .entry(screen_window)
3046                .or_insert_with(Vec::new);
3047
3048            subscriptions.push({
3049                let dismiss_if_visible = dismiss_if_visible.clone();
3050                cx.observe_window_activation(window, move |this, window, cx| {
3051                    dismiss_if_visible(this, window, cx);
3052                })
3053            });
3054
3055            if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
3056                let dismiss_if_visible = dismiss_if_visible.clone();
3057                subscriptions.push(cx.observe_in(
3058                    &multi_workspace,
3059                    window,
3060                    move |this, _, window, cx| {
3061                        dismiss_if_visible(this, window, cx);
3062                    },
3063                ));
3064            }
3065
3066            if let Some(panel) = self
3067                .workspace
3068                .upgrade()
3069                .and_then(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
3070            {
3071                subscriptions.push(cx.subscribe_in(
3072                    &panel,
3073                    window,
3074                    move |this, _, event: &AgentPanelEvent, window, cx| match event {
3075                        AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ActiveViewFocused => {
3076                            dismiss_if_visible(this, window, cx);
3077                        }
3078                        AgentPanelEvent::EntryChanged
3079                        | AgentPanelEvent::TerminalCloseRequested { .. }
3080                        | AgentPanelEvent::ThreadInteracted { .. } => {}
3081                    },
3082                ));
3083            }
3084        }
3085    }
3086
3087    pub(crate) fn dismiss_notifications(&mut self, cx: &mut Context<Self>) -> bool {
3088        let had_notifications = !self.notifications.is_empty();
3089        for window in self.notifications.drain(..) {
3090            window
3091                .update(cx, |_, window, _| {
3092                    window.remove_window();
3093                })
3094                .ok();
3095
3096            self.notification_subscriptions.remove(&window);
3097        }
3098        had_notifications
3099    }
3100
3101    fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3102        if let Some(entry_view_state) = self
3103            .active_thread()
3104            .map(|active| active.read(cx).entry_view_state.clone())
3105        {
3106            entry_view_state.update(cx, |entry_view_state, cx| {
3107                entry_view_state.agent_ui_font_size_changed(cx);
3108            });
3109        }
3110    }
3111
3112    fn invalidate_mermaid_caches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3113        let current_theme_id = cx.theme().id.clone();
3114        if self.last_theme_id.as_ref() == Some(&current_theme_id) {
3115            return;
3116        }
3117        self.last_theme_id = Some(current_theme_id);
3118
3119        if let Some(connected) = self.as_connected() {
3120            let threads: Vec<_> = connected
3121                .conversation
3122                .read(cx)
3123                .threads
3124                .values()
3125                .cloned()
3126                .collect();
3127            for thread in threads {
3128                thread.update(cx, |thread, cx| {
3129                    thread.invalidate_mermaid_caches(cx);
3130                });
3131            }
3132        }
3133    }
3134
3135    pub(crate) fn insert_dragged_files(
3136        &self,
3137        paths: Vec<project::ProjectPath>,
3138        added_worktrees: Vec<Entity<project::Worktree>>,
3139        window: &mut Window,
3140        cx: &mut Context<Self>,
3141    ) {
3142        if let Some(active_thread) = self.active_thread() {
3143            active_thread.update(cx, |thread, cx| {
3144                thread.message_editor.update(cx, |editor, cx| {
3145                    editor.insert_dragged_files(paths, added_worktrees, window, cx);
3146                    editor.focus_handle(cx).focus(window, cx);
3147                })
3148            });
3149        }
3150    }
3151
3152    /// Inserts the selected text into the message editor or the message being
3153    /// edited, if any.
3154    pub(crate) fn insert_selection(
3155        &self,
3156        selection: AgentContextSelection,
3157        window: &mut Window,
3158        cx: &mut Context<Self>,
3159    ) {
3160        if let Some(active_thread) = self.active_thread() {
3161            active_thread.update(cx, |thread, cx| {
3162                thread.active_editor(cx).update(cx, |editor, cx| {
3163                    editor.insert_selections(selection, window, cx);
3164                })
3165            });
3166        }
3167    }
3168
3169    fn current_model_name(&self, cx: &App) -> SharedString {
3170        // For Omega Agent, use the specific model name (e.g., "Claude 3.5 Sonnet")
3171        // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI")
3172        // This provides better clarity about what refused the request
3173        if self.as_native_connection(cx).is_some() {
3174            self.root_thread_view()
3175                .and_then(|active| active.read(cx).model_selector.clone())
3176                .and_then(|selector| selector.read(cx).active_model(cx))
3177                .map(|model| model.name.clone())
3178                .unwrap_or_else(|| SharedString::from("The model"))
3179        } else {
3180            // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI")
3181            self.agent.agent_id().0
3182        }
3183    }
3184
3185    fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
3186        let message = message.into();
3187
3188        CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
3189    }
3190
3191    pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3192        self.cancel_request_elicitations(cx);
3193        if let Some(active) = self.root_thread_view() {
3194            active.update(cx, |active, cx| active.clear_thread_error(cx));
3195        }
3196        let this = cx.weak_entity();
3197        let Some(connection) = self.as_connected().map(|c| c.connection.clone()) else {
3198            debug_panic!("This should not be possible");
3199            return;
3200        };
3201        window.defer(cx, |window, cx| {
3202            Self::handle_auth_required(this, AuthRequired::new(), connection, window, cx);
3203        })
3204    }
3205
3206    pub(crate) fn logout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3207        if !self.supports_logout() {
3208            return;
3209        }
3210
3211        if let Some(active) = self.root_thread_view() {
3212            active.update(cx, |active, cx| active.clear_thread_error(cx));
3213        }
3214        let Some(connection) = self
3215            .as_connected()
3216            .map(|connected| connected.connection.clone())
3217        else {
3218            return;
3219        };
3220        let logout = connection.logout(cx);
3221        self.auth_task = Some(cx.spawn_in(window, {
3222            async move |this, cx| {
3223                let result = logout.await;
3224                this.update_in(cx, |this, window, cx| {
3225                    if let Err(err) = result {
3226                        if let Some(active) = this.root_thread_view() {
3227                            active.update(cx, |active, cx| active.handle_thread_error(err, cx));
3228                        }
3229                    } else {
3230                        this.cancel_request_elicitations(cx);
3231                        if let Some(connected) = this.as_connected_mut() {
3232                            connected.auth_state = AuthState::Unauthenticated {
3233                                description: None,
3234                                pending_auth_method: None,
3235                            };
3236                            cx.emit(StateChange);
3237                            if let Some(view) = connected.active_view()
3238                                && view
3239                                    .read(cx)
3240                                    .message_editor
3241                                    .focus_handle(cx)
3242                                    .is_focused(window)
3243                            {
3244                                this.focus_handle.focus(window, cx)
3245                            }
3246                            cx.notify();
3247                        }
3248                    }
3249                    drop(this.auth_task.take());
3250                })
3251                .ok();
3252            }
3253        }));
3254    }
3255}
3256
3257fn loading_contents_spinner(size: IconSize) -> AnyElement {
3258    Icon::new(IconName::LoadCircle)
3259        .size(size)
3260        .color(Color::Accent)
3261        .with_rotate_animation(3)
3262        .into_any_element()
3263}
3264
3265fn native_available_skills(
3266    native_connection: &agent::NativeAgentConnection,
3267    session_id: &acp::SessionId,
3268    cx: &App,
3269) -> Vec<AvailableSkill> {
3270    native_connection
3271        .available_skills(session_id, cx)
3272        .into_iter()
3273        .map(|skill| AvailableSkill {
3274            name: skill.name.into(),
3275            description: skill.description.into(),
3276            source: skill.source,
3277            skill_file_path: skill.skill_file_path,
3278            warning: skill.warning,
3279        })
3280        .collect()
3281}
3282
3283fn placeholder_text(agent_name: &str, has_commands: bool) -> String {
3284    if agent_name == agent::OMEGA_AGENT_ID.as_ref() {
3285        format!(
3286            "Message the {}, @ to include context, / for commands",
3287            agent_name
3288        )
3289    } else if has_commands {
3290        format!(
3291            "Message {} — @ to include context, / for commands",
3292            agent_name
3293        )
3294    } else {
3295        format!("Message {} — @ to include context", agent_name)
3296    }
3297}
3298
3299impl Focusable for ConversationView {
3300    fn focus_handle(&self, cx: &App) -> FocusHandle {
3301        match self.active_thread() {
3302            Some(thread) => thread.read(cx).focus_handle(cx),
3303            None => self.focus_handle.clone(),
3304        }
3305    }
3306}
3307
3308#[cfg(any(test, feature = "test-support"))]
3309impl ConversationView {
3310    /// Expands a tool call so its content is visible.
3311    /// This is primarily useful for visual testing.
3312    pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context<Self>) {
3313        if let Some(active) = self.active_thread() {
3314            active.update(cx, |active, cx| {
3315                active.entry_view_state.update(cx, |state, _cx| {
3316                    state.expand_tool_call(tool_call_id);
3317                });
3318            });
3319            cx.notify();
3320        }
3321    }
3322
3323    #[cfg(any(test, feature = "test-support"))]
3324    pub fn set_updated_at(&mut self, updated_at: Instant, cx: &mut Context<Self>) {
3325        let Some(connected) = self.as_connected_mut() else {
3326            return;
3327        };
3328
3329        connected.conversation.update(cx, |conversation, _cx| {
3330            conversation.updated_at = Some(updated_at);
3331        });
3332    }
3333}
3334
3335impl Render for ConversationView {
3336    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3337        self.sync_request_elicitation_states(window, cx);
3338        let request_elicitation_connection = self.request_elicitation_connection();
3339        let active_thread_renders_request_elicitations =
3340            self.active_thread_renders_request_elicitations();
3341        let content = match &self.server_state {
3342            ServerState::Loading { .. } => {
3343                let label_text = self
3344                    .loading_status
3345                    .clone()
3346                    .unwrap_or_else(|| "Loading…".into());
3347                v_flex()
3348                    .flex_1()
3349                    .size_full()
3350                    .items_center()
3351                    .justify_center()
3352                    .child(
3353                        Label::new(label_text).color(Color::Muted).with_animation(
3354                            "loading-agent-label",
3355                            Animation::new(Duration::from_secs(2))
3356                                .repeat()
3357                                .with_easing(pulsating_between(0.3, 0.7)),
3358                            |label, delta| label.alpha(delta),
3359                        ),
3360                    )
3361                    .into_any()
3362            }
3363            ServerState::LoadError { error: e, .. } => v_flex()
3364                .flex_1()
3365                .size_full()
3366                .items_center()
3367                .justify_end()
3368                .child(self.render_load_error(e, window, cx))
3369                .into_any(),
3370            ServerState::Connected(ConnectedServerState {
3371                connection,
3372                auth_state:
3373                    AuthState::Unauthenticated {
3374                        description,
3375                        pending_auth_method,
3376                    },
3377                ..
3378            }) => v_flex()
3379                .flex_1()
3380                .size_full()
3381                .justify_end()
3382                .child(self.render_auth_required_state(
3383                    connection,
3384                    description.as_ref(),
3385                    pending_auth_method.as_ref(),
3386                    window,
3387                    cx,
3388                ))
3389                .into_any_element(),
3390            ServerState::Connected(connected) => {
3391                if let Some(view) = connected.active_view() {
3392                    view.clone().into_any_element()
3393                } else {
3394                    debug_panic!("This state should never be reached");
3395                    div().into_any_element()
3396                }
3397            }
3398        };
3399
3400        v_flex()
3401            .track_focus(&self.focus_handle)
3402            .size_full()
3403            .bg(cx.theme().colors().panel_background)
3404            .child(v_flex().flex_1().min_h_0().child(content))
3405            .when(!active_thread_renders_request_elicitations, |this| {
3406                this.children(request_elicitation_connection.as_ref().map_or_else(
3407                    Vec::new,
3408                    |connection| {
3409                        self.render_request_elicitations(connection, cx.entity().downgrade(), cx)
3410                    },
3411                ))
3412            })
3413    }
3414}
3415
3416fn render_agent_markdown(
3417    markdown: Entity<Markdown>,
3418    style: MarkdownStyle,
3419    workspace: &WeakEntity<Workspace>,
3420    code_span_resolver: &AgentCodeSpanResolver,
3421    cx: &App,
3422) -> MarkdownElement {
3423    let workspace = workspace.clone();
3424    let worktree_roots = code_span_resolver.worktree_roots(cx);
3425    let resolver = code_span_resolver.clone();
3426    MarkdownElement::new(markdown, style)
3427        .code_block_renderer(markdown::CodeBlockRenderer::Default {
3428            copy_button_visibility: markdown::CopyButtonVisibility::VisibleOnHover,
3429            wrap_button_visibility: markdown::WrapButtonVisibility::VisibleOnHover,
3430            border: false,
3431        })
3432        .image_resolver(move |dest_url| resolve_agent_image(dest_url, &worktree_roots))
3433        .on_url_click(move |text, window, cx| {
3434            thread_view::open_link(text, &workspace, window, cx);
3435        })
3436        .on_code_span_link(move |text, cx| resolver.try_resolve(text, cx))
3437}
3438
3439/// Shared, cloneable handle for resolving inline markdown code spans like
3440/// `` `src/main.rs:42` `` to clickable workspace file links.
3441#[derive(Clone)]
3442pub(crate) struct AgentCodeSpanResolver {
3443    inner: Arc<AgentCodeSpanResolverInner>,
3444}
3445
3446/// Maximum number of memoized code-span resolutions kept in the cache.
3447const CODE_SPAN_CACHE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(2048) {
3448    Some(n) => n,
3449    None => unreachable!(),
3450};
3451
3452struct AgentCodeSpanResolverInner {
3453    project: WeakEntity<Project>,
3454    cache: Mutex<LruCache<Arc<str>, Option<SharedString>>>,
3455}
3456
3457impl AgentCodeSpanResolver {
3458    pub(crate) fn new(project: &WeakEntity<Project>, _cx: &App) -> Self {
3459        Self {
3460            inner: Arc::new(AgentCodeSpanResolverInner {
3461                project: project.clone(),
3462                cache: Mutex::new(LruCache::new(CODE_SPAN_CACHE_CAPACITY)),
3463            }),
3464        }
3465    }
3466
3467    pub(crate) fn clear_cache(&self) {
3468        self.inner.cache.lock().clear();
3469    }
3470
3471    /// Absolute paths of every current worktree.
3472    /// Used by the markdown image resolver, which needs the same set of roots.
3473    fn worktree_roots(&self, cx: &App) -> Vec<PathBuf> {
3474        self.inner
3475            .project
3476            .upgrade()
3477            .map(|project| {
3478                project
3479                    .read(cx)
3480                    .visible_worktrees(cx)
3481                    .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
3482                    .collect()
3483            })
3484            .unwrap_or_default()
3485    }
3486
3487    fn try_resolve(&self, text: &str, cx: &App) -> Option<SharedString> {
3488        let trimmed = sanitize_path_text(text.trim());
3489        if !Self::is_path_like(trimmed) {
3490            return None;
3491        }
3492
3493        if let Some(cached) = self.inner.cache.lock().get(trimmed).cloned() {
3494            return cached;
3495        }
3496
3497        let resolved = self.resolve_uncached(trimmed, cx);
3498        self.inner
3499            .cache
3500            .lock()
3501            .push(Arc::from(trimmed), resolved.clone());
3502        resolved
3503    }
3504
3505    fn resolve_uncached(&self, trimmed: &str, cx: &App) -> Option<SharedString> {
3506        let path_with_position = PathWithPosition::parse_str(trimmed);
3507        let candidate_path = &path_with_position.path;
3508        if candidate_path.as_os_str().is_empty() {
3509            return None;
3510        }
3511
3512        let project = self.inner.project.upgrade()?;
3513        let project = project.read(cx);
3514        for worktree in project.visible_worktrees(cx) {
3515            let worktree = worktree.read(cx);
3516            for relative_path in Self::candidate_relative_paths(
3517                candidate_path,
3518                &worktree.abs_path(),
3519                worktree.path_style(),
3520            ) {
3521                let project_path = ProjectPath {
3522                    worktree_id: worktree.id(),
3523                    path: relative_path.clone(),
3524                };
3525                let Some(entry) = project.entry_for_path(&project_path, cx) else {
3526                    continue;
3527                };
3528                if !entry.is_file() {
3529                    continue;
3530                }
3531
3532                let abs_path = worktree.absolutize(&relative_path);
3533                let mention = match path_with_position.row.and_then(|row| row.checked_sub(1)) {
3534                    Some(line) => MentionUri::Selection {
3535                        abs_path: Some(abs_path),
3536                        line_range: line..=line,
3537                        column: path_with_position
3538                            .column
3539                            .map(|column| column.saturating_sub(1)),
3540                    },
3541                    None => MentionUri::File { abs_path },
3542                };
3543
3544                return Some(mention.to_uri().to_string().into());
3545            }
3546        }
3547
3548        None
3549    }
3550
3551    fn candidate_relative_paths(
3552        path: &Path,
3553        worktree_abs_path: &Path,
3554        path_style: PathStyle,
3555    ) -> Vec<Arc<RelPath>> {
3556        let path_text = path.to_string_lossy();
3557        let relative_path: Option<Arc<RelPath>> =
3558            if util::paths::is_absolute(path_text.as_ref(), path_style) {
3559                path_style
3560                    .strip_prefix(path, worktree_abs_path)
3561                    .map(std::borrow::Cow::into_owned)
3562                    .map(Into::into)
3563            } else {
3564                RelPath::new(path, path_style)
3565                    .ok()
3566                    .map(std::borrow::Cow::into_owned)
3567                    .map(Into::into)
3568            };
3569
3570        let Some(relative_path) = relative_path else {
3571            return Vec::new();
3572        };
3573
3574        let mut paths = vec![relative_path.clone()];
3575        if let Some(root_name) = worktree_abs_path.file_name().and_then(|name| name.to_str())
3576            && let Ok(root_name) = RelPath::new(Path::new(root_name), path_style)
3577            && let Ok(stripped) = relative_path.strip_prefix(root_name.as_ref())
3578            && !stripped.is_empty()
3579        {
3580            paths.push(Arc::from(stripped));
3581        }
3582        paths
3583    }
3584
3585    fn is_path_like(text: &str) -> bool {
3586        if text.len() < 3
3587            || text.contains("://")
3588            || text.contains('|')
3589            || text.chars().any(char::is_control)
3590            || text.chars().all(|character| character.is_ascii_digit())
3591        {
3592            return false;
3593        }
3594
3595        let path = PathWithPosition::parse_str(text).path;
3596        let path_text = path.to_string_lossy();
3597        if path_text.contains('/') || path_text.contains('\\') {
3598            return true;
3599        }
3600
3601        path.extension()
3602            .and_then(|extension| extension.to_str())
3603            .is_some_and(|extension| !extension.is_empty())
3604    }
3605}
3606
3607fn plan_label_markdown_style(
3608    status: &acp::PlanEntryStatus,
3609    window: &Window,
3610    cx: &App,
3611) -> MarkdownStyle {
3612    let default_md_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
3613
3614    MarkdownStyle {
3615        base_text_style: TextStyle {
3616            color: cx.theme().colors().text_muted,
3617            strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
3618                Some(gpui::StrikethroughStyle {
3619                    thickness: px(1.),
3620                    color: Some(cx.theme().colors().text_muted.opacity(0.8)),
3621                })
3622            } else {
3623                None
3624            },
3625            ..default_md_style.base_text_style
3626        },
3627        ..default_md_style
3628    }
3629}
3630
3631#[cfg(test)]
3632pub(crate) mod tests {
3633    use acp_thread::StubAgentConnection;
3634    use action_log::ActionLog;
3635    use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
3636    use agent_servers::FakeAcpAgentServer;
3637    use editor::MultiBufferOffset;
3638    use editor::actions::Paste;
3639    use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _};
3640    use fs::FakeFs;
3641    use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, point, size};
3642    use parking_lot::Mutex;
3643    use project::Project;
3644    use serde_json::json;
3645    use settings::SettingsStore;
3646    use std::any::Any;
3647    use std::path::{Path, PathBuf};
3648    use std::rc::Rc;
3649    use std::sync::Arc;
3650    use workspace::{Item, MultiWorkspace};
3651
3652    use crate::agent_panel;
3653    use crate::completion_provider::AgentContextSource;
3654    use crate::test_support::register_test_sidebar;
3655    use crate::thread_metadata_store::ThreadMetadataStore;
3656
3657    use super::*;
3658
3659    #[test]
3660    fn test_data_retention_error_maps_from_provider_error() {
3661        // The agent wraps the provider error in a fresh `anyhow::Error`, so
3662        // the mapping must downcast to `LanguageModelCompletionError` rather
3663        // than matching on the anyhow error directly.
3664        let provider_error = LanguageModelCompletionError::DataRetentionConsentRequired {
3665            model_name: "Claude Fable 5".to_string(),
3666        };
3667        let error = ThreadError::from(anyhow!(provider_error));
3668        assert!(
3669            matches!(error, ThreadError::DataRetentionConsentRequired),
3670            "expected ThreadError::DataRetentionConsentRequired, got: {error:?}"
3671        );
3672    }
3673
3674    #[gpui::test]
3675    async fn test_drop(cx: &mut TestAppContext) {
3676        init_test(cx);
3677
3678        let (conversation_view, _cx) =
3679            setup_conversation_view(StubAgentServer::default_response(), cx).await;
3680        let weak_view = conversation_view.downgrade();
3681        drop(conversation_view);
3682        assert!(!weak_view.is_upgradable());
3683    }
3684
3685    #[gpui::test]
3686    async fn test_drop_preserves_shared_pending_request_elicitations(cx: &mut TestAppContext) {
3687        init_test(cx);
3688        cx.update(|cx| {
3689            cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
3690        });
3691
3692        let response = Arc::new(Mutex::new(None));
3693        let server = ReleaseRequestElicitationServer {
3694            response: response.clone(),
3695        };
3696        let (conversation_view, cx) = setup_conversation_view(server, cx).await;
3697        let _connection = conversation_view
3698            .read_with(cx, |view, _cx| view.request_elicitation_connection())
3699            .expect("conversation should have an active connection");
3700        let store = _connection
3701            .request_elicitations()
3702            .expect("connection should expose request elicitations");
3703        store.read_with(cx, |store, _cx| {
3704            assert_eq!(
3705                store.elicitations().len(),
3706                1,
3707                "test should start with one pending request elicitation"
3708            );
3709        });
3710
3711        assert_eq!(*response.lock(), None);
3712        let weak_view = conversation_view.downgrade();
3713        drop(conversation_view);
3714        cx.update(|_, _| {});
3715        cx.run_until_parked();
3716
3717        assert!(!weak_view.is_upgradable());
3718        store.read_with(cx, |store, _cx| {
3719            assert_eq!(
3720                store.elicitations().len(),
3721                1,
3722                "view release should not clear connection-wide request elicitations"
3723            );
3724        });
3725        assert_eq!(*response.lock(), None);
3726
3727        store.update(cx, |store, cx| store.clear(cx));
3728        cx.run_until_parked();
3729        assert!(matches!(
3730            response.lock().as_ref(),
3731            Some(acp::ElicitationAction::Cancel)
3732        ));
3733    }
3734
3735    #[gpui::test]
3736    async fn test_state_transition_preserves_shared_pending_request_elicitations(
3737        cx: &mut TestAppContext,
3738    ) {
3739        init_test(cx);
3740        cx.update(|cx| {
3741            cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
3742        });
3743
3744        let response = Arc::new(Mutex::new(None));
3745        let server = ReleaseRequestElicitationServer {
3746            response: response.clone(),
3747        };
3748        let (conversation_view, cx) = setup_conversation_view(server, cx).await;
3749        let connection = conversation_view
3750            .read_with(cx, |view, _cx| view.request_elicitation_connection())
3751            .expect("conversation should have an active connection");
3752        let store = connection
3753            .request_elicitations()
3754            .expect("connection should expose request elicitations");
3755        store.read_with(cx, |store, _cx| {
3756            assert_eq!(
3757                store.elicitations().len(),
3758                1,
3759                "test should start with one pending request elicitation"
3760            );
3761        });
3762
3763        conversation_view.update(cx, |view, cx| {
3764            view.set_server_state(
3765                ServerState::LoadError {
3766                    error: LoadError::Other("load failed".into()),
3767                },
3768                cx,
3769            );
3770        });
3771        cx.run_until_parked();
3772
3773        store.read_with(cx, |store, _cx| {
3774            assert_eq!(
3775                store.elicitations().len(),
3776                1,
3777                "leaving a connection should not clear connection-wide request elicitations"
3778            );
3779        });
3780        assert_eq!(*response.lock(), None);
3781
3782        store.update(cx, |store, cx| store.clear(cx));
3783        cx.run_until_parked();
3784        assert!(matches!(
3785            response.lock().as_ref(),
3786            Some(acp::ElicitationAction::Cancel)
3787        ));
3788    }
3789
3790    #[gpui::test]
3791    async fn test_successful_session_creation_clears_resolved_request_elicitations(
3792        cx: &mut TestAppContext,
3793    ) {
3794        init_test(cx);
3795        cx.update(|cx| {
3796            cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
3797        });
3798
3799        let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
3800        let response = Arc::new(Mutex::new(None));
3801        let server = SessionCreationRequestElicitationServer {
3802            store: store.clone(),
3803            response: response.clone(),
3804        };
3805        let (conversation_view, cx) = setup_conversation_view(server, cx).await;
3806        let first_request_id = acp::RequestId::Number(1);
3807        let second_request_id = acp::RequestId::Number(2);
3808        let first_elicitation_id = store.read_with(cx, |store, _cx| {
3809            assert_eq!(
3810                store.elicitations().len(),
3811                2,
3812                "session creation should be waiting on one prompt with another prompt still pending"
3813            );
3814            store
3815                .elicitations()
3816                .iter()
3817                .find_map(|elicitation| {
3818                    let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else {
3819                        return None;
3820                    };
3821                    (&scope.request_id == &first_request_id).then(|| elicitation.id.clone())
3822                })
3823                .expect("first request-scoped elicitation should exist")
3824        });
3825
3826        store.update(cx, |store, cx| {
3827            store.respond_to_elicitation(
3828                &first_elicitation_id,
3829                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
3830                    acp::ElicitationAcceptAction::new(),
3831                )),
3832                cx,
3833            );
3834        });
3835        cx.run_until_parked();
3836
3837        assert!(matches!(
3838            response.lock().as_ref(),
3839            Some(acp::ElicitationAction::Accept(_))
3840        ));
3841        conversation_view.read_with(cx, |view, _cx| {
3842            let connected = view
3843                .as_connected()
3844                .expect("session creation should complete successfully");
3845            assert!(
3846                connected.active_id.is_some(),
3847                "successful session creation should install an active thread"
3848            );
3849        });
3850        store.read_with(cx, |store, _cx| {
3851            let [remaining] = store.elicitations() else {
3852                panic!(
3853                    "expected only the pending request elicitation to remain, got {:?}",
3854                    store.elicitations()
3855                );
3856            };
3857            let acp::ElicitationScope::Request(scope) = remaining.request.scope() else {
3858                panic!("expected request-scoped elicitation");
3859            };
3860            assert_eq!(scope.request_id, second_request_id);
3861            assert!(matches!(
3862                remaining.status,
3863                ElicitationStatus::Pending { .. }
3864            ));
3865        });
3866
3867        store.update(cx, |store, cx| store.clear(cx));
3868        cx.run_until_parked();
3869    }
3870
3871    #[gpui::test]
3872    async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) {
3873        init_test(cx);
3874
3875        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
3876            panic!("expected prompt from external source to sanitize successfully");
3877        };
3878        let initial_content = AgentInitialContent::FromExternalSource(prompt);
3879
3880        let (conversation_view, cx) = setup_conversation_view_with_initial_content(
3881            StubAgentServer::default_response(),
3882            initial_content,
3883            cx,
3884        )
3885        .await;
3886
3887        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
3888            assert!(view.show_external_source_prompt_warning);
3889            assert_eq!(view.thread.read(cx).entries().len(), 0);
3890            assert_eq!(view.message_editor.read(cx).text(cx), "Write me a script");
3891        });
3892    }
3893
3894    #[gpui::test]
3895    async fn test_external_source_prompt_warning_clears_after_send(cx: &mut TestAppContext) {
3896        init_test(cx);
3897
3898        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
3899            panic!("expected prompt from external source to sanitize successfully");
3900        };
3901        let initial_content = AgentInitialContent::FromExternalSource(prompt);
3902
3903        let (conversation_view, cx) = setup_conversation_view_with_initial_content(
3904            StubAgentServer::default_response(),
3905            initial_content,
3906            cx,
3907        )
3908        .await;
3909
3910        active_thread(&conversation_view, cx)
3911            .update_in(cx, |view, window, cx| view.send(window, cx));
3912        cx.run_until_parked();
3913
3914        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
3915            assert!(!view.show_external_source_prompt_warning);
3916            assert_eq!(view.message_editor.read(cx).text(cx), "");
3917            assert_eq!(view.thread.read(cx).entries().len(), 2);
3918        });
3919    }
3920
3921    #[gpui::test]
3922    async fn test_agent_code_span_resolver_resolves_worktree_paths(cx: &mut TestAppContext) {
3923        init_test(cx);
3924
3925        let fs = FakeFs::new(cx.executor());
3926        fs.insert_tree(
3927            util::path!("/project"),
3928            json!({
3929                "src": {
3930                    "main.rs": ""
3931                },
3932                "README.md": ""
3933            }),
3934        )
3935        .await;
3936
3937        let project = Project::test(fs, [Path::new(util::path!("/project"))], cx).await;
3938        let resolver = cx.update(|cx| AgentCodeSpanResolver::new(&project.downgrade(), cx));
3939
3940        let uri = cx
3941            .update(|cx| resolver.try_resolve("src/main.rs:10", cx))
3942            .expect("expected worktree-relative file path to resolve");
3943        assert_eq!(
3944            MentionUri::parse(&uri, PathStyle::local()).unwrap(),
3945            MentionUri::Selection {
3946                abs_path: Some(PathBuf::from(util::path!("/project/src/main.rs"))),
3947                line_range: 9..=9,
3948                column: None,
3949            }
3950        );
3951
3952        let uri = cx
3953            .update(|cx| resolver.try_resolve("src/main.rs:10:5", cx))
3954            .expect("expected worktree-relative file path with row and column to resolve");
3955        assert_eq!(
3956            MentionUri::parse(&uri, PathStyle::local()).unwrap(),
3957            MentionUri::Selection {
3958                abs_path: Some(PathBuf::from(util::path!("/project/src/main.rs"))),
3959                line_range: 9..=9,
3960                column: Some(4),
3961            }
3962        );
3963
3964        let uri = cx
3965            .update(|cx| resolver.try_resolve("src/main.rs:0", cx))
3966            .expect("`:0` should fall back to a file mention instead of returning None");
3967        assert_eq!(
3968            MentionUri::parse(&uri, PathStyle::local()).unwrap(),
3969            MentionUri::File {
3970                abs_path: PathBuf::from(util::path!("/project/src/main.rs")),
3971            }
3972        );
3973
3974        assert!(cx.update(|cx| resolver.try_resolve("String", cx)).is_none());
3975        assert!(
3976            cx.update(|cx| resolver.try_resolve("does/not/exist.rs", cx))
3977                .is_none()
3978        );
3979        assert!(
3980            cx.update(|cx| resolver.try_resolve("src/main.rs.", cx))
3981                .is_some()
3982        );
3983
3984        let uri = cx
3985            .update(|cx| resolver.try_resolve("project/src/main.rs:10", cx))
3986            .expect("expected root-prefixed worktree path to resolve");
3987        assert_eq!(
3988            MentionUri::parse(&uri, PathStyle::local()).unwrap(),
3989            MentionUri::Selection {
3990                abs_path: Some(PathBuf::from(util::path!("/project/src/main.rs"))),
3991                line_range: 9..=9,
3992                column: None,
3993            }
3994        );
3995    }
3996
3997    #[gpui::test]
3998    async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
3999        init_test(cx);
4000
4001        let (conversation_view, cx) =
4002            setup_conversation_view(StubAgentServer::default_response(), cx).await;
4003
4004        let message_editor = message_editor(&conversation_view, cx);
4005        message_editor.update_in(cx, |editor, window, cx| {
4006            editor.set_text("Hello", window, cx);
4007        });
4008
4009        cx.deactivate_window();
4010
4011        active_thread(&conversation_view, cx)
4012            .update_in(cx, |view, window, cx| view.send(window, cx));
4013
4014        cx.run_until_parked();
4015
4016        assert!(
4017            cx.windows()
4018                .iter()
4019                .any(|window| window.downcast::<AgentNotification>().is_some())
4020        );
4021    }
4022
4023    #[gpui::test]
4024    async fn test_no_notification_when_queued_message_will_be_auto_sent(cx: &mut TestAppContext) {
4025        init_test(cx);
4026
4027        let connection = StubAgentConnection::new();
4028        let (conversation_view, cx) =
4029            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4030        add_to_workspace(conversation_view.clone(), cx);
4031
4032        let message_editor = message_editor(&conversation_view, cx);
4033        message_editor.update_in(cx, |editor, window, cx| {
4034            editor.set_text("first", window, cx);
4035        });
4036
4037        active_thread(&conversation_view, cx)
4038            .update_in(cx, |view, window, cx| view.send(window, cx));
4039
4040        cx.run_until_parked();
4041
4042        let session_id = conversation_view.read_with(cx, |view, cx| {
4043            view.active_thread()
4044                .unwrap()
4045                .read(cx)
4046                .thread
4047                .read(cx)
4048                .session_id()
4049                .clone()
4050        });
4051
4052        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
4053            thread.add_to_queue(
4054                vec![acp::ContentBlock::Text(acp::TextContent::new(
4055                    "queued".to_string(),
4056                ))],
4057                vec![],
4058                window,
4059                cx,
4060            );
4061        });
4062
4063        cx.deactivate_window();
4064        cx.run_until_parked();
4065
4066        cx.update(|_, cx| {
4067            connection.send_update(
4068                session_id.clone(),
4069                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
4070                    "first response".into(),
4071                )),
4072                cx,
4073            );
4074            connection.end_turn(session_id, acp::StopReason::EndTurn);
4075        });
4076
4077        cx.run_until_parked();
4078
4079        assert_eq!(
4080            cx.windows()
4081                .iter()
4082                .filter(|window| window.downcast::<AgentNotification>().is_some())
4083                .count(),
4084            0,
4085            "No notification should fire when a queued message will be auto-sent on Stopped"
4086        );
4087    }
4088
4089    #[gpui::test]
4090    async fn test_queued_message_steer_defaults_off_and_toggles(cx: &mut TestAppContext) {
4091        init_test(cx);
4092
4093        let (conversation_view, cx) =
4094            setup_conversation_view(StubAgentServer::default_response(), cx).await;
4095        add_to_workspace(conversation_view.clone(), cx);
4096
4097        let id = active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
4098            thread.add_to_queue(
4099                vec![acp::ContentBlock::Text(acp::TextContent::new(
4100                    "queued".to_string(),
4101                ))],
4102                vec![],
4103                window,
4104                cx,
4105            );
4106            thread.message_queue.first_id().unwrap()
4107        });
4108        cx.run_until_parked();
4109
4110        // Default: steering is off, so the message waits for end-of-generation
4111        // rather than interrupting the agent at the next boundary.
4112        active_thread(&conversation_view, cx).read_with(cx, |thread, _cx| {
4113            assert!(
4114                !thread.message_queue.front_wants_steer(),
4115                "steering should default off"
4116            );
4117        });
4118
4119        active_thread(&conversation_view, cx).update(cx, |thread, _cx| {
4120            thread.message_queue.toggle_steer(id);
4121        });
4122        active_thread(&conversation_view, cx).read_with(cx, |thread, _cx| {
4123            assert!(
4124                thread.message_queue.front_wants_steer(),
4125                "steering should be on after toggling"
4126            );
4127        });
4128    }
4129
4130    #[gpui::test]
4131    async fn test_queue_resumes_after_stop_and_new_message(cx: &mut TestAppContext) {
4132        init_test(cx);
4133
4134        let connection = StubAgentConnection::new();
4135        let (conversation_view, cx) =
4136            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
4137        add_to_workspace(conversation_view.clone(), cx);
4138
4139        let message_editor = message_editor(&conversation_view, cx);
4140        message_editor.update_in(cx, |editor, window, cx| {
4141            editor.set_text("first", window, cx);
4142        });
4143        active_thread(&conversation_view, cx)
4144            .update_in(cx, |view, window, cx| view.send(window, cx));
4145        cx.run_until_parked();
4146
4147        // Queue a follow-up while the agent is generating.
4148        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
4149            thread.add_to_queue(
4150                vec![acp::ContentBlock::Text(acp::TextContent::new(
4151                    "queued".to_string(),
4152                ))],
4153                vec![],
4154                window,
4155                cx,
4156            );
4157        });
4158
4159        // User stops generation: the queued message must NOT be sent.
4160        active_thread(&conversation_view, cx)
4161            .update_in(cx, |thread, _window, cx| thread.cancel_generation(cx));
4162        cx.run_until_parked();
4163
4164        let queue_len = active_thread(&conversation_view, cx)
4165            .read_with(cx, |thread, _cx| thread.message_queue.len());
4166        assert_eq!(queue_len, 1, "stopping must not send the queued message");
4167
4168        // User sends a new message, which should resume queue auto-processing.
4169        message_editor.update_in(cx, |editor, window, cx| {
4170            editor.set_text("second", window, cx);
4171        });
4172        active_thread(&conversation_view, cx)
4173            .update_in(cx, |view, window, cx| view.send(window, cx));
4174        cx.run_until_parked();
4175
4176        let session_id = conversation_view.read_with(cx, |view, cx| {
4177            view.active_thread()
4178                .unwrap()
4179                .read(cx)
4180                .thread
4181                .read(cx)
4182                .session_id()
4183                .clone()
4184        });
4185
4186        // When this generation completes, the queued message should be picked
4187        // up automatically (regression test for the "frozen queue" bug).
4188        connection.end_turn(session_id, acp::StopReason::EndTurn);
4189        cx.run_until_parked();
4190
4191        let queue_len = active_thread(&conversation_view, cx)
4192            .read_with(cx, |thread, _cx| thread.message_queue.len());
4193        assert_eq!(
4194            queue_len, 0,
4195            "queued message should be auto-sent after the user re-engages"
4196        );
4197    }
4198
4199    #[gpui::test]
4200    async fn test_notification_for_error(cx: &mut TestAppContext) {
4201        init_test(cx);
4202
4203        let server = FakeAcpAgentServer::new();
4204        let (conversation_view, cx) = setup_conversation_view(server.clone(), cx).await;
4205
4206        let message_editor = message_editor(&conversation_view, cx);
4207        message_editor.update_in(cx, |editor, window, cx| {
4208            editor.set_text("Hello", window, cx);
4209        });
4210
4211        cx.deactivate_window();
4212        server.fail_next_prompt();
4213
4214        active_thread(&conversation_view, cx)
4215            .update_in(cx, |view, window, cx| view.send(window, cx));
4216
4217        cx.run_until_parked();
4218
4219        assert!(
4220            cx.windows()
4221                .iter()
4222                .any(|window| window.downcast::<AgentNotification>().is_some())
4223        );
4224    }
4225
4226    #[gpui::test]
4227    async fn test_acp_server_exit_transitions_conversation_to_load_error_without_panic(
4228        cx: &mut TestAppContext,
4229    ) {
4230        init_test(cx);
4231
4232        let server = FakeAcpAgentServer::new();
4233        let close_session_count = server.close_session_count();
4234        let (conversation_view, cx) = setup_conversation_view(server.clone(), cx).await;
4235
4236        cx.run_until_parked();
4237
4238        server.simulate_server_exit();
4239        cx.run_until_parked();
4240
4241        conversation_view.read_with(cx, |view, _cx| {
4242            assert!(
4243                matches!(view.server_state, ServerState::LoadError { .. }),
4244                "Conversation should transition to LoadError when an ACP thread exits"
4245            );
4246        });
4247        assert_eq!(
4248            close_session_count.load(std::sync::atomic::Ordering::SeqCst),
4249            1,
4250            "ConversationView should close the ACP session after a thread exit"
4251        );
4252    }
4253
4254    #[gpui::test]
4255    async fn test_thread_view_seeds_existing_elicitation_form_state(cx: &mut TestAppContext) {
4256        init_test(cx);
4257        cx.update(|cx| {
4258            cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
4259        });
4260
4261        let connection = PreloadedElicitationConnection::default();
4262        let elicitation_id = connection.elicitation_id.clone();
4263        let (conversation_view, cx) =
4264            setup_conversation_view(StubAgentServer::new(connection), cx).await;
4265
4266        let elicitation_id = elicitation_id
4267            .lock()
4268            .clone()
4269            .expect("connection should preload an elicitation");
4270        let active_thread = active_thread(&conversation_view, cx);
4271        active_thread.read_with(cx, |thread, _cx| {
4272            assert!(
4273                thread.has_elicitation_form_state(&elicitation_id),
4274                "pending form elicitations that predate ThreadView construction should be usable"
4275            );
4276        });
4277    }
4278
4279    #[gpui::test]
4280    async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
4281        init_test(cx);
4282
4283        let fs = FakeFs::new(cx.executor());
4284        let project = Project::test(fs, [], cx).await;
4285        let (multi_workspace, cx) =
4286            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4287        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4288
4289        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4290        let connection_store =
4291            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4292
4293        let conversation_view = cx.update(|window, cx| {
4294            cx.new(|cx| {
4295                ConversationView::new(
4296                    Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
4297                    connection_store,
4298                    Agent::Custom { id: "Test".into() },
4299                    Some(acp::SessionId::new("resume-session")),
4300                    None,
4301                    None,
4302                    None,
4303                    None,
4304                    workspace.downgrade(),
4305                    project,
4306                    Some(thread_store),
4307                    AgentThreadSource::AgentPanel,
4308                    window,
4309                    cx,
4310                )
4311            })
4312        });
4313
4314        cx.run_until_parked();
4315
4316        conversation_view.read_with(cx, |view, cx| {
4317            let state = view.active_thread().unwrap();
4318            assert!(state.read(cx).resumed_without_history);
4319            assert_eq!(state.read(cx).list_state.item_count(), 0);
4320        });
4321    }
4322
4323    #[derive(Clone)]
4324    struct RestoredAvailableCommandsConnection;
4325
4326    impl AgentConnection for RestoredAvailableCommandsConnection {
4327        fn agent_id(&self) -> AgentId {
4328            AgentId::new("restored-available-commands")
4329        }
4330
4331        fn telemetry_id(&self) -> SharedString {
4332            "restored-available-commands".into()
4333        }
4334
4335        fn new_session(
4336            self: Rc<Self>,
4337            project: Entity<Project>,
4338            _work_dirs: PathList,
4339            cx: &mut App,
4340        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4341            let thread = build_test_thread(
4342                self,
4343                project,
4344                "RestoredAvailableCommandsConnection",
4345                acp::SessionId::new("new-session"),
4346                cx,
4347            );
4348            Task::ready(Ok(thread))
4349        }
4350
4351        fn supports_load_session(&self) -> bool {
4352            true
4353        }
4354
4355        fn load_session(
4356            self: Rc<Self>,
4357            session_id: acp::SessionId,
4358            project: Entity<Project>,
4359            _work_dirs: PathList,
4360            _title: Option<SharedString>,
4361            cx: &mut App,
4362        ) -> Task<gpui::Result<Entity<AcpThread>>> {
4363            let thread = build_test_thread(
4364                self,
4365                project,
4366                "RestoredAvailableCommandsConnection",
4367                session_id,
4368                cx,
4369            );
4370
4371            thread
4372                .update(cx, |thread, cx| {
4373                    thread.handle_session_update(
4374                        acp::SessionUpdate::AvailableCommandsUpdate(
4375                            acp::AvailableCommandsUpdate::new(vec![acp::AvailableCommand::new(
4376                                "help", "Get help",
4377                            )]),
4378                        ),
4379                        cx,
4380                    )
4381                })
4382                .expect("available commands update should succeed");
4383
4384            Task::ready(Ok(thread))
4385        }
4386
4387        fn auth_methods(&self) -> &[acp::AuthMethod] {
4388            &[]
4389        }
4390
4391        fn authenticate(
4392            &self,
4393            _method_id: acp::AuthMethodId,
4394            _cx: &mut App,
4395        ) -> Task<gpui::Result<()>> {
4396            Task::ready(Ok(()))
4397        }
4398
4399        fn prompt(
4400            &self,
4401            _params: acp::PromptRequest,
4402            _cx: &mut App,
4403        ) -> Task<gpui::Result<acp::PromptResponse>> {
4404            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
4405        }
4406
4407        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
4408
4409        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
4410            self
4411        }
4412    }
4413
4414    #[gpui::test]
4415    async fn test_restored_threads_keep_available_commands(cx: &mut TestAppContext) {
4416        init_test(cx);
4417
4418        let fs = FakeFs::new(cx.executor());
4419        let project = Project::test(fs, [], cx).await;
4420        let (multi_workspace, cx) =
4421            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4422        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4423
4424        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4425        let connection_store =
4426            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4427
4428        let conversation_view = cx.update(|window, cx| {
4429            cx.new(|cx| {
4430                ConversationView::new(
4431                    Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
4432                    connection_store,
4433                    Agent::Custom { id: "Test".into() },
4434                    Some(acp::SessionId::new("restored-session")),
4435                    None,
4436                    None,
4437                    None,
4438                    None,
4439                    workspace.downgrade(),
4440                    project,
4441                    Some(thread_store),
4442                    AgentThreadSource::AgentPanel,
4443                    window,
4444                    cx,
4445                )
4446            })
4447        });
4448
4449        cx.run_until_parked();
4450
4451        let message_editor = message_editor(&conversation_view, cx);
4452        let editor =
4453            message_editor.update(cx, |message_editor, _cx| message_editor.editor().clone());
4454        let placeholder = editor.update(cx, |editor, cx| editor.placeholder_text(cx));
4455
4456        active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
4457            let available_commands = view
4458                .session_capabilities
4459                .read()
4460                .available_commands()
4461                .to_vec();
4462            assert_eq!(available_commands.len(), 1);
4463            assert_eq!(available_commands[0].name.as_str(), "help");
4464            assert_eq!(available_commands[0].description.as_str(), "Get help");
4465        });
4466
4467        assert_eq!(
4468            placeholder,
4469            Some("Message Test — @ to include context, / for commands".to_string())
4470        );
4471
4472        message_editor.update_in(cx, |editor, window, cx| {
4473            editor.set_text("/help", window, cx);
4474        });
4475
4476        let contents_result = message_editor
4477            .update(cx, |editor, cx| editor.contents(false, cx))
4478            .await;
4479
4480        assert!(contents_result.is_ok());
4481    }
4482
4483    #[gpui::test]
4484    async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) {
4485        init_test(cx);
4486
4487        let fs = FakeFs::new(cx.executor());
4488        fs.insert_tree(
4489            "/project",
4490            json!({
4491                "subdir": {
4492                    "file.txt": "hello"
4493                }
4494            }),
4495        )
4496        .await;
4497        let project = Project::test(fs, [Path::new("/project")], cx).await;
4498        let (multi_workspace, cx) =
4499            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4500        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4501
4502        let connection = CwdCapturingConnection::new();
4503        let captured_cwd = connection.captured_work_dirs.clone();
4504
4505        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4506        let connection_store =
4507            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4508
4509        let _conversation_view = cx.update(|window, cx| {
4510            cx.new(|cx| {
4511                ConversationView::new(
4512                    Rc::new(StubAgentServer::new(connection)),
4513                    connection_store,
4514                    Agent::Custom { id: "Test".into() },
4515                    Some(acp::SessionId::new("session-1")),
4516                    None,
4517                    Some(PathList::new(&[PathBuf::from("/project/subdir")])),
4518                    None,
4519                    None,
4520                    workspace.downgrade(),
4521                    project,
4522                    Some(thread_store),
4523                    AgentThreadSource::AgentPanel,
4524                    window,
4525                    cx,
4526                )
4527            })
4528        });
4529
4530        cx.run_until_parked();
4531
4532        assert_eq!(
4533            captured_cwd.lock().as_ref().unwrap(),
4534            &PathList::new(&[Path::new("/project/subdir")]),
4535            "Should use session cwd when it's inside the project"
4536        );
4537    }
4538
4539    #[gpui::test]
4540    async fn test_refusal_handling(cx: &mut TestAppContext) {
4541        init_test(cx);
4542
4543        let (conversation_view, cx) =
4544            setup_conversation_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
4545
4546        let message_editor = message_editor(&conversation_view, cx);
4547        message_editor.update_in(cx, |editor, window, cx| {
4548            editor.set_text("Do something harmful", window, cx);
4549        });
4550
4551        active_thread(&conversation_view, cx)
4552            .update_in(cx, |view, window, cx| view.send(window, cx));
4553
4554        cx.run_until_parked();
4555
4556        // Check that the refusal error is set
4557        conversation_view.read_with(cx, |thread_view, cx| {
4558            let state = thread_view.active_thread().unwrap();
4559            assert!(
4560                matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)),
4561                "Expected refusal error to be set"
4562            );
4563        });
4564    }
4565
4566    #[gpui::test]
4567    async fn test_connect_failure_transitions_to_load_error(cx: &mut TestAppContext) {
4568        init_test(cx);
4569
4570        let (conversation_view, cx) = setup_conversation_view(FailingAgentServer, cx).await;
4571
4572        conversation_view.read_with(cx, |view, cx| {
4573            let title = view.title(cx);
4574            assert_eq!(
4575                title.as_ref(),
4576                "Error Loading Codex CLI",
4577                "Tab title should show the agent name with an error prefix"
4578            );
4579            match &view.server_state {
4580                ServerState::LoadError {
4581                    error: LoadError::Other(msg),
4582                    ..
4583                } => {
4584                    assert!(
4585                        msg.contains("Invalid gzip header"),
4586                        "Error callout should contain the underlying extraction error, got: {msg}"
4587                    );
4588                }
4589                other => panic!(
4590                    "Expected LoadError::Other, got: {}",
4591                    match other {
4592                        ServerState::Loading { .. } => "Loading (stuck!)",
4593                        ServerState::LoadError { .. } => "LoadError (wrong variant)",
4594                        ServerState::Connected(_) => "Connected",
4595                    }
4596                ),
4597            }
4598        });
4599    }
4600
4601    #[gpui::test]
4602    async fn test_reset_preserves_session_id_after_load_error(cx: &mut TestAppContext) {
4603        use crate::thread_metadata_store::{ThreadId, ThreadMetadata};
4604        use chrono::Utc;
4605        use project::{AgentId as ProjectAgentId, WorktreePaths};
4606        use std::sync::atomic::Ordering;
4607
4608        init_test(cx);
4609
4610        let fs = FakeFs::new(cx.executor());
4611        let project = Project::test(fs, [], cx).await;
4612        let (multi_workspace, cx) =
4613            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4614        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4615
4616        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4617        let connection_store =
4618            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4619
4620        // Simulate a previous run that persisted metadata for this session.
4621        let resume_session_id = acp::SessionId::new("persistent-session");
4622        let stored_title: SharedString = "Persistent chat".into();
4623        cx.update(|_window, cx| {
4624            ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4625                store.save(
4626                    ThreadMetadata {
4627                        thread_id: ThreadId::new(),
4628                        session_id: Some(resume_session_id.clone()),
4629                        agent_id: ProjectAgentId::new("Flaky"),
4630                        title: Some(stored_title.clone()),
4631                        title_override: None,
4632                        updated_at: Utc::now(),
4633                        created_at: Some(Utc::now()),
4634                        interacted_at: None,
4635                        worktree_paths: WorktreePaths::from_folder_paths(&PathList::default()),
4636                        remote_connection: None,
4637                        archived: false,
4638                    },
4639                    cx,
4640                );
4641            });
4642        });
4643
4644        let connection = StubAgentConnection::new().with_supports_load_session(true);
4645        let (server, fail) = FlakyAgentServer::new(connection);
4646
4647        let conversation_view = cx.update(|window, cx| {
4648            cx.new(|cx| {
4649                ConversationView::new(
4650                    Rc::new(server),
4651                    connection_store,
4652                    Agent::Custom { id: "Flaky".into() },
4653                    Some(resume_session_id.clone()),
4654                    None,
4655                    None,
4656                    None,
4657                    None,
4658                    workspace.downgrade(),
4659                    project.clone(),
4660                    Some(thread_store),
4661                    AgentThreadSource::AgentPanel,
4662                    window,
4663                    cx,
4664                )
4665            })
4666        });
4667        cx.run_until_parked();
4668
4669        // The first connect() fails, so we land in LoadError.
4670        conversation_view.read_with(cx, |view, _cx| {
4671            assert!(
4672                matches!(view.server_state, ServerState::LoadError { .. }),
4673                "expected LoadError after failed initial connect"
4674            );
4675            assert_eq!(
4676                view.root_session_id.as_ref(),
4677                Some(&resume_session_id),
4678                "root_session_id should still hold the original id while in LoadError"
4679            );
4680        });
4681
4682        // Now let the agent come online and emit AgentServersUpdated. This is
4683        // the moment the bug would have stomped on root_session_id.
4684        fail.store(false, Ordering::SeqCst);
4685        project.update(cx, |project, cx| {
4686            project
4687                .agent_server_store()
4688                .update(cx, |_store, cx| cx.emit(project::AgentServersUpdated));
4689        });
4690        cx.run_until_parked();
4691
4692        // The retry should have resumed the ORIGINAL session, not created a
4693        // brand-new one.
4694        conversation_view.read_with(cx, |view, cx| {
4695            let connected = view
4696                .as_connected()
4697                .expect("should be Connected after flaky server comes online");
4698            let active_id = connected
4699                .active_id
4700                .as_ref()
4701                .expect("Connected state should have an active_id");
4702            assert_eq!(
4703                active_id, &resume_session_id,
4704                "reset() must resume the original session id, not call new_session()"
4705            );
4706            let active_thread = view
4707                .active_thread()
4708                .expect("should have an active thread view");
4709            let thread_session = active_thread.read(cx).thread.read(cx).session_id().clone();
4710            assert_eq!(
4711                thread_session, resume_session_id,
4712                "the live AcpThread should hold the resumed session id"
4713            );
4714        });
4715    }
4716
4717    #[gpui::test]
4718    async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) {
4719        init_test(cx);
4720
4721        let connection = AuthGatedAgentConnection::new();
4722        let (conversation_view, cx) =
4723            setup_conversation_view(StubAgentServer::new(connection), cx).await;
4724
4725        // When new_session returns AuthRequired, the server should transition
4726        // to Connected + Unauthenticated rather than getting stuck in Loading.
4727        conversation_view.read_with(cx, |view, _cx| {
4728            let connected = view
4729                .as_connected()
4730                .expect("Should be in Connected state even though auth is required");
4731            assert!(
4732                !connected.auth_state.is_ok(),
4733                "Auth state should be Unauthenticated"
4734            );
4735            assert!(
4736                !view.supports_logout(),
4737                "Logout should be hidden while unauthenticated"
4738            );
4739            assert!(
4740                connected.active_id.is_none(),
4741                "There should be no active thread since no session was created"
4742            );
4743            assert!(
4744                !view.active_thread_renders_request_elicitations(),
4745                "request elicitations should render outside ThreadView when no thread exists"
4746            );
4747            assert!(
4748                connected.threads.is_empty(),
4749                "There should be no threads since no session was created"
4750            );
4751        });
4752
4753        conversation_view.read_with(cx, |view, _cx| {
4754            assert!(
4755                view.active_thread().is_none(),
4756                "active_thread() should be None when unauthenticated without a session"
4757            );
4758        });
4759
4760        // Authenticate using the real authenticate flow on ConnectionView.
4761        // This calls connection.authenticate(), which flips the internal flag,
4762        // then on success triggers reset() -> new_session() which now succeeds.
4763        conversation_view.update_in(cx, |view, window, cx| {
4764            view.authenticate(
4765                acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID),
4766                window,
4767                cx,
4768            );
4769        });
4770        cx.run_until_parked();
4771
4772        // After auth, the server should have an active thread in the Ok state.
4773        conversation_view.read_with(cx, |view, cx| {
4774            let connected = view
4775                .as_connected()
4776                .expect("Should still be in Connected state after auth");
4777            assert!(connected.auth_state.is_ok(), "Auth state should be Ok");
4778            assert!(
4779                view.supports_logout(),
4780                "Logout should be available after authentication"
4781            );
4782            assert!(
4783                connected.active_id.is_some(),
4784                "There should be an active thread after successful auth"
4785            );
4786            assert!(
4787                view.active_thread_renders_request_elicitations(),
4788                "request elicitations should render inside ThreadView while authenticated"
4789            );
4790            assert_eq!(
4791                connected.threads.len(),
4792                1,
4793                "There should be exactly one thread"
4794            );
4795
4796            let active = view
4797                .active_thread()
4798                .expect("active_thread() should return the new thread");
4799            assert!(
4800                active.read(cx).thread_error.is_none(),
4801                "The new thread should have no errors"
4802            );
4803        });
4804
4805        conversation_view.update_in(cx, |view, window, cx| view.logout(window, cx));
4806        cx.run_until_parked();
4807
4808        conversation_view.read_with(cx, |view, _cx| {
4809            let connected = view
4810                .as_connected()
4811                .expect("Should still be in Connected state after logout");
4812            assert!(
4813                !connected.auth_state.is_ok(),
4814                "Auth state should be Unauthenticated after logout"
4815            );
4816            assert!(
4817                !view.supports_logout(),
4818                "Logout should be hidden after logout"
4819            );
4820            assert!(
4821                view.active_thread().is_some(),
4822                "The existing thread should still exist after logout"
4823            );
4824            assert!(
4825                !view.active_thread_renders_request_elicitations(),
4826                "Unauthenticated auth UI should render request elicitations outside ThreadView"
4827            );
4828        });
4829    }
4830
4831    #[gpui::test]
4832    async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
4833        init_test(cx);
4834
4835        let tool_call_id = acp::ToolCallId::new("1");
4836        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label")
4837            .kind(acp::ToolKind::Edit)
4838            .content(vec!["hi".into()]);
4839        let connection =
4840            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
4841                tool_call_id,
4842                PermissionOptions::Flat(vec![acp::PermissionOption::new(
4843                    "1",
4844                    "Allow",
4845                    acp::PermissionOptionKind::AllowOnce,
4846                )]),
4847            )]));
4848
4849        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
4850
4851        let (conversation_view, cx) =
4852            setup_conversation_view(StubAgentServer::new(connection), cx).await;
4853
4854        let message_editor = message_editor(&conversation_view, cx);
4855        message_editor.update_in(cx, |editor, window, cx| {
4856            editor.set_text("Hello", window, cx);
4857        });
4858
4859        cx.deactivate_window();
4860
4861        active_thread(&conversation_view, cx)
4862            .update_in(cx, |view, window, cx| view.send(window, cx));
4863
4864        cx.run_until_parked();
4865
4866        assert!(
4867            cx.windows()
4868                .iter()
4869                .any(|window| window.downcast::<AgentNotification>().is_some())
4870        );
4871    }
4872
4873    #[gpui::test]
4874    async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) {
4875        init_test(cx);
4876
4877        let (conversation_view, cx) =
4878            setup_conversation_view(StubAgentServer::default_response(), cx).await;
4879
4880        add_to_workspace(conversation_view.clone(), cx);
4881
4882        let message_editor = message_editor(&conversation_view, cx);
4883
4884        message_editor.update_in(cx, |editor, window, cx| {
4885            editor.set_text("Hello", window, cx);
4886        });
4887
4888        // Window is active (don't deactivate), but panel will be hidden
4889        // Note: In the test environment, the panel is not actually added to the dock,
4890        // so is_agent_panel_hidden will return true
4891
4892        active_thread(&conversation_view, cx)
4893            .update_in(cx, |view, window, cx| view.send(window, cx));
4894
4895        cx.run_until_parked();
4896
4897        // Should show notification because window is active but panel is hidden
4898        assert!(
4899            cx.windows()
4900                .iter()
4901                .any(|window| window.downcast::<AgentNotification>().is_some()),
4902            "Expected notification when panel is hidden"
4903        );
4904    }
4905
4906    #[gpui::test]
4907    async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) {
4908        init_test(cx);
4909
4910        let (conversation_view, cx) =
4911            setup_conversation_view(StubAgentServer::default_response(), cx).await;
4912
4913        let message_editor = message_editor(&conversation_view, cx);
4914        message_editor.update_in(cx, |editor, window, cx| {
4915            editor.set_text("Hello", window, cx);
4916        });
4917
4918        // Deactivate window - should show notification regardless of setting
4919        cx.deactivate_window();
4920
4921        active_thread(&conversation_view, cx)
4922            .update_in(cx, |view, window, cx| view.send(window, cx));
4923
4924        cx.run_until_parked();
4925
4926        // Should still show notification when window is inactive (existing behavior)
4927        assert!(
4928            cx.windows()
4929                .iter()
4930                .any(|window| window.downcast::<AgentNotification>().is_some()),
4931            "Expected notification when window is inactive"
4932        );
4933    }
4934
4935    #[gpui::test]
4936    async fn test_notification_when_different_conversation_is_active_in_visible_panel(
4937        cx: &mut TestAppContext,
4938    ) {
4939        init_test(cx);
4940
4941        let fs = FakeFs::new(cx.executor());
4942
4943        cx.update(|cx| {
4944            cx.update_flags(true, vec!["agent-v2".to_string()]);
4945            agent::ThreadStore::init_global(cx);
4946            language_model::LanguageModelRegistry::test(cx);
4947            <dyn Fs>::set_global(fs.clone(), cx);
4948        });
4949
4950        let project = Project::test(fs, [], cx).await;
4951        let multi_workspace_handle =
4952            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4953
4954        let workspace = multi_workspace_handle
4955            .read_with(cx, |mw, _cx| mw.workspace().clone())
4956            .unwrap();
4957
4958        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
4959
4960        let panel = workspace.update_in(cx, |workspace, window, cx| {
4961            let panel = cx.new(|cx| crate::AgentPanel::new(workspace, window, cx));
4962            workspace.add_panel(panel.clone(), window, cx);
4963            workspace.focus_panel::<crate::AgentPanel>(window, cx);
4964            panel
4965        });
4966
4967        cx.run_until_parked();
4968
4969        panel.update_in(cx, |panel, window, cx| {
4970            panel.open_external_thread_with_server(
4971                Rc::new(StubAgentServer::default_response()),
4972                window,
4973                cx,
4974            );
4975        });
4976
4977        cx.run_until_parked();
4978
4979        panel.read_with(cx, |panel, cx| {
4980            assert!(crate::AgentPanel::is_visible(&workspace, cx));
4981            assert!(panel.active_conversation_view().is_some());
4982        });
4983
4984        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
4985        let connection_store =
4986            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
4987
4988        let conversation_view = cx.update(|window, cx| {
4989            cx.new(|cx| {
4990                ConversationView::new(
4991                    Rc::new(StubAgentServer::default_response()),
4992                    connection_store,
4993                    Agent::Custom { id: "Test".into() },
4994                    None,
4995                    None,
4996                    None,
4997                    None,
4998                    None,
4999                    workspace.downgrade(),
5000                    project.clone(),
5001                    Some(thread_store),
5002                    AgentThreadSource::AgentPanel,
5003                    window,
5004                    cx,
5005                )
5006            })
5007        });
5008
5009        cx.run_until_parked();
5010
5011        panel.read_with(cx, |panel, _cx| {
5012            assert_ne!(
5013                panel
5014                    .active_conversation_view()
5015                    .map(|view| view.entity_id()),
5016                Some(conversation_view.entity_id()),
5017                "The visible panel should still be showing a different conversation"
5018            );
5019        });
5020
5021        let message_editor = message_editor(&conversation_view, cx);
5022        message_editor.update_in(cx, |editor, window, cx| {
5023            editor.set_text("Hello", window, cx);
5024        });
5025
5026        active_thread(&conversation_view, cx)
5027            .update_in(cx, |view, window, cx| view.send(window, cx));
5028
5029        cx.run_until_parked();
5030
5031        assert!(
5032            cx.windows()
5033                .iter()
5034                .any(|window| window.downcast::<AgentNotification>().is_some()),
5035            "Expected notification when a different conversation is active in the visible panel"
5036        );
5037    }
5038
5039    #[gpui::test]
5040    async fn test_no_notification_when_sidebar_open_but_different_thread_focused(
5041        cx: &mut TestAppContext,
5042    ) {
5043        init_test(cx);
5044
5045        let fs = FakeFs::new(cx.executor());
5046
5047        cx.update(|cx| {
5048            cx.update_flags(true, vec!["agent-v2".to_string()]);
5049            agent::ThreadStore::init_global(cx);
5050            language_model::LanguageModelRegistry::test(cx);
5051            <dyn Fs>::set_global(fs.clone(), cx);
5052        });
5053
5054        let project = Project::test(fs, [], cx).await;
5055        let multi_workspace_handle =
5056            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5057
5058        let workspace = multi_workspace_handle
5059            .read_with(cx, |mw, _cx| mw.workspace().clone())
5060            .unwrap();
5061
5062        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
5063        register_test_sidebar(true, cx);
5064
5065        // Open the sidebar so that sidebar_open() returns true.
5066        multi_workspace_handle
5067            .update(cx, |mw, _window, cx| {
5068                mw.open_sidebar(cx);
5069            })
5070            .unwrap();
5071
5072        cx.run_until_parked();
5073
5074        assert!(
5075            multi_workspace_handle
5076                .read_with(cx, |mw, _cx| mw.sidebar_open())
5077                .unwrap(),
5078            "Sidebar should be open"
5079        );
5080
5081        // Create a conversation view that is NOT the active one in the panel.
5082        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
5083        let connection_store =
5084            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
5085
5086        let conversation_view = cx.update(|window, cx| {
5087            cx.new(|cx| {
5088                ConversationView::new(
5089                    Rc::new(StubAgentServer::default_response()),
5090                    connection_store,
5091                    Agent::Custom { id: "Test".into() },
5092                    None,
5093                    None,
5094                    None,
5095                    None,
5096                    None,
5097                    workspace.downgrade(),
5098                    project.clone(),
5099                    Some(thread_store),
5100                    AgentThreadSource::AgentPanel,
5101                    window,
5102                    cx,
5103                )
5104            })
5105        });
5106
5107        cx.run_until_parked();
5108
5109        let message_editor = message_editor(&conversation_view, cx);
5110        message_editor.update_in(cx, |editor, window, cx| {
5111            editor.set_text("Hello", window, cx);
5112        });
5113
5114        active_thread(&conversation_view, cx)
5115            .update_in(cx, |view, window, cx| view.send(window, cx));
5116
5117        cx.run_until_parked();
5118
5119        assert!(
5120            !cx.windows()
5121                .iter()
5122                .any(|window| window.downcast::<AgentNotification>().is_some()),
5123            "Expected no notification when the sidebar is open, even if focused on another thread"
5124        );
5125    }
5126
5127    #[gpui::test]
5128    async fn test_notification_when_sidebar_open_but_thread_list_hidden(cx: &mut TestAppContext) {
5129        init_test(cx);
5130
5131        let fs = FakeFs::new(cx.executor());
5132
5133        cx.update(|cx| {
5134            cx.update_flags(true, vec!["agent-v2".to_string()]);
5135            agent::ThreadStore::init_global(cx);
5136            language_model::LanguageModelRegistry::test(cx);
5137            <dyn Fs>::set_global(fs.clone(), cx);
5138        });
5139
5140        let project = Project::test(fs, [], cx).await;
5141        let multi_workspace_handle =
5142            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5143
5144        let workspace = multi_workspace_handle
5145            .read_with(cx, |mw, _cx| mw.workspace().clone())
5146            .unwrap();
5147
5148        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
5149        register_test_sidebar(false, cx);
5150        multi_workspace_handle
5151            .update(cx, |mw, _window, cx| {
5152                mw.open_sidebar(cx);
5153            })
5154            .unwrap();
5155        cx.run_until_parked();
5156
5157        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
5158        let connection_store =
5159            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
5160
5161        let conversation_view = cx.update(|window, cx| {
5162            cx.new(|cx| {
5163                ConversationView::new(
5164                    Rc::new(StubAgentServer::default_response()),
5165                    connection_store,
5166                    Agent::Custom { id: "Test".into() },
5167                    None,
5168                    None,
5169                    None,
5170                    None,
5171                    None,
5172                    workspace.downgrade(),
5173                    project.clone(),
5174                    Some(thread_store),
5175                    AgentThreadSource::AgentPanel,
5176                    window,
5177                    cx,
5178                )
5179            })
5180        });
5181        cx.run_until_parked();
5182
5183        let message_editor = message_editor(&conversation_view, cx);
5184        message_editor.update_in(cx, |editor, window, cx| {
5185            editor.set_text("Hello", window, cx);
5186        });
5187
5188        active_thread(&conversation_view, cx)
5189            .update_in(cx, |view, window, cx| view.send(window, cx));
5190        cx.run_until_parked();
5191
5192        assert!(
5193            cx.windows()
5194                .iter()
5195                .any(|window| window.downcast::<AgentNotification>().is_some()),
5196            "Expected notification when the sidebar is open but the thread list is hidden"
5197        );
5198    }
5199
5200    #[gpui::test]
5201    async fn test_notification_dismissed_when_sidebar_opens(cx: &mut TestAppContext) {
5202        init_test(cx);
5203
5204        let fs = FakeFs::new(cx.executor());
5205
5206        cx.update(|cx| {
5207            cx.update_flags(true, vec!["agent-v2".to_string()]);
5208            agent::ThreadStore::init_global(cx);
5209            language_model::LanguageModelRegistry::test(cx);
5210            <dyn Fs>::set_global(fs.clone(), cx);
5211        });
5212
5213        let project = Project::test(fs, [], cx).await;
5214        let multi_workspace_handle =
5215            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5216
5217        let workspace = multi_workspace_handle
5218            .read_with(cx, |mw, _cx| mw.workspace().clone())
5219            .unwrap();
5220
5221        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
5222        register_test_sidebar(true, cx);
5223
5224        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
5225        let connection_store =
5226            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
5227
5228        let conversation_view = cx.update(|window, cx| {
5229            cx.new(|cx| {
5230                ConversationView::new(
5231                    Rc::new(StubAgentServer::default_response()),
5232                    connection_store,
5233                    Agent::Custom { id: "Test".into() },
5234                    None,
5235                    None,
5236                    None,
5237                    None,
5238                    None,
5239                    workspace.downgrade(),
5240                    project.clone(),
5241                    Some(thread_store),
5242                    AgentThreadSource::AgentPanel,
5243                    window,
5244                    cx,
5245                )
5246            })
5247        });
5248
5249        cx.run_until_parked();
5250
5251        let message_editor = message_editor(&conversation_view, cx);
5252        message_editor.update_in(cx, |editor, window, cx| {
5253            editor.set_text("Hello", window, cx);
5254        });
5255
5256        active_thread(&conversation_view, cx)
5257            .update_in(cx, |view, window, cx| view.send(window, cx));
5258
5259        cx.run_until_parked();
5260
5261        assert_eq!(
5262            cx.windows()
5263                .iter()
5264                .filter(|window| window.downcast::<AgentNotification>().is_some())
5265                .count(),
5266            1,
5267            "Expected a notification while the thread is not visible"
5268        );
5269
5270        multi_workspace_handle
5271            .update(cx, |mw, _window, cx| {
5272                mw.open_sidebar(cx);
5273            })
5274            .unwrap();
5275
5276        cx.run_until_parked();
5277
5278        assert_eq!(
5279            cx.windows()
5280                .iter()
5281                .filter(|window| window.downcast::<AgentNotification>().is_some())
5282                .count(),
5283            0,
5284            "Notification should auto-dismiss when the sidebar opens and makes the thread visible"
5285        );
5286    }
5287
5288    #[gpui::test]
5289    async fn test_notification_when_workspace_is_background_in_multi_workspace(
5290        cx: &mut TestAppContext,
5291    ) {
5292        init_test(cx);
5293
5294        // Enable multi-workspace feature flag and init globals needed by AgentPanel
5295        let fs = FakeFs::new(cx.executor());
5296
5297        cx.update(|cx| {
5298            agent::ThreadStore::init_global(cx);
5299            language_model::LanguageModelRegistry::test(cx);
5300            <dyn Fs>::set_global(fs.clone(), cx);
5301        });
5302
5303        let project1 = Project::test(fs.clone(), [], cx).await;
5304
5305        // Create a MultiWorkspace window with one workspace
5306        let multi_workspace_handle =
5307            cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
5308
5309        // Get workspace 1 (the initial workspace)
5310        let workspace1 = multi_workspace_handle
5311            .read_with(cx, |mw, _cx| mw.workspace().clone())
5312            .unwrap();
5313
5314        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
5315
5316        let panel = workspace1.update_in(cx, |workspace, window, cx| {
5317            let panel = cx.new(|cx| crate::AgentPanel::new(workspace, window, cx));
5318            workspace.add_panel(panel.clone(), window, cx);
5319
5320            // Open the dock and activate the agent panel so it's visible
5321            workspace.focus_panel::<crate::AgentPanel>(window, cx);
5322            panel
5323        });
5324
5325        cx.run_until_parked();
5326
5327        panel.update_in(cx, |panel, window, cx| {
5328            panel.open_external_thread_with_server(
5329                Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
5330                window,
5331                cx,
5332            );
5333        });
5334
5335        cx.run_until_parked();
5336
5337        cx.read(|cx| {
5338            assert!(
5339                crate::AgentPanel::is_visible(&workspace1, cx),
5340                "AgentPanel should be visible in workspace1's dock"
5341            );
5342        });
5343
5344        // Set up thread view in workspace 1
5345        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
5346        let connection_store =
5347            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
5348
5349        let conversation_view = cx.update(|window, cx| {
5350            cx.new(|cx| {
5351                ConversationView::new(
5352                    Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
5353                    connection_store,
5354                    Agent::Custom { id: "Test".into() },
5355                    None,
5356                    None,
5357                    None,
5358                    None,
5359                    None,
5360                    workspace1.downgrade(),
5361                    project1.clone(),
5362                    Some(thread_store),
5363                    AgentThreadSource::AgentPanel,
5364                    window,
5365                    cx,
5366                )
5367            })
5368        });
5369        cx.run_until_parked();
5370
5371        let root_session_id = conversation_view
5372            .read_with(cx, |view, cx| {
5373                view.root_thread_view()
5374                    .map(|thread| thread.read(cx).thread.read(cx).session_id().clone())
5375            })
5376            .expect("Conversation view should have a root thread");
5377
5378        let message_editor = message_editor(&conversation_view, cx);
5379        message_editor.update_in(cx, |editor, window, cx| {
5380            editor.set_text("Hello", window, cx);
5381        });
5382
5383        // Create a second workspace and switch to it.
5384        // This makes workspace1 the "background" workspace.
5385        let project2 = Project::test(fs, [], cx).await;
5386        multi_workspace_handle
5387            .update(cx, |mw, window, cx| {
5388                mw.test_add_workspace(project2, window, cx);
5389            })
5390            .unwrap();
5391
5392        cx.run_until_parked();
5393
5394        // Verify workspace1 is no longer the active workspace
5395        multi_workspace_handle
5396            .read_with(cx, |mw, _cx| {
5397                assert_ne!(mw.workspace(), &workspace1);
5398            })
5399            .unwrap();
5400
5401        // Window is active, agent panel is visible in workspace1, but workspace1
5402        // is in the background. The notification should show because the user
5403        // can't actually see the agent panel.
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        assert!(
5410            cx.windows()
5411                .iter()
5412                .any(|window| window.downcast::<AgentNotification>().is_some()),
5413            "Expected notification when workspace is in background within MultiWorkspace"
5414        );
5415
5416        // Also verify: clicking "View Panel" should switch to workspace1.
5417        cx.windows()
5418            .iter()
5419            .find_map(|window| window.downcast::<AgentNotification>())
5420            .unwrap()
5421            .update(cx, |window, _, cx| window.accept(cx))
5422            .unwrap();
5423
5424        cx.run_until_parked();
5425
5426        multi_workspace_handle
5427            .read_with(cx, |mw, _cx| {
5428                assert_eq!(
5429                    mw.workspace(),
5430                    &workspace1,
5431                    "Expected workspace1 to become the active workspace after accepting notification"
5432                );
5433            })
5434            .unwrap();
5435
5436        panel.read_with(cx, |panel, cx| {
5437            let active_session_id = panel
5438                .active_agent_thread(cx)
5439                .map(|thread| thread.read(cx).session_id().clone());
5440            assert_eq!(
5441                active_session_id,
5442                Some(root_session_id),
5443                "Expected accepting the notification to load the notified thread in AgentPanel"
5444            );
5445        });
5446    }
5447
5448    #[gpui::test]
5449    async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
5450        init_test(cx);
5451
5452        // Set notify_when_agent_waiting to Never
5453        cx.update(|cx| {
5454            AgentSettings::override_global(
5455                AgentSettings {
5456                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
5457                    ..AgentSettings::get_global(cx).clone()
5458                },
5459                cx,
5460            );
5461        });
5462
5463        let (conversation_view, cx) =
5464            setup_conversation_view(StubAgentServer::default_response(), cx).await;
5465
5466        let message_editor = message_editor(&conversation_view, cx);
5467        message_editor.update_in(cx, |editor, window, cx| {
5468            editor.set_text("Hello", window, cx);
5469        });
5470
5471        // Window is active
5472
5473        active_thread(&conversation_view, cx)
5474            .update_in(cx, |view, window, cx| view.send(window, cx));
5475
5476        cx.run_until_parked();
5477
5478        // Should NOT show notification because notify_when_agent_waiting is Never
5479        assert!(
5480            !cx.windows()
5481                .iter()
5482                .any(|window| window.downcast::<AgentNotification>().is_some()),
5483            "Expected no notification when notify_when_agent_waiting is Never"
5484        );
5485    }
5486
5487    #[gpui::test]
5488    async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
5489        init_test(cx);
5490
5491        let (conversation_view, cx) =
5492            setup_conversation_view(StubAgentServer::default_response(), cx).await;
5493
5494        let weak_view = conversation_view.downgrade();
5495
5496        let message_editor = message_editor(&conversation_view, cx);
5497        message_editor.update_in(cx, |editor, window, cx| {
5498            editor.set_text("Hello", window, cx);
5499        });
5500
5501        cx.deactivate_window();
5502
5503        active_thread(&conversation_view, cx)
5504            .update_in(cx, |view, window, cx| view.send(window, cx));
5505
5506        cx.run_until_parked();
5507
5508        // Verify notification is shown
5509        assert!(
5510            cx.windows()
5511                .iter()
5512                .any(|window| window.downcast::<AgentNotification>().is_some()),
5513            "Expected notification to be shown"
5514        );
5515
5516        // Drop the thread view (simulating navigation to a new thread)
5517        drop(conversation_view);
5518        drop(message_editor);
5519        // Trigger an update to flush effects, which will call release_dropped_entities
5520        cx.update(|_window, _cx| {});
5521        cx.run_until_parked();
5522
5523        // Verify the entity was actually released
5524        assert!(
5525            !weak_view.is_upgradable(),
5526            "Thread view entity should be released after dropping"
5527        );
5528
5529        // The notification should be automatically closed via on_release
5530        assert!(
5531            !cx.windows()
5532                .iter()
5533                .any(|window| window.downcast::<AgentNotification>().is_some()),
5534            "Notification should be closed when thread view is dropped"
5535        );
5536    }
5537
5538    async fn setup_conversation_view(
5539        agent: impl AgentServer + 'static,
5540        cx: &mut TestAppContext,
5541    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
5542        setup_conversation_view_with_initial_content_opt(agent, None, cx).await
5543    }
5544
5545    #[gpui::test]
5546    async fn test_completed_plan_snapshot_keeps_list_state_in_sync(cx: &mut TestAppContext) {
5547        init_test(cx);
5548
5549        let connection = StubAgentConnection::new();
5550        let (conversation_view, cx) =
5551            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
5552
5553        message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| {
5554            editor.set_text("Hello", window, cx);
5555        });
5556        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
5557            view.send(window, cx);
5558        });
5559        cx.run_until_parked();
5560
5561        let session_id = active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
5562            assert_thread_list_item_count_matches_entries(view, cx);
5563            view.thread.read(cx).session_id().clone()
5564        });
5565
5566        cx.update(|_, cx| {
5567            connection.send_update(
5568                session_id.clone(),
5569                acp::SessionUpdate::Plan(acp::Plan::new(vec![acp::PlanEntry::new(
5570                    "Do the thing",
5571                    acp::PlanEntryPriority::Medium,
5572                    acp::PlanEntryStatus::InProgress,
5573                )])),
5574                cx,
5575            );
5576        });
5577        cx.run_until_parked();
5578        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
5579            assert_thread_list_item_count_matches_entries(view, cx);
5580        });
5581
5582        cx.update(|_, cx| {
5583            connection.send_update(
5584                session_id.clone(),
5585                acp::SessionUpdate::Plan(acp::Plan::new(vec![acp::PlanEntry::new(
5586                    "Do the thing",
5587                    acp::PlanEntryPriority::Medium,
5588                    acp::PlanEntryStatus::Completed,
5589                )])),
5590                cx,
5591            );
5592        });
5593        cx.run_until_parked();
5594        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
5595            assert_thread_list_item_count_matches_entries(view, cx);
5596        });
5597
5598        connection.end_turn(session_id, acp::StopReason::EndTurn);
5599        cx.run_until_parked();
5600        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
5601            assert_thread_list_item_count_matches_entries(view, cx);
5602        });
5603    }
5604
5605    async fn setup_conversation_view_with_initial_content(
5606        agent: impl AgentServer + 'static,
5607        initial_content: AgentInitialContent,
5608        cx: &mut TestAppContext,
5609    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
5610        setup_conversation_view_with_initial_content_opt(agent, Some(initial_content), cx).await
5611    }
5612
5613    async fn setup_conversation_view_with_initial_content_opt(
5614        agent: impl AgentServer + 'static,
5615        initial_content: Option<AgentInitialContent>,
5616        cx: &mut TestAppContext,
5617    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
5618        let fs = FakeFs::new(cx.executor());
5619        let project = Project::test(fs, [], cx).await;
5620        let (multi_workspace, cx) =
5621            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5622        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5623
5624        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
5625        let connection_store =
5626            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
5627
5628        let agent_key = Agent::Custom { id: "Test".into() };
5629
5630        let conversation_view = cx.update(|window, cx| {
5631            cx.new(|cx| {
5632                ConversationView::new(
5633                    Rc::new(agent),
5634                    connection_store.clone(),
5635                    agent_key.clone(),
5636                    None,
5637                    None,
5638                    None,
5639                    None,
5640                    initial_content,
5641                    workspace.downgrade(),
5642                    project,
5643                    Some(thread_store),
5644                    AgentThreadSource::AgentPanel,
5645                    window,
5646                    cx,
5647                )
5648            })
5649        });
5650        cx.run_until_parked();
5651
5652        (conversation_view, cx)
5653    }
5654
5655    fn add_to_workspace(conversation_view: Entity<ConversationView>, cx: &mut VisualTestContext) {
5656        let workspace =
5657            conversation_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
5658
5659        workspace
5660            .update_in(cx, |workspace, window, cx| {
5661                workspace.add_item_to_active_pane(
5662                    Box::new(cx.new(|_| ThreadViewItem(conversation_view.clone()))),
5663                    None,
5664                    true,
5665                    window,
5666                    cx,
5667                );
5668            })
5669            .unwrap();
5670    }
5671
5672    struct ThreadViewItem(Entity<ConversationView>);
5673
5674    impl Item for ThreadViewItem {
5675        type Event = ();
5676
5677        fn include_in_nav_history() -> bool {
5678            false
5679        }
5680
5681        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
5682            "Test".into()
5683        }
5684    }
5685
5686    impl EventEmitter<()> for ThreadViewItem {}
5687
5688    impl Focusable for ThreadViewItem {
5689        fn focus_handle(&self, cx: &App) -> FocusHandle {
5690            self.0.read(cx).focus_handle(cx)
5691        }
5692    }
5693
5694    impl Render for ThreadViewItem {
5695        fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5696            // Render the title editor in the element tree too. In the real app
5697            // it is part of the agent panel
5698            let title_editor = self
5699                .0
5700                .read(cx)
5701                .active_thread()
5702                .map(|t| t.read(cx).title_editor.clone());
5703
5704            v_flex().children(title_editor).child(self.0.clone())
5705        }
5706    }
5707
5708    pub(crate) struct StubAgentServer<C> {
5709        connection: C,
5710    }
5711
5712    impl<C> StubAgentServer<C> {
5713        pub(crate) fn new(connection: C) -> Self {
5714            Self { connection }
5715        }
5716    }
5717
5718    impl StubAgentServer<StubAgentConnection> {
5719        pub(crate) fn default_response() -> Self {
5720            let conn = StubAgentConnection::new();
5721            conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5722                acp::ContentChunk::new("Default response".into()),
5723            )]);
5724            Self::new(conn)
5725        }
5726    }
5727
5728    impl<C> AgentServer for StubAgentServer<C>
5729    where
5730        C: 'static + AgentConnection + Send + Clone,
5731    {
5732        fn logo(&self) -> ui::IconName {
5733            ui::IconName::OmegaAgent
5734        }
5735
5736        fn agent_id(&self) -> AgentId {
5737            "Test".into()
5738        }
5739
5740        fn connect(
5741            &self,
5742            _delegate: AgentServerDelegate,
5743            _project: Entity<Project>,
5744            _cx: &mut App,
5745        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
5746            Task::ready(Ok(Rc::new(self.connection.clone())))
5747        }
5748
5749        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5750            self
5751        }
5752    }
5753
5754    struct FailingAgentServer;
5755
5756    impl AgentServer for FailingAgentServer {
5757        fn logo(&self) -> ui::IconName {
5758            ui::IconName::AiOpenAi
5759        }
5760
5761        fn agent_id(&self) -> AgentId {
5762            AgentId::new("Codex CLI")
5763        }
5764
5765        fn connect(
5766            &self,
5767            _delegate: AgentServerDelegate,
5768            _project: Entity<Project>,
5769            _cx: &mut App,
5770        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
5771            Task::ready(Err(anyhow!(
5772                "extracting downloaded asset for \
5773                 https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\
5774                 codex-acp-0.9.4-aarch64-pc-windows-msvc.zip: \
5775                 failed to iterate over archive: Invalid gzip header"
5776            )))
5777        }
5778
5779        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5780            self
5781        }
5782    }
5783
5784    /// Agent server whose `connect()` fails while `fail` is `true` and
5785    /// returns the wrapped connection otherwise. Used to simulate the
5786    /// race where an external agent isn't yet registered at startup.
5787    pub(crate) struct FlakyAgentServer {
5788        connection: StubAgentConnection,
5789        fail: Arc<std::sync::atomic::AtomicBool>,
5790    }
5791
5792    impl FlakyAgentServer {
5793        pub(crate) fn new(
5794            connection: StubAgentConnection,
5795        ) -> (Self, Arc<std::sync::atomic::AtomicBool>) {
5796            let fail = Arc::new(std::sync::atomic::AtomicBool::new(true));
5797            (
5798                Self {
5799                    connection,
5800                    fail: fail.clone(),
5801                },
5802                fail,
5803            )
5804        }
5805    }
5806
5807    impl AgentServer for FlakyAgentServer {
5808        fn logo(&self) -> ui::IconName {
5809            ui::IconName::OmegaAgent
5810        }
5811
5812        fn agent_id(&self) -> AgentId {
5813            "Flaky".into()
5814        }
5815
5816        fn connect(
5817            &self,
5818            _delegate: AgentServerDelegate,
5819            _project: Entity<Project>,
5820            _cx: &mut App,
5821        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
5822            if self.fail.load(std::sync::atomic::Ordering::SeqCst) {
5823                Task::ready(Err(anyhow!(
5824                    "Custom agent server `Flaky` is not registered"
5825                )))
5826            } else {
5827                Task::ready(Ok(Rc::new(self.connection.clone())))
5828            }
5829        }
5830
5831        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5832            self
5833        }
5834    }
5835
5836    fn build_test_thread(
5837        connection: Rc<dyn AgentConnection>,
5838        project: Entity<Project>,
5839        name: &'static str,
5840        session_id: acp::SessionId,
5841        cx: &mut App,
5842    ) -> Entity<AcpThread> {
5843        let action_log = cx.new(|_| ActionLog::new(project.clone()));
5844        cx.new(|cx| {
5845            AcpThread::new(
5846                None,
5847                Some(name.into()),
5848                None,
5849                connection,
5850                project,
5851                action_log,
5852                session_id,
5853                watch::Receiver::constant(
5854                    acp::PromptCapabilities::new()
5855                        .image(true)
5856                        .audio(true)
5857                        .embedded_context(true),
5858                ),
5859                cx,
5860            )
5861        })
5862    }
5863
5864    #[derive(Clone, Default)]
5865    struct PreloadedElicitationConnection {
5866        elicitation_id: Arc<Mutex<Option<ElicitationEntryId>>>,
5867    }
5868
5869    impl AgentConnection for PreloadedElicitationConnection {
5870        fn agent_id(&self) -> AgentId {
5871            AgentId::new("preloaded-elicitation")
5872        }
5873
5874        fn telemetry_id(&self) -> SharedString {
5875            "preloaded-elicitation".into()
5876        }
5877
5878        fn new_session(
5879            self: Rc<Self>,
5880            project: Entity<Project>,
5881            _work_dirs: PathList,
5882            cx: &mut App,
5883        ) -> Task<gpui::Result<Entity<AcpThread>>> {
5884            let session_id = acp::SessionId::new("new-session");
5885            let thread = build_test_thread(
5886                self.clone(),
5887                project,
5888                "PreloadedElicitationConnection",
5889                session_id.clone(),
5890                cx,
5891            );
5892            thread.update(cx, |thread, cx| {
5893                thread
5894                    .request_elicitation(
5895                        acp::CreateElicitationRequest::new(
5896                            acp::ElicitationFormMode::new(
5897                                acp::ElicitationSessionScope::new(session_id),
5898                                acp::ElicitationSchema::new().string("name", true),
5899                            ),
5900                            "Provide a name",
5901                        ),
5902                        cx,
5903                    )
5904                    .expect("preloaded elicitation should be accepted")
5905                    .detach();
5906            });
5907            let elicitation_id = thread.read_with(cx, |thread, _cx| {
5908                thread.entries().iter().find_map(|entry| {
5909                    if let AgentThreadEntry::Elicitation(elicitation_id) = entry {
5910                        Some(elicitation_id.clone())
5911                    } else {
5912                        None
5913                    }
5914                })
5915            });
5916            *self.elicitation_id.lock() = elicitation_id;
5917            Task::ready(Ok(thread))
5918        }
5919
5920        fn auth_methods(&self) -> &[acp::AuthMethod] {
5921            &[]
5922        }
5923
5924        fn authenticate(
5925            &self,
5926            _method_id: acp::AuthMethodId,
5927            _cx: &mut App,
5928        ) -> Task<gpui::Result<()>> {
5929            Task::ready(Ok(()))
5930        }
5931
5932        fn prompt(
5933            &self,
5934            _params: acp::PromptRequest,
5935            _cx: &mut App,
5936        ) -> Task<gpui::Result<acp::PromptResponse>> {
5937            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
5938        }
5939
5940        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
5941
5942        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5943            self
5944        }
5945    }
5946
5947    struct SessionCreationRequestElicitationServer {
5948        store: Entity<ElicitationStore>,
5949        response: Arc<Mutex<Option<acp::ElicitationAction>>>,
5950    }
5951
5952    impl AgentServer for SessionCreationRequestElicitationServer {
5953        fn logo(&self) -> ui::IconName {
5954            ui::IconName::OmegaAgent
5955        }
5956
5957        fn agent_id(&self) -> AgentId {
5958            "SessionCreationRequestElicitation".into()
5959        }
5960
5961        fn connect(
5962            &self,
5963            _delegate: AgentServerDelegate,
5964            _project: Entity<Project>,
5965            _cx: &mut App,
5966        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
5967            let connection = SessionCreationRequestElicitationConnection {
5968                store: self.store.clone(),
5969                response: self.response.clone(),
5970            };
5971            Task::ready(Ok(Rc::new(connection)))
5972        }
5973
5974        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
5975            self
5976        }
5977    }
5978
5979    struct SessionCreationRequestElicitationConnection {
5980        store: Entity<ElicitationStore>,
5981        response: Arc<Mutex<Option<acp::ElicitationAction>>>,
5982    }
5983
5984    impl AgentConnection for SessionCreationRequestElicitationConnection {
5985        fn agent_id(&self) -> AgentId {
5986            AgentId::new("session-creation-request-elicitation")
5987        }
5988
5989        fn telemetry_id(&self) -> SharedString {
5990            "session-creation-request-elicitation".into()
5991        }
5992
5993        fn new_session(
5994            self: Rc<Self>,
5995            project: Entity<Project>,
5996            _work_dirs: PathList,
5997            cx: &mut App,
5998        ) -> Task<gpui::Result<Entity<AcpThread>>> {
5999            let thread = build_test_thread(
6000                self.clone(),
6001                project,
6002                "SessionCreationRequestElicitationConnection",
6003                acp::SessionId::new("session-creation-request-elicitation-session"),
6004                cx,
6005            );
6006            let first_response_task = self.store.update(cx, |store, cx| {
6007                store
6008                    .request_elicitation(
6009                        acp::CreateElicitationRequest::new(
6010                            acp::ElicitationFormMode::new(
6011                                acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
6012                                acp::ElicitationSchema::new().string("name", true),
6013                            ),
6014                            "Provide a name",
6015                        ),
6016                        cx,
6017                    )
6018                    .expect("first request-scoped elicitation should be accepted")
6019            });
6020            self.store
6021                .update(cx, |store, cx| {
6022                    store
6023                        .request_elicitation(
6024                            acp::CreateElicitationRequest::new(
6025                                acp::ElicitationFormMode::new(
6026                                    acp::ElicitationRequestScope::new(acp::RequestId::Number(2)),
6027                                    acp::ElicitationSchema::new().string("account", true),
6028                                ),
6029                                "Provide an account",
6030                            ),
6031                            cx,
6032                        )
6033                        .expect("second request-scoped elicitation should be accepted")
6034                })
6035                .detach();
6036
6037            let response = self.response.clone();
6038            cx.spawn(async move |_cx| {
6039                let elicitation_response = first_response_task.await;
6040                *response.lock() = Some(elicitation_response.action);
6041                Ok(thread)
6042            })
6043        }
6044
6045        fn request_elicitations(&self) -> Option<Entity<ElicitationStore>> {
6046            Some(self.store.clone())
6047        }
6048
6049        fn auth_methods(&self) -> &[acp::AuthMethod] {
6050            &[]
6051        }
6052
6053        fn authenticate(
6054            &self,
6055            _method_id: acp::AuthMethodId,
6056            _cx: &mut App,
6057        ) -> Task<gpui::Result<()>> {
6058            Task::ready(Ok(()))
6059        }
6060
6061        fn prompt(
6062            &self,
6063            _params: acp::PromptRequest,
6064            _cx: &mut App,
6065        ) -> Task<gpui::Result<acp::PromptResponse>> {
6066            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
6067        }
6068
6069        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
6070
6071        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
6072            self
6073        }
6074    }
6075
6076    struct ReleaseRequestElicitationServer {
6077        response: Arc<Mutex<Option<acp::ElicitationAction>>>,
6078    }
6079
6080    impl AgentServer for ReleaseRequestElicitationServer {
6081        fn logo(&self) -> ui::IconName {
6082            ui::IconName::OmegaAgent
6083        }
6084
6085        fn agent_id(&self) -> AgentId {
6086            "ReleaseRequestElicitation".into()
6087        }
6088
6089        fn connect(
6090            &self,
6091            _delegate: AgentServerDelegate,
6092            _project: Entity<Project>,
6093            cx: &mut App,
6094        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
6095            let connection = ReleaseRequestElicitationConnection {
6096                store: cx.new(|_| ElicitationStore::default()),
6097                response: self.response.clone(),
6098            };
6099            Task::ready(Ok(Rc::new(connection)))
6100        }
6101
6102        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
6103            self
6104        }
6105    }
6106
6107    struct ReleaseRequestElicitationConnection {
6108        store: Entity<ElicitationStore>,
6109        response: Arc<Mutex<Option<acp::ElicitationAction>>>,
6110    }
6111
6112    impl AgentConnection for ReleaseRequestElicitationConnection {
6113        fn agent_id(&self) -> AgentId {
6114            AgentId::new("release-request-elicitation")
6115        }
6116
6117        fn telemetry_id(&self) -> SharedString {
6118            "release-request-elicitation".into()
6119        }
6120
6121        fn new_session(
6122            self: Rc<Self>,
6123            project: Entity<Project>,
6124            _work_dirs: PathList,
6125            cx: &mut App,
6126        ) -> Task<gpui::Result<Entity<AcpThread>>> {
6127            let thread = build_test_thread(
6128                self.clone(),
6129                project,
6130                "ReleaseRequestElicitationConnection",
6131                acp::SessionId::new("release-request-elicitation-session"),
6132                cx,
6133            );
6134            let response_task = self.store.update(cx, |store, cx| {
6135                store
6136                    .request_elicitation(
6137                        acp::CreateElicitationRequest::new(
6138                            acp::ElicitationFormMode::new(
6139                                acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
6140                                acp::ElicitationSchema::new().string("name", true),
6141                            ),
6142                            "Provide a name",
6143                        ),
6144                        cx,
6145                    )
6146                    .expect("request-scoped elicitation should be accepted")
6147            });
6148            let response = self.response.clone();
6149            cx.spawn(async move |_cx| {
6150                let elicitation_response = response_task.await;
6151                *response.lock() = Some(elicitation_response.action);
6152            })
6153            .detach();
6154            Task::ready(Ok(thread))
6155        }
6156
6157        fn request_elicitations(&self) -> Option<Entity<ElicitationStore>> {
6158            Some(self.store.clone())
6159        }
6160
6161        fn auth_methods(&self) -> &[acp::AuthMethod] {
6162            &[]
6163        }
6164
6165        fn authenticate(
6166            &self,
6167            _method_id: acp::AuthMethodId,
6168            _cx: &mut App,
6169        ) -> Task<gpui::Result<()>> {
6170            Task::ready(Ok(()))
6171        }
6172
6173        fn prompt(
6174            &self,
6175            _params: acp::PromptRequest,
6176            _cx: &mut App,
6177        ) -> Task<gpui::Result<acp::PromptResponse>> {
6178            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
6179        }
6180
6181        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
6182
6183        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
6184            self
6185        }
6186    }
6187
6188    #[derive(Clone)]
6189    struct ResumeOnlyAgentConnection;
6190
6191    impl AgentConnection for ResumeOnlyAgentConnection {
6192        fn agent_id(&self) -> AgentId {
6193            AgentId::new("resume-only")
6194        }
6195
6196        fn telemetry_id(&self) -> SharedString {
6197            "resume-only".into()
6198        }
6199
6200        fn new_session(
6201            self: Rc<Self>,
6202            project: Entity<Project>,
6203            _work_dirs: PathList,
6204            cx: &mut gpui::App,
6205        ) -> Task<gpui::Result<Entity<AcpThread>>> {
6206            let thread = build_test_thread(
6207                self,
6208                project,
6209                "ResumeOnlyAgentConnection",
6210                acp::SessionId::new("new-session"),
6211                cx,
6212            );
6213            Task::ready(Ok(thread))
6214        }
6215
6216        fn supports_resume_session(&self) -> bool {
6217            true
6218        }
6219
6220        fn resume_session(
6221            self: Rc<Self>,
6222            session_id: acp::SessionId,
6223            project: Entity<Project>,
6224            _work_dirs: PathList,
6225            _title: Option<SharedString>,
6226            cx: &mut App,
6227        ) -> Task<gpui::Result<Entity<AcpThread>>> {
6228            let thread =
6229                build_test_thread(self, project, "ResumeOnlyAgentConnection", session_id, cx);
6230            Task::ready(Ok(thread))
6231        }
6232
6233        fn auth_methods(&self) -> &[acp::AuthMethod] {
6234            &[]
6235        }
6236
6237        fn authenticate(
6238            &self,
6239            _method_id: acp::AuthMethodId,
6240            _cx: &mut App,
6241        ) -> Task<gpui::Result<()>> {
6242            Task::ready(Ok(()))
6243        }
6244
6245        fn prompt(
6246            &self,
6247            _params: acp::PromptRequest,
6248            _cx: &mut App,
6249        ) -> Task<gpui::Result<acp::PromptResponse>> {
6250            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
6251        }
6252
6253        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
6254
6255        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
6256            self
6257        }
6258    }
6259
6260    /// Simulates an agent that requires authentication before a session can be
6261    /// created. `new_session` returns `AuthRequired` until `authenticate` is
6262    /// called with the correct method, after which sessions are created normally.
6263    #[derive(Clone)]
6264    struct AuthGatedAgentConnection {
6265        authenticated: Arc<Mutex<bool>>,
6266        auth_method: acp::AuthMethod,
6267    }
6268
6269    impl AuthGatedAgentConnection {
6270        const AUTH_METHOD_ID: &str = "test-login";
6271
6272        fn new() -> Self {
6273            Self {
6274                authenticated: Arc::new(Mutex::new(false)),
6275                auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
6276                    Self::AUTH_METHOD_ID,
6277                    "Test Login",
6278                )),
6279            }
6280        }
6281    }
6282
6283    impl AgentConnection for AuthGatedAgentConnection {
6284        fn agent_id(&self) -> AgentId {
6285            AgentId::new("auth-gated")
6286        }
6287
6288        fn telemetry_id(&self) -> SharedString {
6289            "auth-gated".into()
6290        }
6291
6292        fn new_session(
6293            self: Rc<Self>,
6294            project: Entity<Project>,
6295            work_dirs: PathList,
6296            cx: &mut gpui::App,
6297        ) -> Task<gpui::Result<Entity<AcpThread>>> {
6298            if !*self.authenticated.lock() {
6299                return Task::ready(Err(acp_thread::AuthRequired::new()
6300                    .with_description("Sign in to continue".to_string())
6301                    .into()));
6302            }
6303
6304            let session_id = acp::SessionId::new("auth-gated-session");
6305            let action_log = cx.new(|_| ActionLog::new(project.clone()));
6306            Task::ready(Ok(cx.new(|cx| {
6307                AcpThread::new(
6308                    None,
6309                    None,
6310                    Some(work_dirs),
6311                    self,
6312                    project,
6313                    action_log,
6314                    session_id,
6315                    watch::Receiver::constant(
6316                        acp::PromptCapabilities::new()
6317                            .image(true)
6318                            .audio(true)
6319                            .embedded_context(true),
6320                    ),
6321                    cx,
6322                )
6323            })))
6324        }
6325
6326        fn auth_methods(&self) -> &[acp::AuthMethod] {
6327            std::slice::from_ref(&self.auth_method)
6328        }
6329
6330        fn authenticate(
6331            &self,
6332            method_id: acp::AuthMethodId,
6333            _cx: &mut App,
6334        ) -> Task<gpui::Result<()>> {
6335            if &method_id == self.auth_method.id() {
6336                *self.authenticated.lock() = true;
6337                Task::ready(Ok(()))
6338            } else {
6339                Task::ready(Err(anyhow::anyhow!("Unknown auth method")))
6340            }
6341        }
6342
6343        fn supports_logout(&self) -> bool {
6344            true
6345        }
6346
6347        fn logout(&self, _cx: &mut App) -> Task<gpui::Result<()>> {
6348            *self.authenticated.lock() = false;
6349            Task::ready(Ok(()))
6350        }
6351
6352        fn prompt(
6353            &self,
6354            _params: acp::PromptRequest,
6355            _cx: &mut App,
6356        ) -> Task<gpui::Result<acp::PromptResponse>> {
6357            unimplemented!()
6358        }
6359
6360        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
6361            unimplemented!()
6362        }
6363
6364        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
6365            self
6366        }
6367    }
6368
6369    /// Simulates a model which always returns a refusal response
6370    #[derive(Clone)]
6371    struct RefusalAgentConnection;
6372
6373    impl AgentConnection for RefusalAgentConnection {
6374        fn agent_id(&self) -> AgentId {
6375            AgentId::new("refusal")
6376        }
6377
6378        fn telemetry_id(&self) -> SharedString {
6379            "refusal".into()
6380        }
6381
6382        fn new_session(
6383            self: Rc<Self>,
6384            project: Entity<Project>,
6385            work_dirs: PathList,
6386            cx: &mut gpui::App,
6387        ) -> Task<gpui::Result<Entity<AcpThread>>> {
6388            Task::ready(Ok(cx.new(|cx| {
6389                let action_log = cx.new(|_| ActionLog::new(project.clone()));
6390                AcpThread::new(
6391                    None,
6392                    None,
6393                    Some(work_dirs),
6394                    self,
6395                    project,
6396                    action_log,
6397                    acp::SessionId::new("test"),
6398                    watch::Receiver::constant(
6399                        acp::PromptCapabilities::new()
6400                            .image(true)
6401                            .audio(true)
6402                            .embedded_context(true),
6403                    ),
6404                    cx,
6405                )
6406            })))
6407        }
6408
6409        fn auth_methods(&self) -> &[acp::AuthMethod] {
6410            &[]
6411        }
6412
6413        fn authenticate(
6414            &self,
6415            _method_id: acp::AuthMethodId,
6416            _cx: &mut App,
6417        ) -> Task<gpui::Result<()>> {
6418            unimplemented!()
6419        }
6420
6421        fn prompt(
6422            &self,
6423            _params: acp::PromptRequest,
6424            _cx: &mut App,
6425        ) -> Task<gpui::Result<acp::PromptResponse>> {
6426            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal)))
6427        }
6428
6429        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
6430            unimplemented!()
6431        }
6432
6433        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
6434            self
6435        }
6436    }
6437
6438    #[derive(Clone)]
6439    struct CwdCapturingConnection {
6440        captured_work_dirs: Arc<Mutex<Option<PathList>>>,
6441    }
6442
6443    impl CwdCapturingConnection {
6444        fn new() -> Self {
6445            Self {
6446                captured_work_dirs: Arc::new(Mutex::new(None)),
6447            }
6448        }
6449    }
6450
6451    impl AgentConnection for CwdCapturingConnection {
6452        fn agent_id(&self) -> AgentId {
6453            AgentId::new("cwd-capturing")
6454        }
6455
6456        fn telemetry_id(&self) -> SharedString {
6457            "cwd-capturing".into()
6458        }
6459
6460        fn new_session(
6461            self: Rc<Self>,
6462            project: Entity<Project>,
6463            work_dirs: PathList,
6464            cx: &mut gpui::App,
6465        ) -> Task<gpui::Result<Entity<AcpThread>>> {
6466            *self.captured_work_dirs.lock() = Some(work_dirs.clone());
6467            let action_log = cx.new(|_| ActionLog::new(project.clone()));
6468            let thread = cx.new(|cx| {
6469                AcpThread::new(
6470                    None,
6471                    None,
6472                    Some(work_dirs),
6473                    self.clone(),
6474                    project,
6475                    action_log,
6476                    acp::SessionId::new("new-session"),
6477                    watch::Receiver::constant(
6478                        acp::PromptCapabilities::new()
6479                            .image(true)
6480                            .audio(true)
6481                            .embedded_context(true),
6482                    ),
6483                    cx,
6484                )
6485            });
6486            Task::ready(Ok(thread))
6487        }
6488
6489        fn supports_load_session(&self) -> bool {
6490            true
6491        }
6492
6493        fn load_session(
6494            self: Rc<Self>,
6495            session_id: acp::SessionId,
6496            project: Entity<Project>,
6497            work_dirs: PathList,
6498            _title: Option<SharedString>,
6499            cx: &mut App,
6500        ) -> Task<gpui::Result<Entity<AcpThread>>> {
6501            *self.captured_work_dirs.lock() = Some(work_dirs.clone());
6502            let action_log = cx.new(|_| ActionLog::new(project.clone()));
6503            let thread = cx.new(|cx| {
6504                AcpThread::new(
6505                    None,
6506                    None,
6507                    Some(work_dirs),
6508                    self.clone(),
6509                    project,
6510                    action_log,
6511                    session_id,
6512                    watch::Receiver::constant(
6513                        acp::PromptCapabilities::new()
6514                            .image(true)
6515                            .audio(true)
6516                            .embedded_context(true),
6517                    ),
6518                    cx,
6519                )
6520            });
6521            Task::ready(Ok(thread))
6522        }
6523
6524        fn auth_methods(&self) -> &[acp::AuthMethod] {
6525            &[]
6526        }
6527
6528        fn authenticate(
6529            &self,
6530            _method_id: acp::AuthMethodId,
6531            _cx: &mut App,
6532        ) -> Task<gpui::Result<()>> {
6533            Task::ready(Ok(()))
6534        }
6535
6536        fn prompt(
6537            &self,
6538            _params: acp::PromptRequest,
6539            _cx: &mut App,
6540        ) -> Task<gpui::Result<acp::PromptResponse>> {
6541            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
6542        }
6543
6544        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
6545
6546        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
6547            self
6548        }
6549    }
6550
6551    pub(crate) fn init_test(cx: &mut TestAppContext) {
6552        cx.update(|cx| {
6553            let settings_store = SettingsStore::test(cx);
6554            cx.set_global(settings_store);
6555            // Use an isolated DB so parallel tests can't overwrite each
6556            // other's global keys (e.g. the last-created entry kind).
6557            cx.set_global(db::AppDatabase::test_new());
6558            ThreadMetadataStore::init_global(cx);
6559            theme_settings::init(theme::LoadThemes::JustBase, cx);
6560            editor::init(cx);
6561            agent_panel::init(cx);
6562            release_channel::init(semver::Version::new(0, 0, 0), cx);
6563            prompt_store::init(cx)
6564        });
6565    }
6566
6567    fn active_thread(
6568        conversation_view: &Entity<ConversationView>,
6569        cx: &TestAppContext,
6570    ) -> Entity<ThreadView> {
6571        cx.read(|cx| {
6572            conversation_view
6573                .read(cx)
6574                .active_thread()
6575                .expect("No active thread")
6576                .clone()
6577        })
6578    }
6579
6580    fn assert_thread_list_item_count_matches_entries(view: &ThreadView, cx: &App) {
6581        assert_eq!(
6582            view.list_state.item_count(),
6583            view.thread.read(cx).entries().len() + usize::from(view.generating_indicator_in_list)
6584        );
6585    }
6586
6587    fn message_editor(
6588        conversation_view: &Entity<ConversationView>,
6589        cx: &TestAppContext,
6590    ) -> Entity<MessageEditor> {
6591        let thread = active_thread(conversation_view, cx);
6592        cx.read(|cx| thread.read(cx).message_editor.clone())
6593    }
6594
6595    #[gpui::test]
6596    async fn test_rewind_views(cx: &mut TestAppContext) {
6597        init_test(cx);
6598
6599        let fs = FakeFs::new(cx.executor());
6600        fs.insert_tree(
6601            "/project",
6602            json!({
6603                "test1.txt": "old content 1",
6604                "test2.txt": "old content 2"
6605            }),
6606        )
6607        .await;
6608        let project = Project::test(fs, [Path::new("/project")], cx).await;
6609        let (multi_workspace, cx) =
6610            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6611        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6612
6613        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
6614        let connection_store =
6615            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
6616
6617        let connection = Rc::new(StubAgentConnection::new());
6618        let conversation_view = cx.update(|window, cx| {
6619            cx.new(|cx| {
6620                ConversationView::new(
6621                    Rc::new(StubAgentServer::new(connection.as_ref().clone())),
6622                    connection_store,
6623                    Agent::Custom { id: "Test".into() },
6624                    None,
6625                    None,
6626                    None,
6627                    None,
6628                    None,
6629                    workspace.downgrade(),
6630                    project.clone(),
6631                    Some(thread_store.clone()),
6632                    AgentThreadSource::AgentPanel,
6633                    window,
6634                    cx,
6635                )
6636            })
6637        });
6638
6639        cx.run_until_parked();
6640
6641        let thread = conversation_view
6642            .read_with(cx, |view, cx| {
6643                view.active_thread().map(|r| r.read(cx).thread.clone())
6644            })
6645            .unwrap();
6646
6647        // First user message
6648        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
6649            acp::ToolCall::new("tool1", "Edit file 1")
6650                .kind(acp::ToolKind::Edit)
6651                .status(acp::ToolCallStatus::Completed)
6652                .content(vec![acp::ToolCallContent::Diff(
6653                    acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"),
6654                )]),
6655        )]);
6656
6657        thread
6658            .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx))
6659            .await
6660            .unwrap();
6661        cx.run_until_parked();
6662
6663        thread.read_with(cx, |thread, _cx| {
6664            assert_eq!(thread.entries().len(), 2);
6665        });
6666
6667        conversation_view.read_with(cx, |view, cx| {
6668            let entry_view_state = view
6669                .active_thread()
6670                .map(|active| active.read(cx).entry_view_state.clone())
6671                .unwrap();
6672            entry_view_state.read_with(cx, |entry_view_state, _| {
6673                assert!(
6674                    entry_view_state
6675                        .entry(0)
6676                        .unwrap()
6677                        .message_editor()
6678                        .is_some()
6679                );
6680                assert!(entry_view_state.entry(1).unwrap().has_content());
6681            });
6682        });
6683
6684        // Second user message
6685        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
6686            acp::ToolCall::new("tool2", "Edit file 2")
6687                .kind(acp::ToolKind::Edit)
6688                .status(acp::ToolCallStatus::Completed)
6689                .content(vec![acp::ToolCallContent::Diff(
6690                    acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"),
6691                )]),
6692        )]);
6693
6694        thread
6695            .update(cx, |thread, cx| thread.send_raw("Another one", cx))
6696            .await
6697            .unwrap();
6698        cx.run_until_parked();
6699
6700        let second_user_message_id = thread.read_with(cx, |thread, _| {
6701            assert_eq!(thread.entries().len(), 4);
6702            let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else {
6703                panic!();
6704            };
6705            user_message.client_id.clone().unwrap()
6706        });
6707
6708        conversation_view.read_with(cx, |view, cx| {
6709            let entry_view_state = view
6710                .active_thread()
6711                .unwrap()
6712                .read(cx)
6713                .entry_view_state
6714                .clone();
6715            entry_view_state.read_with(cx, |entry_view_state, _| {
6716                assert!(
6717                    entry_view_state
6718                        .entry(0)
6719                        .unwrap()
6720                        .message_editor()
6721                        .is_some()
6722                );
6723                assert!(entry_view_state.entry(1).unwrap().has_content());
6724                assert!(
6725                    entry_view_state
6726                        .entry(2)
6727                        .unwrap()
6728                        .message_editor()
6729                        .is_some()
6730                );
6731                assert!(entry_view_state.entry(3).unwrap().has_content());
6732            });
6733        });
6734
6735        // Rewind to first message
6736        thread
6737            .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx))
6738            .await
6739            .unwrap();
6740
6741        cx.run_until_parked();
6742
6743        thread.read_with(cx, |thread, _| {
6744            assert_eq!(thread.entries().len(), 2);
6745        });
6746
6747        conversation_view.read_with(cx, |view, cx| {
6748            let active = view.active_thread().unwrap();
6749            active
6750                .read(cx)
6751                .entry_view_state
6752                .read_with(cx, |entry_view_state, _| {
6753                    assert!(
6754                        entry_view_state
6755                            .entry(0)
6756                            .unwrap()
6757                            .message_editor()
6758                            .is_some()
6759                    );
6760                    assert!(entry_view_state.entry(1).unwrap().has_content());
6761
6762                    // Old views should be dropped
6763                    assert!(entry_view_state.entry(2).is_none());
6764                    assert!(entry_view_state.entry(3).is_none());
6765                });
6766        });
6767    }
6768
6769    #[gpui::test]
6770    async fn test_regenerate_keeps_pending_subagent_edits(cx: &mut TestAppContext) {
6771        init_test(cx);
6772
6773        let fs = FakeFs::new(cx.executor());
6774        fs.insert_tree(
6775            "/project",
6776            json!({
6777                "file.txt": "original content"
6778            }),
6779        )
6780        .await;
6781        let project = Project::test(fs, [Path::new("/project")], cx).await;
6782        let (multi_workspace, cx) =
6783            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6784        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6785
6786        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
6787        let connection_store =
6788            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
6789
6790        let connection = Rc::new(StubAgentConnection::new());
6791        let conversation_view = cx.update(|window, cx| {
6792            cx.new(|cx| {
6793                ConversationView::new(
6794                    Rc::new(StubAgentServer::new(connection.as_ref().clone())),
6795                    connection_store,
6796                    Agent::Custom { id: "Test".into() },
6797                    None,
6798                    None,
6799                    None,
6800                    None,
6801                    None,
6802                    workspace.downgrade(),
6803                    project.clone(),
6804                    Some(thread_store.clone()),
6805                    AgentThreadSource::AgentPanel,
6806                    window,
6807                    cx,
6808                )
6809            })
6810        });
6811
6812        cx.run_until_parked();
6813
6814        let thread = conversation_view
6815            .read_with(cx, |view, cx| {
6816                view.active_thread().map(|r| r.read(cx).thread.clone())
6817            })
6818            .unwrap();
6819
6820        // First turn: a subagent tool call. Subagent edits never appear as
6821        // diffs in the parent thread's entries; they are only forwarded to the
6822        // parent's action log through the linked-log mechanism.
6823        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
6824            acp::ToolCall::new("spawn1", "Subagent task")
6825                .kind(acp::ToolKind::Other)
6826                .status(acp::ToolCallStatus::Completed)
6827                .meta(acp_thread::meta_with_tool_name("spawn_agent")),
6828        )]);
6829
6830        thread
6831            .update(cx, |thread, cx| thread.send_raw("Use a subagent", cx))
6832            .await
6833            .unwrap();
6834        cx.run_until_parked();
6835
6836        // Simulate the subagent editing a file: edits performed through a
6837        // child action log are forwarded to the parent thread's action log,
6838        // just like `Thread::new_subagent` wires it up.
6839        let parent_action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
6840        let subagent_action_log = cx.update(|_, cx| {
6841            cx.new(|_| {
6842                ActionLog::new(project.clone()).with_linked_action_log(parent_action_log.clone())
6843            })
6844        });
6845
6846        let buffer = project
6847            .update(cx, |project, cx| {
6848                let path = project.find_project_path("file.txt", cx).unwrap();
6849                project.open_buffer(path, cx)
6850            })
6851            .await
6852            .unwrap();
6853        cx.update(|_, cx| {
6854            subagent_action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
6855            buffer.update(cx, |buffer, cx| {
6856                buffer.set_text("edited by subagent", cx);
6857            });
6858            subagent_action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
6859        });
6860        cx.run_until_parked();
6861
6862        parent_action_log.read_with(cx, |log, cx| {
6863            assert_eq!(
6864                log.changed_buffers(cx).count(),
6865                1,
6866                "the subagent edit should be pending review in the parent's action log"
6867            );
6868        });
6869
6870        // Second turn: a plain follow-up.
6871        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6872            acp::ContentChunk::new("Response".into()),
6873        )]);
6874        thread
6875            .update(cx, |thread, cx| thread.send_raw("Follow-up", cx))
6876            .await
6877            .unwrap();
6878        cx.run_until_parked();
6879
6880        let follow_up_ix = thread.read_with(cx, |thread, cx| {
6881            thread
6882                .entries()
6883                .iter()
6884                .position(|entry| entry.to_markdown(cx) == "## User\n\nFollow-up\n\n")
6885                .unwrap()
6886        });
6887
6888        // Edit and regenerate the follow-up message.
6889        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
6890            view.active_thread()
6891                .unwrap()
6892                .read(cx)
6893                .entry_view_state
6894                .read(cx)
6895                .entry(follow_up_ix)
6896                .unwrap()
6897                .message_editor()
6898                .unwrap()
6899                .clone()
6900        });
6901        user_message_editor.update_in(cx, |editor, window, cx| {
6902            editor.set_text("Edited follow-up", window, cx);
6903        });
6904
6905        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6906            acp::ContentChunk::new("New response".into()),
6907        )]);
6908        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
6909            view.regenerate(follow_up_ix, user_message_editor.clone(), window, cx);
6910        });
6911        cx.run_until_parked();
6912
6913        // The thread should have been rewound and the edited message resent.
6914        thread.read_with(cx, |thread, cx| {
6915            let entries = thread.entries();
6916            assert_eq!(entries.len(), 4);
6917            assert_eq!(
6918                entries[2].to_markdown(cx),
6919                "## User\n\nEdited follow-up\n\n"
6920            );
6921        });
6922
6923        // The subagent's edits predate the regenerated prompt, so they must be
6924        // auto-kept rather than rejected by the rewind.
6925        buffer.read_with(cx, |buffer, _| {
6926            assert_eq!(
6927                buffer.text(),
6928                "edited by subagent",
6929                "pending subagent edits should be kept when regenerating a later prompt"
6930            );
6931        });
6932        parent_action_log.read_with(cx, |log, cx| {
6933            assert_eq!(
6934                log.changed_buffers(cx).count(),
6935                0,
6936                "the subagent edit should have been auto-kept"
6937            );
6938        });
6939    }
6940
6941    #[gpui::test]
6942    async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
6943        init_test(cx);
6944
6945        let connection = StubAgentConnection::new();
6946
6947        // Each user prompt will result in a user message entry plus an agent message entry.
6948        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6949            acp::ContentChunk::new("Response 1".into()),
6950        )]);
6951
6952        let (conversation_view, cx) =
6953            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
6954
6955        let thread = conversation_view
6956            .read_with(cx, |view, cx| {
6957                view.active_thread().map(|r| r.read(cx).thread.clone())
6958            })
6959            .unwrap();
6960
6961        thread
6962            .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
6963            .await
6964            .unwrap();
6965        cx.run_until_parked();
6966
6967        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6968            acp::ContentChunk::new("Response 2".into()),
6969        )]);
6970
6971        thread
6972            .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
6973            .await
6974            .unwrap();
6975        cx.run_until_parked();
6976
6977        // Move somewhere else first so we're not trivially already on the last user prompt.
6978        active_thread(&conversation_view, cx).update(cx, |view, cx| {
6979            view.scroll_to_top(cx);
6980        });
6981        cx.run_until_parked();
6982
6983        active_thread(&conversation_view, cx).update(cx, |view, cx| {
6984            view.scroll_to_user_message_index(None, cx);
6985            let scroll_top = view.list_state.logical_scroll_top();
6986            // Entries layout is: [User1, Assistant1, User2, Assistant2]
6987            assert_eq!(scroll_top.item_ix, 2);
6988
6989            view.scroll_to_top(cx);
6990            view.scroll_to_user_message_index(Some(0), cx);
6991            let scroll_top = view.list_state.logical_scroll_top();
6992            assert_eq!(scroll_top.item_ix, 0);
6993
6994            view.scroll_to_top(cx);
6995            view.scroll_to_user_message_index(Some(2), cx);
6996            let scroll_top = view.list_state.logical_scroll_top();
6997            assert_eq!(scroll_top.item_ix, 2);
6998        });
6999    }
7000
7001    #[gpui::test]
7002    async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
7003        cx: &mut TestAppContext,
7004    ) {
7005        init_test(cx);
7006
7007        let (conversation_view, cx) =
7008            setup_conversation_view(StubAgentServer::default_response(), cx).await;
7009
7010        // With no entries, scrolling should be a no-op and must not panic.
7011        active_thread(&conversation_view, cx).update(cx, |view, cx| {
7012            view.scroll_to_user_message_index(None, cx);
7013            let scroll_top = view.list_state.logical_scroll_top();
7014            assert_eq!(scroll_top.item_ix, 0);
7015        });
7016    }
7017
7018    #[gpui::test]
7019    async fn test_thread_search_finds_matches_across_entries(cx: &mut TestAppContext) {
7020        init_test(cx);
7021
7022        let connection = StubAgentConnection::new();
7023        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7024            acp::ContentChunk::new(
7025                "Yes, you can substitute banana for plantain in this recipe.".into(),
7026            ),
7027        )]);
7028
7029        let (conversation_view, cx) =
7030            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7031
7032        let thread =
7033            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7034
7035        thread
7036            .update(cx, |thread, cx| {
7037                thread.send_raw("Can I use banana here?", cx)
7038            })
7039            .await
7040            .unwrap();
7041        cx.run_until_parked();
7042
7043        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7044            acp::ContentChunk::new(
7045                "Banana yogurt also works as a topping; whisk the banana smooth first.".into(),
7046            ),
7047        )]);
7048
7049        thread
7050            .update(cx, |thread, cx| {
7051                thread.send_raw("What about as a topping?", cx)
7052            })
7053            .await
7054            .unwrap();
7055        cx.run_until_parked();
7056
7057        let thread_view = active_thread(&conversation_view, cx);
7058        thread_view.update_in(cx, |view, window, cx| {
7059            view.toggle_search(&crate::ToggleSearch, window, cx);
7060        });
7061        cx.run_until_parked();
7062
7063        let bar = thread_view
7064            .read_with(cx, |view, _| view.thread_search_bar.clone())
7065            .expect("thread_search_bar should be set after toggle_search");
7066        bar.update_in(cx, |bar, window, cx| {
7067            bar.query_editor.update(cx, |editor, cx| {
7068                editor.set_text("banana", window, cx);
7069            });
7070            bar.update_matches(window, cx);
7071        });
7072        cx.run_until_parked();
7073
7074        let (match_count, active_text) =
7075            bar.read_with(cx, |bar, cx| (bar.match_count(), bar.active_match_text(cx)));
7076        assert_eq!(
7077            match_count, 4,
7078            "expected 4 matches for case-insensitive 'banana'"
7079        );
7080        assert_eq!(active_text.as_deref(), Some("1/4"));
7081
7082        thread_view.read_with(cx, |view, _| {
7083            assert_eq!(view.list_state.logical_scroll_top().item_ix, 0);
7084        });
7085
7086        bar.update_in(cx, |bar, window, cx| {
7087            bar.select_next_match(&super::thread_search_bar::SelectNextThreadMatch, window, cx);
7088        });
7089        cx.run_until_parked();
7090        let active_text_2 = bar.read_with(cx, |bar, cx| bar.active_match_text(cx));
7091        assert_eq!(active_text_2.as_deref(), Some("2/4"));
7092
7093        bar.update_in(cx, |bar, window, cx| {
7094            bar.select_prev_match(
7095                &super::thread_search_bar::SelectPreviousThreadMatch,
7096                window,
7097                cx,
7098            );
7099        });
7100        let active_text_3 = bar.read_with(cx, |bar, cx| bar.active_match_text(cx));
7101        assert_eq!(active_text_3.as_deref(), Some("1/4"));
7102
7103        bar.update_in(cx, |bar, window, cx| {
7104            bar.query_editor.update(cx, |editor, cx| {
7105                editor.set_text("apple", window, cx);
7106            });
7107            bar.update_matches(window, cx);
7108        });
7109        cx.run_until_parked();
7110        let (match_count_apple, active_text_apple) =
7111            bar.read_with(cx, |bar, cx| (bar.match_count(), bar.active_match_text(cx)));
7112        assert_eq!(match_count_apple, 0);
7113        assert_eq!(active_text_apple.as_deref(), Some("0/0"));
7114    }
7115
7116    #[gpui::test]
7117    async fn test_thread_search_includes_expanded_thinking_blocks(cx: &mut TestAppContext) {
7118        init_test(cx);
7119
7120        let connection = StubAgentConnection::new();
7121        connection.set_next_prompt_updates(vec![
7122            acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
7123                "Hidden papaya reasoning.".into(),
7124            )),
7125            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
7126                "Final answer without that fruit.".into(),
7127            )),
7128        ]);
7129
7130        let (conversation_view, cx) =
7131            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7132
7133        let thread =
7134            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7135        thread
7136            .update(cx, |thread, cx| thread.send_raw("Think this through", cx))
7137            .await
7138            .unwrap();
7139        cx.run_until_parked();
7140
7141        let (assistant_entry_ix, thought_chunk_ix) = thread.read_with(cx, |thread, _| {
7142            thread
7143                .entries()
7144                .iter()
7145                .enumerate()
7146                .find_map(|(entry_ix, entry)| match entry {
7147                    AgentThreadEntry::AssistantMessage(message) => message
7148                        .chunks
7149                        .iter()
7150                        .position(|chunk| matches!(chunk, AssistantMessageChunk::Thought { .. }))
7151                        .map(|chunk_ix| (entry_ix, chunk_ix)),
7152                    _ => None,
7153                })
7154                .expect("assistant thought chunk should exist")
7155        });
7156
7157        let thread_view = active_thread(&conversation_view, cx);
7158        thread_view.update_in(cx, |view, window, cx| {
7159            view.toggle_search(&crate::ToggleSearch, window, cx);
7160        });
7161        cx.run_until_parked();
7162
7163        let bar = thread_view
7164            .read_with(cx, |view, _| view.thread_search_bar.clone())
7165            .expect("thread_search_bar should be set after toggle_search");
7166        bar.update_in(cx, |bar, window, cx| {
7167            bar.query_editor.update(cx, |editor, cx| {
7168                editor.set_text("papaya", window, cx);
7169            });
7170            bar.update_matches(window, cx);
7171        });
7172        cx.run_until_parked();
7173        assert_eq!(
7174            bar.read_with(cx, |bar, _| bar.match_count()),
7175            0,
7176            "collapsed thinking content should not be searched",
7177        );
7178
7179        thread_view.update(cx, |view, cx| {
7180            view.entry_view_state.update(cx, |state, cx| {
7181                state.toggle_thinking_block_expansion((assistant_entry_ix, thought_chunk_ix), cx);
7182            });
7183        });
7184        bar.update_in(cx, |bar, window, cx| {
7185            bar.update_matches(window, cx);
7186        });
7187        cx.run_until_parked();
7188
7189        assert_eq!(
7190            bar.read_with(cx, |bar, _| bar.match_count()),
7191            1,
7192            "expanded thinking content should be searchable",
7193        );
7194    }
7195
7196    #[gpui::test]
7197    async fn test_thread_search_includes_expanded_tool_call_content(cx: &mut TestAppContext) {
7198        init_test(cx);
7199
7200        let tool_call_id = acp::ToolCallId::new("search-tool-content");
7201        let connection = StubAgentConnection::new();
7202        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
7203            acp::ToolCall::new(tool_call_id.clone(), "Inspect output")
7204                .kind(acp::ToolKind::Other)
7205                .status(acp::ToolCallStatus::Completed)
7206                .content(vec!["Tool output mentions papaya once.".into()]),
7207        )]);
7208
7209        let (conversation_view, cx) =
7210            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7211
7212        let thread =
7213            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7214        thread
7215            .update(cx, |thread, cx| thread.send_raw("Run the tool", cx))
7216            .await
7217            .unwrap();
7218        cx.run_until_parked();
7219
7220        let thread_view = active_thread(&conversation_view, cx);
7221        thread_view.update_in(cx, |view, window, cx| {
7222            view.toggle_search(&crate::ToggleSearch, window, cx);
7223        });
7224        cx.run_until_parked();
7225
7226        let bar = thread_view
7227            .read_with(cx, |view, _| view.thread_search_bar.clone())
7228            .expect("thread_search_bar should be set after toggle_search");
7229        bar.update_in(cx, |bar, window, cx| {
7230            bar.query_editor.update(cx, |editor, cx| {
7231                editor.set_text("papaya", window, cx);
7232            });
7233            bar.update_matches(window, cx);
7234        });
7235        cx.run_until_parked();
7236        assert_eq!(
7237            bar.read_with(cx, |bar, _| bar.match_count()),
7238            0,
7239            "collapsed tool-call content should not be searched",
7240        );
7241
7242        thread_view.update(cx, |view, cx| {
7243            view.entry_view_state.update(cx, |state, _cx| {
7244                state.expand_tool_call(tool_call_id);
7245            });
7246        });
7247        bar.update_in(cx, |bar, window, cx| {
7248            bar.update_matches(window, cx);
7249        });
7250        cx.run_until_parked();
7251
7252        assert_eq!(
7253            bar.read_with(cx, |bar, _| bar.match_count()),
7254            1,
7255            "expanded tool-call content should be searchable",
7256        );
7257    }
7258
7259    #[gpui::test]
7260    async fn test_thread_search_scrolls_to_later_user_message_match(cx: &mut TestAppContext) {
7261        init_test(cx);
7262
7263        let connection = StubAgentConnection::new();
7264        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7265            acp::ContentChunk::new("First reply, no fruit here.".into()),
7266        )]);
7267
7268        let (conversation_view, cx) =
7269            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7270        add_to_workspace(conversation_view.clone(), cx);
7271
7272        let thread =
7273            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7274        thread
7275            .update(cx, |thread, cx| thread.send_raw("First question", cx))
7276            .await
7277            .unwrap();
7278        cx.run_until_parked();
7279        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7280            acp::ContentChunk::new("Second reply, still no fruit.".into()),
7281        )]);
7282        thread
7283            .update(cx, |thread, cx| thread.send_raw("Where is the papaya?", cx))
7284            .await
7285            .unwrap();
7286        cx.run_until_parked();
7287
7288        let thread_view = active_thread(&conversation_view, cx);
7289        let papaya_entry_ix = thread.read_with(cx, |thread, _| {
7290            thread
7291                .entries()
7292                .iter()
7293                .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
7294                .expect("a user message entry should exist")
7295        });
7296
7297        thread_view.update_in(cx, |view, window, cx| {
7298            view.toggle_search(&crate::ToggleSearch, window, cx);
7299        });
7300        cx.run_until_parked();
7301        let bar = thread_view
7302            .read_with(cx, |view, _| view.thread_search_bar.clone())
7303            .expect("thread_search_bar should be set after toggle_search");
7304        bar.update_in(cx, |bar, window, cx| {
7305            bar.query_editor.update(cx, |editor, cx| {
7306                editor.set_text("papaya", window, cx);
7307            });
7308            bar.update_matches(window, cx);
7309        });
7310        cx.run_until_parked();
7311
7312        bar.read_with(cx, |bar, _| {
7313            assert_eq!(
7314                bar.match_count(),
7315                1,
7316                "only the second user message matches 'papaya'"
7317            );
7318        });
7319
7320        thread_view.read_with(cx, |view, _| {
7321            assert_eq!(
7322                view.list_state.logical_scroll_top().item_ix,
7323                papaya_entry_ix,
7324                "list should scroll to the user-message entry that owns the match",
7325            );
7326        });
7327    }
7328
7329    /// Passive rescans (streaming updates, unrelated expansion toggles, query
7330    /// refinement) must not yank the list back to the active match.
7331    #[gpui::test]
7332    async fn test_thread_search_passive_rescan_preserves_scroll(cx: &mut TestAppContext) {
7333        init_test(cx);
7334
7335        let connection = StubAgentConnection::new();
7336        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7337            acp::ContentChunk::new("First reply, no fruit here.".into()),
7338        )]);
7339
7340        let (conversation_view, cx) =
7341            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7342        add_to_workspace(conversation_view.clone(), cx);
7343
7344        let thread =
7345            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7346        thread
7347            .update(cx, |thread, cx| thread.send_raw("First question", cx))
7348            .await
7349            .unwrap();
7350        cx.run_until_parked();
7351        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7352            acp::ContentChunk::new("Second reply, still no fruit.".into()),
7353        )]);
7354        thread
7355            .update(cx, |thread, cx| thread.send_raw("Where is the papaya?", cx))
7356            .await
7357            .unwrap();
7358        cx.run_until_parked();
7359
7360        let thread_view = active_thread(&conversation_view, cx);
7361        let papaya_entry_ix = thread.read_with(cx, |thread, _| {
7362            thread
7363                .entries()
7364                .iter()
7365                .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
7366                .expect("a user message entry should exist")
7367        });
7368
7369        thread_view.update_in(cx, |view, window, cx| {
7370            view.toggle_search(&crate::ToggleSearch, window, cx);
7371        });
7372        cx.run_until_parked();
7373        let bar = thread_view
7374            .read_with(cx, |view, _| view.thread_search_bar.clone())
7375            .expect("thread_search_bar should be set after toggle_search");
7376        bar.update_in(cx, |bar, window, cx| {
7377            bar.query_editor.update(cx, |editor, cx| {
7378                editor.set_text("papaya", window, cx);
7379            });
7380            bar.update_matches(window, cx);
7381        });
7382        cx.run_until_parked();
7383
7384        // The initial query activates (and scrolls to) the only match.
7385        thread_view.read_with(cx, |view, _| {
7386            assert_eq!(
7387                view.list_state.logical_scroll_top().item_ix,
7388                papaya_entry_ix,
7389            );
7390        });
7391
7392        // Simulate the user scrolling elsewhere, then a passive rescan re-running
7393        // the matcher while the same hit stays active.
7394        thread_view.update(cx, |view, _| {
7395            view.list_state.scroll_to(gpui::ListOffset {
7396                item_ix: 0,
7397                offset_in_item: gpui::px(0.),
7398            });
7399        });
7400        bar.update_in(cx, |bar, window, cx| {
7401            bar.update_matches(window, cx);
7402        });
7403        cx.run_until_parked();
7404
7405        thread_view.read_with(cx, |view, _| {
7406            assert_eq!(
7407                view.list_state.logical_scroll_top().item_ix,
7408                0,
7409                "a passive rescan must not scroll back to the active match",
7410            );
7411        });
7412    }
7413
7414    #[gpui::test]
7415    async fn test_thread_search_dismiss_clears_highlights(cx: &mut TestAppContext) {
7416        init_test(cx);
7417
7418        let connection = StubAgentConnection::new();
7419        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7420            acp::ContentChunk::new("Mango is a tropical fruit.".into()),
7421        )]);
7422
7423        let (conversation_view, cx) =
7424            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7425
7426        let thread =
7427            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7428
7429        thread
7430            .update(cx, |thread, cx| thread.send_raw("Tell me about mango", cx))
7431            .await
7432            .unwrap();
7433        cx.run_until_parked();
7434
7435        let thread_view = active_thread(&conversation_view, cx);
7436        thread_view.update_in(cx, |view, window, cx| {
7437            view.toggle_search(&crate::ToggleSearch, window, cx);
7438        });
7439        cx.run_until_parked();
7440
7441        let bar = thread_view
7442            .read_with(cx, |view, _| view.thread_search_bar.clone())
7443            .unwrap();
7444        bar.update_in(cx, |bar, window, cx| {
7445            bar.query_editor.update(cx, |editor, cx| {
7446                editor.set_text("mango", window, cx);
7447            });
7448            bar.update_matches(window, cx);
7449        });
7450        cx.run_until_parked();
7451
7452        let entries = thread.read_with(cx, |thread, _| thread.entries().len());
7453        assert!(entries > 0);
7454
7455        bar.update(cx, |bar, cx| bar.clear_highlights(cx));
7456        cx.run_until_parked();
7457
7458        bar.read_with(cx, |bar, _| {
7459            assert_eq!(bar.match_count(), 0);
7460            assert!(bar.active_match_index().is_none());
7461        });
7462    }
7463
7464    #[gpui::test]
7465    async fn test_thread_search_release_clears_markdown_highlights(cx: &mut TestAppContext) {
7466        init_test(cx);
7467
7468        let connection = StubAgentConnection::new();
7469        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7470            acp::ContentChunk::new("Mango is a tropical fruit.".into()),
7471        )]);
7472
7473        let (conversation_view, cx) =
7474            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7475
7476        let thread =
7477            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7478        thread
7479            .update(cx, |thread, cx| thread.send_raw("Tell me about mango", cx))
7480            .await
7481            .unwrap();
7482        cx.run_until_parked();
7483
7484        let assistant_markdown = thread.read_with(cx, |thread, _| {
7485            thread
7486                .entries()
7487                .iter()
7488                .find_map(|entry| match entry {
7489                    AgentThreadEntry::AssistantMessage(message) => {
7490                        message.chunks.iter().find_map(|chunk| match chunk {
7491                            AssistantMessageChunk::Message { block, .. } => {
7492                                block.markdown().cloned()
7493                            }
7494                            AssistantMessageChunk::Thought { .. } => None,
7495                        })
7496                    }
7497                    _ => None,
7498                })
7499                .expect("assistant message should have markdown")
7500        });
7501
7502        let entry_view_state = active_thread(&conversation_view, cx)
7503            .read_with(cx, |view, _| view.entry_view_state.clone());
7504        let on_activate_match: Arc<dyn Fn(usize, &mut Window, &mut App)> = Arc::new(|_, _, _| {});
7505        let bar = cx.update(|window, cx| {
7506            cx.new(|cx| {
7507                super::thread_search_bar::ThreadSearchBar::new(
7508                    thread.clone(),
7509                    entry_view_state,
7510                    on_activate_match,
7511                    window,
7512                    cx,
7513                )
7514            })
7515        });
7516
7517        bar.update_in(cx, |bar, window, cx| {
7518            bar.query_editor.update(cx, |editor, cx| {
7519                editor.set_text("mango", window, cx);
7520            });
7521            bar.update_matches(window, cx);
7522        });
7523        cx.run_until_parked();
7524
7525        assert!(
7526            assistant_markdown
7527                .read_with(cx, |markdown, _| !markdown.search_highlights().is_empty()),
7528            "search should have highlighted the assistant markdown before release",
7529        );
7530
7531        drop(bar);
7532        cx.update(|_, _| {});
7533        cx.run_until_parked();
7534
7535        assert!(
7536            assistant_markdown.read_with(cx, |markdown, _| markdown.search_highlights().is_empty()),
7537            "releasing the search bar should clear retained markdown highlights",
7538        );
7539    }
7540
7541    #[gpui::test]
7542    async fn test_thread_search_refreshes_on_new_thread_entry(cx: &mut TestAppContext) {
7543        init_test(cx);
7544
7545        let connection = StubAgentConnection::new();
7546        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7547            acp::ContentChunk::new("First reply mentions banana once.".into()),
7548        )]);
7549
7550        let (conversation_view, cx) =
7551            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7552
7553        let thread =
7554            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7555        thread
7556            .update(cx, |thread, cx| thread.send_raw("Tell me about banana", cx))
7557            .await
7558            .unwrap();
7559        cx.run_until_parked();
7560
7561        let thread_view = active_thread(&conversation_view, cx);
7562        thread_view.update_in(cx, |view, window, cx| {
7563            view.toggle_search(&crate::ToggleSearch, window, cx);
7564        });
7565        cx.run_until_parked();
7566
7567        let bar = thread_view
7568            .read_with(cx, |view, _| view.thread_search_bar.clone())
7569            .expect("thread_search_bar should be set after toggle_search");
7570        bar.update_in(cx, |bar, window, cx| {
7571            bar.query_editor.update(cx, |editor, cx| {
7572                editor.set_text("banana", window, cx);
7573            });
7574            bar.update_matches(window, cx);
7575        });
7576        cx.run_until_parked();
7577
7578        let count_before = bar.read_with(cx, |bar, _| bar.match_count());
7579        assert!(
7580            count_before >= 2,
7581            "expected at least two initial matches, got {count_before}",
7582        );
7583
7584        bar.update_in(cx, |bar, window, cx| {
7585            bar.select_next_match(&super::thread_search_bar::SelectNextThreadMatch, window, cx);
7586        });
7587        cx.run_until_parked();
7588        assert_eq!(
7589            bar.read_with(cx, |bar, _| bar.active_match_index()),
7590            Some(1),
7591            "setup precondition: second match should be active before the refresh",
7592        );
7593
7594        // Advance past the debounced thread-update rescan.
7595        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7596            acp::ContentChunk::new("Banana banana: two more banana hits here.".into()),
7597        )]);
7598        thread
7599            .update(cx, |thread, cx| thread.send_raw("More banana please", cx))
7600            .await
7601            .unwrap();
7602        cx.run_until_parked();
7603        cx.executor()
7604            .advance_clock(super::thread_search_bar::SEARCH_UPDATE_DEBOUNCE * 2);
7605        cx.run_until_parked();
7606
7607        let (count_after, active_after) =
7608            bar.read_with(cx, |bar, _| (bar.match_count(), bar.active_match_index()));
7609        assert!(
7610            count_after > count_before,
7611            "thread subscription should refresh matches after new content \
7612             streamed in: before={count_before}, after={count_after}",
7613        );
7614        assert_eq!(
7615            active_after,
7616            Some(1),
7617            "refreshing matches should preserve the active result when it still exists",
7618        );
7619    }
7620
7621    /// Regression test for re-entering `ThreadView` during search navigation.
7622    #[gpui::test]
7623    async fn test_thread_search_select_next_from_thread_view_update_does_not_panic(
7624        cx: &mut TestAppContext,
7625    ) {
7626        init_test(cx);
7627
7628        let connection = StubAgentConnection::new();
7629        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7630            acp::ContentChunk::new(
7631                "Banana banana banana, the banana fits the banana bread.".into(),
7632            ),
7633        )]);
7634
7635        let (conversation_view, cx) =
7636            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7637
7638        let thread =
7639            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7640        thread
7641            .update(cx, |thread, cx| thread.send_raw("Need banana help", cx))
7642            .await
7643            .unwrap();
7644        cx.run_until_parked();
7645
7646        let thread_view = active_thread(&conversation_view, cx);
7647        thread_view.update_in(cx, |view, window, cx| {
7648            view.toggle_search(&crate::ToggleSearch, window, cx);
7649        });
7650        cx.run_until_parked();
7651
7652        let bar = thread_view
7653            .read_with(cx, |view, _| view.thread_search_bar.clone())
7654            .expect("thread_search_bar should be set after toggle_search");
7655        bar.update_in(cx, |bar, window, cx| {
7656            bar.query_editor.update(cx, |editor, cx| {
7657                editor.set_text("banana", window, cx);
7658            });
7659            bar.update_matches(window, cx);
7660        });
7661        cx.run_until_parked();
7662
7663        let initial_match_count = bar.read_with(cx, |bar, _| bar.match_count());
7664        assert!(
7665            initial_match_count >= 2,
7666            "setup precondition: expected at least 2 matches, got {}",
7667            initial_match_count,
7668        );
7669
7670        thread_view.update_in(cx, |view, window, cx| {
7671            let bar = view
7672                .thread_search_bar
7673                .clone()
7674                .expect("bar should still be set");
7675            bar.update(cx, |bar, cx| {
7676                bar.select_next_match(&super::thread_search_bar::SelectNextThreadMatch, window, cx);
7677            });
7678        });
7679        cx.run_until_parked();
7680
7681        let active_after = bar.read_with(cx, |bar, _| bar.active_match_index());
7682        assert_eq!(
7683            active_after,
7684            Some(1),
7685            "select_next_match should have advanced from match 0 to match 1",
7686        );
7687    }
7688
7689    /// Past user-message hits must be painted on the inner `Editor`.
7690    #[gpui::test]
7691    async fn test_thread_search_highlights_user_message_editor(cx: &mut TestAppContext) {
7692        init_test(cx);
7693
7694        let connection = StubAgentConnection::new();
7695        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7696            acp::ContentChunk::new("Sure, I can help with that.".into()),
7697        )]);
7698
7699        let (conversation_view, cx) =
7700            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7701        add_to_workspace(conversation_view.clone(), cx);
7702
7703        let thread =
7704            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7705        thread
7706            .update(cx, |thread, cx| {
7707                thread.send_raw("Where do I find a kumquat?", cx)
7708            })
7709            .await
7710            .unwrap();
7711        cx.run_until_parked();
7712
7713        let thread_view = active_thread(&conversation_view, cx);
7714        thread_view.update_in(cx, |view, window, cx| {
7715            view.toggle_search(&crate::ToggleSearch, window, cx);
7716        });
7717        cx.run_until_parked();
7718
7719        let bar = thread_view
7720            .read_with(cx, |view, _| view.thread_search_bar.clone())
7721            .expect("thread_search_bar should be set after toggle_search");
7722        bar.update_in(cx, |bar, window, cx| {
7723            bar.query_editor.update(cx, |editor, cx| {
7724                editor.set_text("kumquat", window, cx);
7725            });
7726            bar.update_matches(window, cx);
7727        });
7728        cx.run_until_parked();
7729
7730        let match_count = bar.read_with(cx, |bar, _| bar.match_count());
7731        assert_eq!(
7732            match_count, 1,
7733            "expected exactly one match for 'kumquat' (in the user message)",
7734        );
7735
7736        let user_message_editor = thread_view.read_with(cx, |view, cx| {
7737            view.entry_view_state
7738                .read(cx)
7739                .entry(0)
7740                .and_then(|entry| entry.message_editor())
7741                .map(|message_editor| message_editor.read(cx).editor().clone())
7742                .expect("entry 0 should be a user message with a message editor")
7743        });
7744        let has_highlight = user_message_editor.read_with(cx, |editor, _cx| {
7745            editor.has_background_highlights(editor::HighlightKey::BufferSearchHighlights)
7746        });
7747        assert!(
7748            has_highlight,
7749            "user message editor should carry BufferSearchHighlights after the bar's matcher ran",
7750        );
7751
7752        bar.update(cx, |bar, cx| bar.clear_highlights(cx));
7753        cx.run_until_parked();
7754        let has_highlight_after_clear = user_message_editor.read_with(cx, |editor, _cx| {
7755            editor.has_background_highlights(editor::HighlightKey::BufferSearchHighlights)
7756        });
7757        assert!(
7758            !has_highlight_after_clear,
7759            "clear_highlights should remove the editor-backed highlights",
7760        );
7761    }
7762
7763    /// `editor::Cancel` should dismiss thread search before reaching workspace handlers.
7764    #[gpui::test]
7765    async fn test_thread_search_editor_cancel_dismisses_bar(cx: &mut TestAppContext) {
7766        init_test(cx);
7767
7768        let (conversation_view, cx) =
7769            setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
7770        add_to_workspace(conversation_view.clone(), cx);
7771
7772        let thread_view = active_thread(&conversation_view, cx);
7773        thread_view.update_in(cx, |view, window, cx| {
7774            view.toggle_search(&crate::ToggleSearch, window, cx);
7775        });
7776        cx.run_until_parked();
7777
7778        let visible_before = thread_view.read_with(cx, |view, _| view.thread_search_visible);
7779        assert!(
7780            visible_before,
7781            "search bar should be visible after toggle_search"
7782        );
7783
7784        let bar = thread_view
7785            .read_with(cx, |view, _| view.thread_search_bar.clone())
7786            .expect("bar should be set");
7787        let query_focus = bar.read_with(cx, |bar, cx| bar.query_editor.focus_handle(cx));
7788        cx.update(|window, cx| {
7789            window.focus(&query_focus, cx);
7790        });
7791        cx.run_until_parked();
7792
7793        conversation_view.update_in(cx, |_, window, cx| {
7794            window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx);
7795        });
7796        cx.run_until_parked();
7797
7798        let visible_after = thread_view.read_with(cx, |view, _| view.thread_search_visible);
7799        assert!(
7800            !visible_after,
7801            "editor::Cancel should have dismissed the bar before reaching the workspace",
7802        );
7803    }
7804
7805    /// JetBrains keymaps route Shift+Enter through `editor::NewlineBelow`.
7806    #[gpui::test]
7807    async fn test_thread_search_shift_enter_navigates_with_jetbrains_keymap(
7808        cx: &mut TestAppContext,
7809    ) {
7810        init_test(cx);
7811        cx.update(|cx| {
7812            // Load both the default binding and the conflicting base keymap.
7813            search::init(cx);
7814
7815            let mut default_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
7816                "keymaps/default-linux.json",
7817                cx,
7818            )
7819            .unwrap();
7820            for binding in &mut default_bindings {
7821                binding.set_meta(settings::KeybindSource::Default.meta());
7822            }
7823            cx.bind_keys(default_bindings);
7824
7825            let mut jetbrains_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
7826                "keymaps/linux/jetbrains.json",
7827                cx,
7828            )
7829            .unwrap();
7830            for binding in &mut jetbrains_bindings {
7831                binding.set_meta(settings::KeybindSource::Base.meta());
7832            }
7833            cx.bind_keys(jetbrains_bindings);
7834        });
7835
7836        let connection = StubAgentConnection::new();
7837        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7838            acp::ContentChunk::new(
7839                "Banana banana banana, multiple banana mentions in this reply.".into(),
7840            ),
7841        )]);
7842
7843        let (conversation_view, cx) =
7844            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7845        add_to_workspace(conversation_view.clone(), cx);
7846
7847        let thread =
7848            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7849        thread
7850            .update(cx, |thread, cx| thread.send_raw("Need banana help", cx))
7851            .await
7852            .unwrap();
7853        cx.run_until_parked();
7854
7855        let thread_view = active_thread(&conversation_view, cx);
7856        thread_view.update_in(cx, |view, window, cx| {
7857            view.toggle_search(&crate::ToggleSearch, window, cx);
7858        });
7859        cx.run_until_parked();
7860
7861        let bar = thread_view
7862            .read_with(cx, |view, _| view.thread_search_bar.clone())
7863            .expect("thread_search_bar should be set after toggle_search");
7864
7865        bar.update_in(cx, |bar, window, cx| {
7866            bar.query_editor.update(cx, |editor, cx| {
7867                editor.set_text("banana", window, cx);
7868            });
7869            bar.update_matches(window, cx);
7870        });
7871        cx.run_until_parked();
7872
7873        let initial_count = bar.read_with(cx, |bar, _| bar.match_count());
7874        assert!(
7875            initial_count >= 2,
7876            "test precondition: need ≥2 matches across the thread, got {}",
7877            initial_count,
7878        );
7879        assert_eq!(
7880            bar.read_with(cx, |bar, _| bar.active_match_index()),
7881            Some(0),
7882            "first match should be active after the bar populates its match list",
7883        );
7884
7885        let query_focus = bar.read_with(cx, |bar, cx| bar.query_editor.focus_handle(cx));
7886        cx.update(|window, cx| {
7887            window.focus(&query_focus, cx);
7888        });
7889        cx.run_until_parked();
7890        cx.update(|window, cx| {
7891            assert!(
7892                query_focus.contains_focused(window, cx),
7893                "query editor must be focused before simulating shift-enter",
7894            );
7895        });
7896
7897        cx.simulate_keystrokes("shift-enter");
7898        cx.run_until_parked();
7899
7900        let query_text_after = bar.read_with(cx, |bar, cx| bar.query_editor.read(cx).text(cx));
7901        assert!(
7902            !query_text_after.contains('\n'),
7903            "shift-enter must not insert a newline into the query buffer; got {:?}",
7904            query_text_after,
7905        );
7906
7907        let active_after = bar.read_with(cx, |bar, _| bar.active_match_index());
7908        assert_eq!(
7909            active_after,
7910            Some(initial_count - 1),
7911            "shift-enter should have wrapped active match from 0 to {} (got {:?})",
7912            initial_count - 1,
7913            active_after,
7914        );
7915    }
7916
7917    /// `f3`/`shift-f3` are bound in the broad `AcpThread` context (like buffer
7918    /// search's pane-level `cmd-g`), so they must navigate matches even when
7919    /// focus is outside the search bar.
7920    #[gpui::test]
7921    async fn test_thread_search_navigates_from_outside_search_bar(cx: &mut TestAppContext) {
7922        init_test(cx);
7923        cx.update(|cx| {
7924            search::init(cx);
7925            let mut default_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
7926                "keymaps/default-linux.json",
7927                cx,
7928            )
7929            .unwrap();
7930            for binding in &mut default_bindings {
7931                binding.set_meta(settings::KeybindSource::Default.meta());
7932            }
7933            cx.bind_keys(default_bindings);
7934        });
7935
7936        let connection = StubAgentConnection::new();
7937        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7938            acp::ContentChunk::new(
7939                "Banana banana banana, multiple banana mentions in this reply.".into(),
7940            ),
7941        )]);
7942
7943        let (conversation_view, cx) =
7944            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
7945        add_to_workspace(conversation_view.clone(), cx);
7946
7947        let thread =
7948            active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
7949        thread
7950            .update(cx, |thread, cx| thread.send_raw("Need banana help", cx))
7951            .await
7952            .unwrap();
7953        cx.run_until_parked();
7954
7955        let thread_view = active_thread(&conversation_view, cx);
7956        thread_view.update_in(cx, |view, window, cx| {
7957            view.toggle_search(&crate::ToggleSearch, window, cx);
7958        });
7959        cx.run_until_parked();
7960
7961        let bar = thread_view
7962            .read_with(cx, |view, _| view.thread_search_bar.clone())
7963            .expect("thread_search_bar should be set after toggle_search");
7964        bar.update_in(cx, |bar, window, cx| {
7965            bar.query_editor.update(cx, |editor, cx| {
7966                editor.set_text("banana", window, cx);
7967            });
7968            bar.update_matches(window, cx);
7969        });
7970        cx.run_until_parked();
7971
7972        let initial_count = bar.read_with(cx, |bar, _| bar.match_count());
7973        assert!(
7974            initial_count >= 2,
7975            "test precondition: need ≥ 2 matches, got {}",
7976            initial_count,
7977        );
7978        assert_eq!(
7979            bar.read_with(cx, |bar, _| bar.active_match_index()),
7980            Some(0)
7981        );
7982
7983        // Move focus out of the search bar, back into the thread view itself.
7984        let thread_focus = thread_view.read_with(cx, |view, cx| view.focus_handle(cx));
7985        cx.update(|window, cx| window.focus(&thread_focus, cx));
7986        cx.run_until_parked();
7987        cx.update(|window, cx| {
7988            let bar_focused = bar.read_with(cx, |bar, cx| {
7989                bar.query_editor
7990                    .focus_handle(cx)
7991                    .contains_focused(window, cx)
7992            });
7993            assert!(!bar_focused, "search bar must not be focused for this test");
7994        });
7995
7996        cx.simulate_keystrokes("f3");
7997        cx.run_until_parked();
7998        assert_eq!(
7999            bar.read_with(cx, |bar, _| bar.active_match_index()),
8000            Some(1),
8001            "f3 from outside the bar should advance to the next match",
8002        );
8003
8004        cx.simulate_keystrokes("shift-f3");
8005        cx.run_until_parked();
8006        assert_eq!(
8007            bar.read_with(cx, |bar, _| bar.active_match_index()),
8008            Some(0),
8009            "shift-f3 from outside the bar should return to the previous match",
8010        );
8011    }
8012
8013    #[gpui::test]
8014    async fn test_message_editing_cancel(cx: &mut TestAppContext) {
8015        init_test(cx);
8016
8017        let connection = StubAgentConnection::new();
8018
8019        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8020            acp::ContentChunk::new("Response".into()),
8021        )]);
8022
8023        let (conversation_view, cx) =
8024            setup_conversation_view(StubAgentServer::new(connection), cx).await;
8025        add_to_workspace(conversation_view.clone(), cx);
8026
8027        let message_editor = message_editor(&conversation_view, cx);
8028        message_editor.update_in(cx, |editor, window, cx| {
8029            editor.set_text("Original message to edit", window, cx);
8030        });
8031        active_thread(&conversation_view, cx)
8032            .update_in(cx, |view, window, cx| view.send(window, cx));
8033
8034        cx.run_until_parked();
8035
8036        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
8037            assert_eq!(
8038                view.active_thread()
8039                    .and_then(|active| active.read(cx).editing_message),
8040                None
8041            );
8042
8043            view.active_thread()
8044                .map(|active| &active.read(cx).entry_view_state)
8045                .as_ref()
8046                .unwrap()
8047                .read(cx)
8048                .entry(0)
8049                .unwrap()
8050                .message_editor()
8051                .unwrap()
8052                .clone()
8053        });
8054
8055        // Focus
8056        cx.focus(&user_message_editor);
8057        conversation_view.read_with(cx, |view, cx| {
8058            assert_eq!(
8059                view.active_thread()
8060                    .and_then(|active| active.read(cx).editing_message),
8061                Some(0)
8062            );
8063        });
8064
8065        // Edit
8066        user_message_editor.update_in(cx, |editor, window, cx| {
8067            editor.set_text("Edited message content", window, cx);
8068        });
8069
8070        // Cancel
8071        user_message_editor.update_in(cx, |_editor, window, cx| {
8072            window.dispatch_action(Box::new(editor::actions::Cancel), cx);
8073        });
8074
8075        conversation_view.read_with(cx, |view, cx| {
8076            assert_eq!(
8077                view.active_thread()
8078                    .and_then(|active| active.read(cx).editing_message),
8079                None
8080            );
8081        });
8082
8083        user_message_editor.read_with(cx, |editor, cx| {
8084            assert_eq!(editor.text(cx), "Original message to edit");
8085        });
8086    }
8087
8088    #[gpui::test]
8089    async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) {
8090        init_test(cx);
8091
8092        let connection = StubAgentConnection::new();
8093
8094        let (conversation_view, cx) =
8095            setup_conversation_view(StubAgentServer::new(connection), cx).await;
8096        add_to_workspace(conversation_view.clone(), cx);
8097
8098        let message_editor = message_editor(&conversation_view, cx);
8099        message_editor.update_in(cx, |editor, window, cx| {
8100            editor.set_text("", window, cx);
8101        });
8102
8103        let thread = cx.read(|cx| {
8104            conversation_view
8105                .read(cx)
8106                .active_thread()
8107                .unwrap()
8108                .read(cx)
8109                .thread
8110                .clone()
8111        });
8112        let entries_before = cx.read(|cx| thread.read(cx).entries().len());
8113
8114        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
8115            view.send(window, cx);
8116        });
8117        cx.run_until_parked();
8118
8119        let entries_after = cx.read(|cx| thread.read(cx).entries().len());
8120        assert_eq!(
8121            entries_before, entries_after,
8122            "No message should be sent when editor is empty"
8123        );
8124    }
8125
8126    #[gpui::test]
8127    async fn test_message_editing_regenerate(cx: &mut TestAppContext) {
8128        init_test(cx);
8129
8130        let connection = StubAgentConnection::new();
8131
8132        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8133            acp::ContentChunk::new("Response".into()),
8134        )]);
8135
8136        let (conversation_view, cx) =
8137            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
8138        add_to_workspace(conversation_view.clone(), cx);
8139
8140        let message_editor = message_editor(&conversation_view, cx);
8141        message_editor.update_in(cx, |editor, window, cx| {
8142            editor.set_text("Original message to edit", window, cx);
8143        });
8144        active_thread(&conversation_view, cx)
8145            .update_in(cx, |view, window, cx| view.send(window, cx));
8146
8147        cx.run_until_parked();
8148
8149        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
8150            assert_eq!(
8151                view.active_thread()
8152                    .and_then(|active| active.read(cx).editing_message),
8153                None
8154            );
8155            assert_eq!(
8156                view.active_thread()
8157                    .unwrap()
8158                    .read(cx)
8159                    .thread
8160                    .read(cx)
8161                    .entries()
8162                    .len(),
8163                2
8164            );
8165
8166            view.active_thread()
8167                .map(|active| &active.read(cx).entry_view_state)
8168                .as_ref()
8169                .unwrap()
8170                .read(cx)
8171                .entry(0)
8172                .unwrap()
8173                .message_editor()
8174                .unwrap()
8175                .clone()
8176        });
8177
8178        // Focus
8179        cx.focus(&user_message_editor);
8180
8181        // Edit
8182        user_message_editor.update_in(cx, |editor, window, cx| {
8183            editor.set_text("Edited message content", window, cx);
8184        });
8185
8186        // Send
8187        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8188            acp::ContentChunk::new("New Response".into()),
8189        )]);
8190
8191        user_message_editor.update_in(cx, |_editor, window, cx| {
8192            window.dispatch_action(Box::new(Chat), cx);
8193        });
8194
8195        cx.run_until_parked();
8196
8197        conversation_view.read_with(cx, |view, cx| {
8198            assert_eq!(
8199                view.active_thread()
8200                    .and_then(|active| active.read(cx).editing_message),
8201                None
8202            );
8203
8204            let entries = view
8205                .active_thread()
8206                .unwrap()
8207                .read(cx)
8208                .thread
8209                .read(cx)
8210                .entries();
8211            assert_eq!(entries.len(), 2);
8212            assert_eq!(
8213                entries[0].to_markdown(cx),
8214                "## User\n\nEdited message content\n\n"
8215            );
8216            assert_eq!(
8217                entries[1].to_markdown(cx),
8218                "## Assistant\n\nNew Response\n\n"
8219            );
8220
8221            let entry_view_state = view
8222                .active_thread()
8223                .map(|active| &active.read(cx).entry_view_state)
8224                .unwrap();
8225            let new_editor = entry_view_state.read_with(cx, |state, _cx| {
8226                assert!(!state.entry(1).unwrap().has_content());
8227                state.entry(0).unwrap().message_editor().unwrap().clone()
8228            });
8229
8230            assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
8231        })
8232    }
8233
8234    #[gpui::test]
8235    async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
8236        init_test(cx);
8237
8238        let connection = StubAgentConnection::new();
8239
8240        let (conversation_view, cx) =
8241            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
8242        add_to_workspace(conversation_view.clone(), cx);
8243
8244        let message_editor = message_editor(&conversation_view, cx);
8245        message_editor.update_in(cx, |editor, window, cx| {
8246            editor.set_text("Original message to edit", window, cx);
8247        });
8248        active_thread(&conversation_view, cx)
8249            .update_in(cx, |view, window, cx| view.send(window, cx));
8250
8251        cx.run_until_parked();
8252
8253        let (user_message_editor, session_id) = conversation_view.read_with(cx, |view, cx| {
8254            let thread = view.active_thread().unwrap().read(cx).thread.read(cx);
8255            assert_eq!(thread.entries().len(), 1);
8256
8257            let editor = view
8258                .active_thread()
8259                .map(|active| &active.read(cx).entry_view_state)
8260                .as_ref()
8261                .unwrap()
8262                .read(cx)
8263                .entry(0)
8264                .unwrap()
8265                .message_editor()
8266                .unwrap()
8267                .clone();
8268
8269            (editor, thread.session_id().clone())
8270        });
8271
8272        // Focus
8273        cx.focus(&user_message_editor);
8274
8275        conversation_view.read_with(cx, |view, cx| {
8276            assert_eq!(
8277                view.active_thread()
8278                    .and_then(|active| active.read(cx).editing_message),
8279                Some(0)
8280            );
8281        });
8282
8283        // Edit
8284        user_message_editor.update_in(cx, |editor, window, cx| {
8285            editor.set_text("Edited message content", window, cx);
8286        });
8287
8288        conversation_view.read_with(cx, |view, cx| {
8289            assert_eq!(
8290                view.active_thread()
8291                    .and_then(|active| active.read(cx).editing_message),
8292                Some(0)
8293            );
8294        });
8295
8296        // Finish streaming response
8297        cx.update(|_, cx| {
8298            connection.send_update(
8299                session_id.clone(),
8300                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())),
8301                cx,
8302            );
8303            connection.end_turn(session_id, acp::StopReason::EndTurn);
8304        });
8305
8306        conversation_view.read_with(cx, |view, cx| {
8307            assert_eq!(
8308                view.active_thread()
8309                    .and_then(|active| active.read(cx).editing_message),
8310                Some(0)
8311            );
8312        });
8313
8314        cx.run_until_parked();
8315
8316        // Should still be editing
8317        cx.update(|window, cx| {
8318            assert!(user_message_editor.focus_handle(cx).is_focused(window));
8319            assert_eq!(
8320                conversation_view
8321                    .read(cx)
8322                    .active_thread()
8323                    .and_then(|active| active.read(cx).editing_message),
8324                Some(0)
8325            );
8326            assert_eq!(
8327                user_message_editor.read(cx).text(cx),
8328                "Edited message content"
8329            );
8330        });
8331    }
8332
8333    #[gpui::test]
8334    async fn test_stale_stop_does_not_disable_follow_tail_during_regenerate(
8335        cx: &mut TestAppContext,
8336    ) {
8337        init_test(cx);
8338
8339        let connection = StubAgentConnection::new();
8340
8341        let (conversation_view, cx) =
8342            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
8343        add_to_workspace(conversation_view.clone(), cx);
8344
8345        let message_editor = message_editor(&conversation_view, cx);
8346        message_editor.update_in(cx, |editor, window, cx| {
8347            editor.set_text("Original message to edit", window, cx);
8348        });
8349        active_thread(&conversation_view, cx)
8350            .update_in(cx, |view, window, cx| view.send(window, cx));
8351
8352        cx.run_until_parked();
8353
8354        let user_message_editor = conversation_view.read_with(cx, |view, cx| {
8355            view.active_thread()
8356                .map(|active| &active.read(cx).entry_view_state)
8357                .as_ref()
8358                .unwrap()
8359                .read(cx)
8360                .entry(0)
8361                .unwrap()
8362                .message_editor()
8363                .unwrap()
8364                .clone()
8365        });
8366
8367        cx.focus(&user_message_editor);
8368        user_message_editor.update_in(cx, |editor, window, cx| {
8369            editor.set_text("Edited message content", window, cx);
8370        });
8371
8372        user_message_editor.update_in(cx, |_editor, window, cx| {
8373            window.dispatch_action(Box::new(Chat), cx);
8374        });
8375
8376        cx.run_until_parked();
8377
8378        conversation_view.read_with(cx, |view, cx| {
8379            let active = view.active_thread().unwrap();
8380            let active = active.read(cx);
8381
8382            assert_eq!(active.thread.read(cx).status(), ThreadStatus::Generating);
8383            assert!(
8384                active.list_state.is_following_tail(),
8385                "stale stop events from the cancelled turn must not disable follow-tail for the new turn"
8386            );
8387        });
8388    }
8389
8390    struct GeneratingThreadSetup {
8391        conversation_view: Entity<ConversationView>,
8392        thread: Entity<AcpThread>,
8393        message_editor: Entity<MessageEditor>,
8394    }
8395
8396    async fn setup_generating_thread(
8397        cx: &mut TestAppContext,
8398    ) -> (GeneratingThreadSetup, &mut VisualTestContext) {
8399        let connection = StubAgentConnection::new();
8400
8401        let (conversation_view, cx) =
8402            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
8403        add_to_workspace(conversation_view.clone(), cx);
8404
8405        let message_editor = message_editor(&conversation_view, cx);
8406        message_editor.update_in(cx, |editor, window, cx| {
8407            editor.set_text("Hello", window, cx);
8408        });
8409        active_thread(&conversation_view, cx)
8410            .update_in(cx, |view, window, cx| view.send(window, cx));
8411
8412        let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
8413            let thread = view
8414                .active_thread()
8415                .as_ref()
8416                .unwrap()
8417                .read(cx)
8418                .thread
8419                .clone();
8420            (thread.clone(), thread.read(cx).session_id().clone())
8421        });
8422
8423        cx.run_until_parked();
8424
8425        cx.update(|_, cx| {
8426            connection.send_update(
8427                session_id.clone(),
8428                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
8429                    "Response chunk".into(),
8430                )),
8431                cx,
8432            );
8433        });
8434
8435        cx.run_until_parked();
8436
8437        thread.read_with(cx, |thread, _cx| {
8438            assert_eq!(thread.status(), ThreadStatus::Generating);
8439        });
8440
8441        (
8442            GeneratingThreadSetup {
8443                conversation_view,
8444                thread,
8445                message_editor,
8446            },
8447            cx,
8448        )
8449    }
8450
8451    #[gpui::test]
8452    async fn test_escape_cancels_generation_from_conversation_focus(cx: &mut TestAppContext) {
8453        init_test(cx);
8454
8455        let (setup, cx) = setup_generating_thread(cx).await;
8456
8457        let focus_handle = setup
8458            .conversation_view
8459            .read_with(cx, |view, cx| view.focus_handle(cx));
8460        cx.update(|window, cx| {
8461            window.focus(&focus_handle, cx);
8462        });
8463
8464        setup.conversation_view.update_in(cx, |_, window, cx| {
8465            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
8466        });
8467
8468        cx.run_until_parked();
8469
8470        setup.thread.read_with(cx, |thread, _cx| {
8471            assert_eq!(thread.status(), ThreadStatus::Idle);
8472        });
8473    }
8474
8475    #[gpui::test]
8476    async fn test_escape_cancels_generation_from_editor_focus(cx: &mut TestAppContext) {
8477        init_test(cx);
8478
8479        let (setup, cx) = setup_generating_thread(cx).await;
8480
8481        let editor_focus_handle = setup
8482            .message_editor
8483            .read_with(cx, |editor, cx| editor.focus_handle(cx));
8484        cx.update(|window, cx| {
8485            window.focus(&editor_focus_handle, cx);
8486        });
8487
8488        setup.message_editor.update_in(cx, |_, window, cx| {
8489            window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx);
8490        });
8491
8492        cx.run_until_parked();
8493
8494        setup.thread.read_with(cx, |thread, _cx| {
8495            assert_eq!(thread.status(), ThreadStatus::Idle);
8496        });
8497    }
8498
8499    #[gpui::test]
8500    async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) {
8501        init_test(cx);
8502
8503        let (conversation_view, cx) =
8504            setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
8505        add_to_workspace(conversation_view.clone(), cx);
8506
8507        let thread = conversation_view.read_with(cx, |view, cx| {
8508            view.active_thread().unwrap().read(cx).thread.clone()
8509        });
8510
8511        thread.read_with(cx, |thread, _cx| {
8512            assert_eq!(thread.status(), ThreadStatus::Idle);
8513        });
8514
8515        let focus_handle = conversation_view.read_with(cx, |view, _cx| view.focus_handle.clone());
8516        cx.update(|window, cx| {
8517            window.focus(&focus_handle, cx);
8518        });
8519
8520        conversation_view.update_in(cx, |_, window, cx| {
8521            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
8522        });
8523
8524        cx.run_until_parked();
8525
8526        thread.read_with(cx, |thread, _cx| {
8527            assert_eq!(thread.status(), ThreadStatus::Idle);
8528        });
8529    }
8530
8531    #[gpui::test]
8532    async fn test_interrupt(cx: &mut TestAppContext) {
8533        init_test(cx);
8534
8535        let connection = StubAgentConnection::new();
8536
8537        let (conversation_view, cx) =
8538            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
8539        add_to_workspace(conversation_view.clone(), cx);
8540
8541        let message_editor = message_editor(&conversation_view, cx);
8542        message_editor.update_in(cx, |editor, window, cx| {
8543            editor.set_text("Message 1", window, cx);
8544        });
8545        active_thread(&conversation_view, cx)
8546            .update_in(cx, |view, window, cx| view.send(window, cx));
8547
8548        let (thread, session_id) = conversation_view.read_with(cx, |view, cx| {
8549            let thread = view.active_thread().unwrap().read(cx).thread.clone();
8550
8551            (thread.clone(), thread.read(cx).session_id().clone())
8552        });
8553
8554        cx.run_until_parked();
8555
8556        cx.update(|_, cx| {
8557            connection.send_update(
8558                session_id.clone(),
8559                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
8560                    "Message 1 resp".into(),
8561                )),
8562                cx,
8563            );
8564        });
8565
8566        cx.run_until_parked();
8567
8568        thread.read_with(cx, |thread, cx| {
8569            assert_eq!(
8570                thread.to_markdown(cx),
8571                indoc::indoc! {"
8572                        ## User
8573
8574                        Message 1
8575
8576                        ## Assistant
8577
8578                        Message 1 resp
8579
8580                    "}
8581            )
8582        });
8583
8584        message_editor.update_in(cx, |editor, window, cx| {
8585            editor.set_text("Message 2", window, cx);
8586        });
8587        active_thread(&conversation_view, cx)
8588            .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx));
8589
8590        cx.update(|_, cx| {
8591            // Simulate a response sent after beginning to cancel
8592            connection.send_update(
8593                session_id.clone(),
8594                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())),
8595                cx,
8596            );
8597        });
8598
8599        cx.run_until_parked();
8600
8601        // Last Message 1 response should appear before Message 2
8602        thread.read_with(cx, |thread, cx| {
8603            assert_eq!(
8604                thread.to_markdown(cx),
8605                indoc::indoc! {"
8606                        ## User
8607
8608                        Message 1
8609
8610                        ## Assistant
8611
8612                        Message 1 response
8613
8614                        ## User
8615
8616                        Message 2
8617
8618                    "}
8619            )
8620        });
8621
8622        cx.update(|_, cx| {
8623            connection.send_update(
8624                session_id.clone(),
8625                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
8626                    "Message 2 response".into(),
8627                )),
8628                cx,
8629            );
8630            connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
8631        });
8632
8633        cx.run_until_parked();
8634
8635        thread.read_with(cx, |thread, cx| {
8636            assert_eq!(
8637                thread.to_markdown(cx),
8638                indoc::indoc! {"
8639                        ## User
8640
8641                        Message 1
8642
8643                        ## Assistant
8644
8645                        Message 1 response
8646
8647                        ## User
8648
8649                        Message 2
8650
8651                        ## Assistant
8652
8653                        Message 2 response
8654
8655                    "}
8656            )
8657        });
8658    }
8659
8660    #[gpui::test]
8661    async fn test_message_editing_insert_selections(cx: &mut TestAppContext) {
8662        init_test(cx);
8663
8664        let connection = StubAgentConnection::new();
8665        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8666            acp::ContentChunk::new("Response".into()),
8667        )]);
8668
8669        let (conversation_view, cx) =
8670            setup_conversation_view(StubAgentServer::new(connection), cx).await;
8671        add_to_workspace(conversation_view.clone(), cx);
8672
8673        let message_editor = message_editor(&conversation_view, cx);
8674        message_editor.update_in(cx, |editor, window, cx| {
8675            editor.set_text("Original message to edit", window, cx)
8676        });
8677        active_thread(&conversation_view, cx)
8678            .update_in(cx, |view, window, cx| view.send(window, cx));
8679        cx.run_until_parked();
8680
8681        let user_message_editor = conversation_view.read_with(cx, |conversation_view, cx| {
8682            conversation_view
8683                .active_thread()
8684                .map(|active| &active.read(cx).entry_view_state)
8685                .as_ref()
8686                .unwrap()
8687                .read(cx)
8688                .entry(0)
8689                .expect("Should have at least one entry")
8690                .message_editor()
8691                .expect("Should have message editor")
8692                .clone()
8693        });
8694
8695        cx.focus(&user_message_editor);
8696        conversation_view.read_with(cx, |view, cx| {
8697            assert_eq!(
8698                view.active_thread()
8699                    .and_then(|active| active.read(cx).editing_message),
8700                Some(0)
8701            );
8702        });
8703
8704        // Ensure to edit the focused message before proceeding otherwise, since
8705        // its content is not different from what was sent, focus will be lost.
8706        user_message_editor.update_in(cx, |editor, window, cx| {
8707            editor.set_text("Original message to edit with ", window, cx)
8708        });
8709
8710        // Create a simple buffer with some text so we can create a selection
8711        // that will then be added to the message being edited.
8712        let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
8713            (
8714                conversation_view.workspace.clone(),
8715                conversation_view.project.clone(),
8716            )
8717        });
8718        let buffer = project.update(cx, |project, cx| {
8719            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
8720        });
8721
8722        workspace
8723            .update_in(cx, |workspace, window, cx| {
8724                let editor = cx.new(|cx| {
8725                    let mut editor =
8726                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
8727
8728                    editor.change_selections(Default::default(), window, cx, |selections| {
8729                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
8730                    });
8731
8732                    editor
8733                });
8734                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
8735            })
8736            .unwrap();
8737
8738        conversation_view.update_in(cx, |view, window, cx| {
8739            assert_eq!(
8740                view.active_thread()
8741                    .and_then(|active| active.read(cx).editing_message),
8742                Some(0)
8743            );
8744            let workspace = workspace.upgrade().unwrap();
8745            let selection = workspace
8746                .update(cx, |workspace, cx| {
8747                    AgentContextSource::from_active(workspace, cx)?
8748                        .read_selection(workspace, false, cx)
8749                })
8750                .unwrap();
8751            view.insert_selection(selection, window, cx);
8752        });
8753
8754        user_message_editor.read_with(cx, |editor, cx| {
8755            let text = editor.editor().read(cx).text(cx);
8756            let expected_text = String::from("Original message to edit with selection ");
8757
8758            assert_eq!(text, expected_text);
8759        });
8760    }
8761
8762    #[gpui::test]
8763    async fn test_insert_selections(cx: &mut TestAppContext) {
8764        init_test(cx);
8765
8766        let connection = StubAgentConnection::new();
8767        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8768            acp::ContentChunk::new("Response".into()),
8769        )]);
8770
8771        let (conversation_view, cx) =
8772            setup_conversation_view(StubAgentServer::new(connection), cx).await;
8773        add_to_workspace(conversation_view.clone(), cx);
8774
8775        let message_editor = message_editor(&conversation_view, cx);
8776        message_editor.update_in(cx, |editor, window, cx| {
8777            editor.set_text("Can you review this snippet ", window, cx)
8778        });
8779
8780        // Create a simple buffer with some text so we can create a selection
8781        // that will then be added to the message being edited.
8782        let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| {
8783            (
8784                conversation_view.workspace.clone(),
8785                conversation_view.project.clone(),
8786            )
8787        });
8788        let buffer = project.update(cx, |project, cx| {
8789            project.create_local_buffer("let a = 10 + 10;", None, false, cx)
8790        });
8791
8792        workspace
8793            .update_in(cx, |workspace, window, cx| {
8794                let editor = cx.new(|cx| {
8795                    let mut editor =
8796                        Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx);
8797
8798                    editor.change_selections(Default::default(), window, cx, |selections| {
8799                        selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]);
8800                    });
8801
8802                    editor
8803                });
8804                workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx);
8805            })
8806            .unwrap();
8807
8808        conversation_view.update_in(cx, |view, window, cx| {
8809            assert_eq!(
8810                view.active_thread()
8811                    .and_then(|active| active.read(cx).editing_message),
8812                None
8813            );
8814            let workspace = view.workspace.upgrade().unwrap();
8815            let selection = workspace
8816                .update(cx, |workspace, cx| {
8817                    AgentContextSource::from_active(workspace, cx)?
8818                        .read_selection(workspace, false, cx)
8819                })
8820                .unwrap();
8821            view.insert_selection(selection, window, cx);
8822        });
8823
8824        message_editor.read_with(cx, |editor, cx| {
8825            let text = editor.text(cx);
8826            let expected_txt = String::from("Can you review this snippet selection ");
8827
8828            assert_eq!(text, expected_txt);
8829        })
8830    }
8831
8832    #[gpui::test]
8833    async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
8834        init_test(cx);
8835
8836        let tool_call_id = acp::ToolCallId::new("terminal-1");
8837        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`")
8838            .kind(acp::ToolKind::Edit);
8839
8840        let permission_options = ToolPermissionContext::new(
8841            TerminalTool::NAME,
8842            vec!["cargo build --release".to_string()],
8843        )
8844        .build_permission_options();
8845
8846        let connection =
8847            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
8848                tool_call_id.clone(),
8849                permission_options,
8850            )]));
8851
8852        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
8853
8854        let (conversation_view, cx) =
8855            setup_conversation_view(StubAgentServer::new(connection), cx).await;
8856
8857        // Disable notifications to avoid popup windows
8858        cx.update(|_window, cx| {
8859            AgentSettings::override_global(
8860                AgentSettings {
8861                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
8862                    ..AgentSettings::get_global(cx).clone()
8863                },
8864                cx,
8865            );
8866        });
8867
8868        let message_editor = message_editor(&conversation_view, cx);
8869        message_editor.update_in(cx, |editor, window, cx| {
8870            editor.set_text("Run cargo build", window, cx);
8871        });
8872
8873        active_thread(&conversation_view, cx)
8874            .update_in(cx, |view, window, cx| view.send(window, cx));
8875
8876        cx.run_until_parked();
8877
8878        // Verify the tool call is in WaitingForConfirmation state with the expected options
8879        conversation_view.read_with(cx, |conversation_view, cx| {
8880            let thread = conversation_view
8881                .active_thread()
8882                .expect("Thread should exist")
8883                .read(cx)
8884                .thread
8885                .clone();
8886            let thread = thread.read(cx);
8887
8888            let tool_call = thread.entries().iter().find_map(|entry| {
8889                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
8890                    Some(call)
8891                } else {
8892                    None
8893                }
8894            });
8895
8896            assert!(tool_call.is_some(), "Expected a tool call entry");
8897            let tool_call = tool_call.unwrap();
8898
8899            // Verify it's waiting for confirmation
8900            assert!(
8901                matches!(
8902                    tool_call.status,
8903                    acp_thread::ToolCallStatus::WaitingForConfirmation { .. }
8904                ),
8905                "Expected WaitingForConfirmation status, got {:?}",
8906                tool_call.status
8907            );
8908
8909            // Verify the options count (granularity options only, no separate Deny option)
8910            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
8911                &tool_call.status
8912            {
8913                let PermissionOptions::Dropdown(choices) = options else {
8914                    panic!("Expected dropdown permission options");
8915                };
8916
8917                assert_eq!(
8918                    choices.len(),
8919                    3,
8920                    "Expected 3 permission options (granularity only)"
8921                );
8922
8923                // Verify specific button labels (now using neutral names)
8924                let labels: Vec<&str> = choices
8925                    .iter()
8926                    .map(|choice| choice.allow.name.as_ref())
8927                    .collect();
8928                assert!(
8929                    labels.contains(&"Always for terminal"),
8930                    "Missing 'Always for terminal' option"
8931                );
8932                assert!(
8933                    labels.contains(&"Always for `cargo build` commands"),
8934                    "Missing pattern option"
8935                );
8936                assert!(
8937                    labels.contains(&"Only this time"),
8938                    "Missing 'Only this time' option"
8939                );
8940            }
8941        });
8942    }
8943
8944    #[gpui::test]
8945    async fn test_tool_permission_buttons_edit_file_with_path_pattern(cx: &mut TestAppContext) {
8946        init_test(cx);
8947
8948        let tool_call_id = acp::ToolCallId::new("edit-file-1");
8949        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`")
8950            .kind(acp::ToolKind::Edit);
8951
8952        let permission_options =
8953            ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()])
8954                .build_permission_options();
8955
8956        let connection =
8957            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
8958                tool_call_id.clone(),
8959                permission_options,
8960            )]));
8961
8962        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
8963
8964        let (conversation_view, cx) =
8965            setup_conversation_view(StubAgentServer::new(connection), cx).await;
8966
8967        // Disable notifications
8968        cx.update(|_window, cx| {
8969            AgentSettings::override_global(
8970                AgentSettings {
8971                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
8972                    ..AgentSettings::get_global(cx).clone()
8973                },
8974                cx,
8975            );
8976        });
8977
8978        let message_editor = message_editor(&conversation_view, cx);
8979        message_editor.update_in(cx, |editor, window, cx| {
8980            editor.set_text("Edit the main file", window, cx);
8981        });
8982
8983        active_thread(&conversation_view, cx)
8984            .update_in(cx, |view, window, cx| view.send(window, cx));
8985
8986        cx.run_until_parked();
8987
8988        // Verify the options
8989        conversation_view.read_with(cx, |conversation_view, cx| {
8990            let thread = conversation_view
8991                .active_thread()
8992                .expect("Thread should exist")
8993                .read(cx)
8994                .thread
8995                .clone();
8996            let thread = thread.read(cx);
8997
8998            let tool_call = thread.entries().iter().find_map(|entry| {
8999                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
9000                    Some(call)
9001                } else {
9002                    None
9003                }
9004            });
9005
9006            assert!(tool_call.is_some(), "Expected a tool call entry");
9007            let tool_call = tool_call.unwrap();
9008
9009            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
9010                &tool_call.status
9011            {
9012                let PermissionOptions::Dropdown(choices) = options else {
9013                    panic!("Expected dropdown permission options");
9014                };
9015
9016                let labels: Vec<&str> = choices
9017                    .iter()
9018                    .map(|choice| choice.allow.name.as_ref())
9019                    .collect();
9020                assert!(
9021                    labels.contains(&"Always for edit file"),
9022                    "Missing 'Always for edit file' option"
9023                );
9024                assert!(
9025                    labels.contains(&"Always for `src/`"),
9026                    "Missing path pattern option"
9027                );
9028            } else {
9029                panic!("Expected WaitingForConfirmation status");
9030            }
9031        });
9032    }
9033
9034    #[gpui::test]
9035    async fn test_tool_permission_buttons_fetch_with_domain_pattern(cx: &mut TestAppContext) {
9036        init_test(cx);
9037
9038        let tool_call_id = acp::ToolCallId::new("fetch-1");
9039        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Fetch `https://docs.rs/gpui`")
9040            .kind(acp::ToolKind::Fetch);
9041
9042        let permission_options =
9043            ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()])
9044                .build_permission_options();
9045
9046        let connection =
9047            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9048                tool_call_id.clone(),
9049                permission_options,
9050            )]));
9051
9052        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9053
9054        let (conversation_view, cx) =
9055            setup_conversation_view(StubAgentServer::new(connection), cx).await;
9056
9057        // Disable notifications
9058        cx.update(|_window, cx| {
9059            AgentSettings::override_global(
9060                AgentSettings {
9061                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9062                    ..AgentSettings::get_global(cx).clone()
9063                },
9064                cx,
9065            );
9066        });
9067
9068        let message_editor = message_editor(&conversation_view, cx);
9069        message_editor.update_in(cx, |editor, window, cx| {
9070            editor.set_text("Fetch the docs", window, cx);
9071        });
9072
9073        active_thread(&conversation_view, cx)
9074            .update_in(cx, |view, window, cx| view.send(window, cx));
9075
9076        cx.run_until_parked();
9077
9078        // Verify the options
9079        conversation_view.read_with(cx, |conversation_view, cx| {
9080            let thread = conversation_view
9081                .active_thread()
9082                .expect("Thread should exist")
9083                .read(cx)
9084                .thread
9085                .clone();
9086            let thread = thread.read(cx);
9087
9088            let tool_call = thread.entries().iter().find_map(|entry| {
9089                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
9090                    Some(call)
9091                } else {
9092                    None
9093                }
9094            });
9095
9096            assert!(tool_call.is_some(), "Expected a tool call entry");
9097            let tool_call = tool_call.unwrap();
9098
9099            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
9100                &tool_call.status
9101            {
9102                let PermissionOptions::Dropdown(choices) = options else {
9103                    panic!("Expected dropdown permission options");
9104                };
9105
9106                let labels: Vec<&str> = choices
9107                    .iter()
9108                    .map(|choice| choice.allow.name.as_ref())
9109                    .collect();
9110                assert!(
9111                    labels.contains(&"Always for fetch"),
9112                    "Missing 'Always for fetch' option"
9113                );
9114                assert!(
9115                    labels.contains(&"Always for `docs.rs`"),
9116                    "Missing domain pattern option"
9117                );
9118            } else {
9119                panic!("Expected WaitingForConfirmation status");
9120            }
9121        });
9122    }
9123
9124    #[gpui::test]
9125    async fn test_tool_permission_buttons_without_pattern(cx: &mut TestAppContext) {
9126        init_test(cx);
9127
9128        let tool_call_id = acp::ToolCallId::new("terminal-no-pattern-1");
9129        let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `./deploy.sh --production`")
9130            .kind(acp::ToolKind::Edit);
9131
9132        // No pattern button since ./deploy.sh doesn't match the alphanumeric pattern
9133        let permission_options = ToolPermissionContext::new(
9134            TerminalTool::NAME,
9135            vec!["./deploy.sh --production".to_string()],
9136        )
9137        .build_permission_options();
9138
9139        let connection =
9140            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9141                tool_call_id.clone(),
9142                permission_options,
9143            )]));
9144
9145        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9146
9147        let (conversation_view, cx) =
9148            setup_conversation_view(StubAgentServer::new(connection), cx).await;
9149
9150        // Disable notifications
9151        cx.update(|_window, cx| {
9152            AgentSettings::override_global(
9153                AgentSettings {
9154                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9155                    ..AgentSettings::get_global(cx).clone()
9156                },
9157                cx,
9158            );
9159        });
9160
9161        let message_editor = message_editor(&conversation_view, cx);
9162        message_editor.update_in(cx, |editor, window, cx| {
9163            editor.set_text("Run the deploy script", window, cx);
9164        });
9165
9166        active_thread(&conversation_view, cx)
9167            .update_in(cx, |view, window, cx| view.send(window, cx));
9168
9169        cx.run_until_parked();
9170
9171        // Verify only 2 options (no pattern button when command doesn't match pattern)
9172        conversation_view.read_with(cx, |conversation_view, cx| {
9173            let thread = conversation_view
9174                .active_thread()
9175                .expect("Thread should exist")
9176                .read(cx)
9177                .thread
9178                .clone();
9179            let thread = thread.read(cx);
9180
9181            let tool_call = thread.entries().iter().find_map(|entry| {
9182                if let acp_thread::AgentThreadEntry::ToolCall(call) = entry {
9183                    Some(call)
9184                } else {
9185                    None
9186                }
9187            });
9188
9189            assert!(tool_call.is_some(), "Expected a tool call entry");
9190            let tool_call = tool_call.unwrap();
9191
9192            if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
9193                &tool_call.status
9194            {
9195                let PermissionOptions::Dropdown(choices) = options else {
9196                    panic!("Expected dropdown permission options");
9197                };
9198
9199                assert_eq!(
9200                    choices.len(),
9201                    2,
9202                    "Expected 2 permission options (no pattern option)"
9203                );
9204
9205                let labels: Vec<&str> = choices
9206                    .iter()
9207                    .map(|choice| choice.allow.name.as_ref())
9208                    .collect();
9209                assert!(
9210                    labels.contains(&"Always for terminal"),
9211                    "Missing 'Always for terminal' option"
9212                );
9213                assert!(
9214                    labels.contains(&"Only this time"),
9215                    "Missing 'Only this time' option"
9216                );
9217                // Should NOT contain a pattern option
9218                assert!(
9219                    !labels.iter().any(|l| l.contains("commands")),
9220                    "Should not have pattern option"
9221                );
9222            } else {
9223                panic!("Expected WaitingForConfirmation status");
9224            }
9225        });
9226    }
9227
9228    #[gpui::test]
9229    async fn test_authorize_tool_call_action_triggers_authorization(cx: &mut TestAppContext) {
9230        init_test(cx);
9231
9232        let tool_call_id = acp::ToolCallId::new("action-test-1");
9233        let tool_call =
9234            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit);
9235
9236        let permission_options =
9237            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo test".to_string()])
9238                .build_permission_options();
9239
9240        let connection =
9241            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9242                tool_call_id.clone(),
9243                permission_options,
9244            )]));
9245
9246        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9247
9248        let (conversation_view, cx) =
9249            setup_conversation_view(StubAgentServer::new(connection), cx).await;
9250        add_to_workspace(conversation_view.clone(), cx);
9251
9252        cx.update(|_window, cx| {
9253            AgentSettings::override_global(
9254                AgentSettings {
9255                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9256                    ..AgentSettings::get_global(cx).clone()
9257                },
9258                cx,
9259            );
9260        });
9261
9262        let message_editor = message_editor(&conversation_view, cx);
9263        message_editor.update_in(cx, |editor, window, cx| {
9264            editor.set_text("Run tests", window, cx);
9265        });
9266
9267        active_thread(&conversation_view, cx)
9268            .update_in(cx, |view, window, cx| view.send(window, cx));
9269
9270        cx.run_until_parked();
9271
9272        // Verify tool call is waiting for confirmation
9273        conversation_view.read_with(cx, |conversation_view, cx| {
9274            let tool_call = conversation_view.pending_tool_call(cx);
9275            assert!(
9276                tool_call.is_some(),
9277                "Expected a tool call waiting for confirmation"
9278            );
9279        });
9280
9281        // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection)
9282        conversation_view.update_in(cx, |_, window, cx| {
9283            window.dispatch_action(
9284                crate::AuthorizeToolCall {
9285                    tool_call_id: "action-test-1".to_string(),
9286                    option_id: "allow".to_string(),
9287                    option_kind: "AllowOnce".to_string(),
9288                }
9289                .boxed_clone(),
9290                cx,
9291            );
9292        });
9293
9294        cx.run_until_parked();
9295
9296        // Verify tool call is no longer waiting for confirmation (was authorized)
9297        conversation_view.read_with(cx, |conversation_view, cx| {
9298            let tool_call = conversation_view.pending_tool_call(cx);
9299            assert!(
9300                tool_call.is_none(),
9301                "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action"
9302            );
9303        });
9304    }
9305
9306    #[gpui::test]
9307    async fn test_authorize_tool_call_action_with_pattern_option(cx: &mut TestAppContext) {
9308        init_test(cx);
9309
9310        let tool_call_id = acp::ToolCallId::new("pattern-action-test-1");
9311        let tool_call =
9312            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
9313
9314        let permission_options =
9315            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
9316                .build_permission_options();
9317
9318        let connection =
9319            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9320                tool_call_id.clone(),
9321                permission_options.clone(),
9322            )]));
9323
9324        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9325
9326        let (conversation_view, cx) =
9327            setup_conversation_view(StubAgentServer::new(connection), cx).await;
9328        add_to_workspace(conversation_view.clone(), cx);
9329
9330        cx.update(|_window, cx| {
9331            AgentSettings::override_global(
9332                AgentSettings {
9333                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9334                    ..AgentSettings::get_global(cx).clone()
9335                },
9336                cx,
9337            );
9338        });
9339
9340        let message_editor = message_editor(&conversation_view, cx);
9341        message_editor.update_in(cx, |editor, window, cx| {
9342            editor.set_text("Install dependencies", window, cx);
9343        });
9344
9345        active_thread(&conversation_view, cx)
9346            .update_in(cx, |view, window, cx| view.send(window, cx));
9347
9348        cx.run_until_parked();
9349
9350        // Find the pattern option ID (the choice with non-empty sub_patterns)
9351        let pattern_option = match &permission_options {
9352            PermissionOptions::Dropdown(choices) => choices
9353                .iter()
9354                .find(|choice| !choice.sub_patterns.is_empty())
9355                .map(|choice| &choice.allow)
9356                .expect("Should have a pattern option for npm command"),
9357            _ => panic!("Expected dropdown permission options"),
9358        };
9359
9360        // Dispatch action with the pattern option (simulating "Always allow `npm` commands")
9361        conversation_view.update_in(cx, |_, window, cx| {
9362            window.dispatch_action(
9363                crate::AuthorizeToolCall {
9364                    tool_call_id: "pattern-action-test-1".to_string(),
9365                    option_id: pattern_option.option_id.0.to_string(),
9366                    option_kind: "AllowAlways".to_string(),
9367                }
9368                .boxed_clone(),
9369                cx,
9370            );
9371        });
9372
9373        cx.run_until_parked();
9374
9375        // Verify tool call was authorized
9376        conversation_view.read_with(cx, |conversation_view, cx| {
9377            let tool_call = conversation_view.pending_tool_call(cx);
9378            assert!(
9379                tool_call.is_none(),
9380                "Tool call should be authorized after selecting pattern option"
9381            );
9382        });
9383    }
9384
9385    #[gpui::test]
9386    async fn test_granularity_selection_updates_state(cx: &mut TestAppContext) {
9387        init_test(cx);
9388
9389        let tool_call_id = acp::ToolCallId::new("granularity-test-1");
9390        let tool_call =
9391            acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit);
9392
9393        let permission_options =
9394            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
9395                .build_permission_options();
9396
9397        let connection =
9398            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9399                tool_call_id.clone(),
9400                permission_options.clone(),
9401            )]));
9402
9403        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9404
9405        let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
9406        add_to_workspace(thread_view.clone(), cx);
9407
9408        cx.update(|_window, cx| {
9409            AgentSettings::override_global(
9410                AgentSettings {
9411                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9412                    ..AgentSettings::get_global(cx).clone()
9413                },
9414                cx,
9415            );
9416        });
9417
9418        let message_editor = message_editor(&thread_view, cx);
9419        message_editor.update_in(cx, |editor, window, cx| {
9420            editor.set_text("Build the project", window, cx);
9421        });
9422
9423        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
9424
9425        cx.run_until_parked();
9426
9427        // Verify default granularity is the last option (index 2 = "Only this time")
9428        thread_view.read_with(cx, |thread_view, cx| {
9429            let state = thread_view.active_thread().unwrap();
9430            let selected = state.read(cx).permission_selections.get(&tool_call_id);
9431            assert!(
9432                selected.is_none(),
9433                "Should have no selection initially (defaults to last)"
9434            );
9435        });
9436
9437        // Select the first option (index 0 = "Always for terminal")
9438        thread_view.update_in(cx, |_, window, cx| {
9439            window.dispatch_action(
9440                crate::SelectPermissionGranularity {
9441                    tool_call_id: "granularity-test-1".to_string(),
9442                    index: 0,
9443                }
9444                .boxed_clone(),
9445                cx,
9446            );
9447        });
9448
9449        cx.run_until_parked();
9450
9451        // Verify the selection was updated
9452        thread_view.read_with(cx, |thread_view, cx| {
9453            let state = thread_view.active_thread().unwrap();
9454            let selected = state.read(cx).permission_selections.get(&tool_call_id);
9455            assert_eq!(
9456                selected.and_then(|s| s.choice_index()),
9457                Some(0),
9458                "Should have selected index 0"
9459            );
9460        });
9461    }
9462
9463    #[gpui::test]
9464    async fn test_allow_button_uses_selected_granularity(cx: &mut TestAppContext) {
9465        init_test(cx);
9466
9467        let tool_call_id = acp::ToolCallId::new("allow-granularity-test-1");
9468        let tool_call =
9469            acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
9470
9471        let permission_options =
9472            ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
9473                .build_permission_options();
9474
9475        // Verify we have the expected options
9476        let PermissionOptions::Dropdown(choices) = &permission_options else {
9477            panic!("Expected dropdown permission options");
9478        };
9479
9480        assert_eq!(choices.len(), 3);
9481        assert!(
9482            choices[0]
9483                .allow
9484                .option_id
9485                .0
9486                .contains("always_allow:terminal")
9487        );
9488        assert!(
9489            choices[1]
9490                .allow
9491                .option_id
9492                .0
9493                .contains("always_allow:terminal")
9494        );
9495        assert!(!choices[1].sub_patterns.is_empty());
9496        assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
9497
9498        let connection =
9499            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9500                tool_call_id.clone(),
9501                permission_options.clone(),
9502            )]));
9503
9504        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9505
9506        let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
9507        add_to_workspace(thread_view.clone(), cx);
9508
9509        cx.update(|_window, cx| {
9510            AgentSettings::override_global(
9511                AgentSettings {
9512                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9513                    ..AgentSettings::get_global(cx).clone()
9514                },
9515                cx,
9516            );
9517        });
9518
9519        let message_editor = message_editor(&thread_view, cx);
9520        message_editor.update_in(cx, |editor, window, cx| {
9521            editor.set_text("Install dependencies", window, cx);
9522        });
9523
9524        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
9525
9526        cx.run_until_parked();
9527
9528        // Select the pattern option (index 1 = "Always for `npm` commands")
9529        thread_view.update_in(cx, |_, window, cx| {
9530            window.dispatch_action(
9531                crate::SelectPermissionGranularity {
9532                    tool_call_id: "allow-granularity-test-1".to_string(),
9533                    index: 1,
9534                }
9535                .boxed_clone(),
9536                cx,
9537            );
9538        });
9539
9540        cx.run_until_parked();
9541
9542        // Simulate clicking the Allow button by dispatching AllowOnce action
9543        // which should use the selected granularity
9544        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
9545            view.allow_once(&AllowOnce, window, cx)
9546        });
9547
9548        cx.run_until_parked();
9549
9550        // Verify tool call was authorized
9551        thread_view.read_with(cx, |thread_view, cx| {
9552            let tool_call = thread_view.pending_tool_call(cx);
9553            assert!(
9554                tool_call.is_none(),
9555                "Tool call should be authorized after Allow with pattern granularity"
9556            );
9557        });
9558    }
9559
9560    #[gpui::test]
9561    async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) {
9562        init_test(cx);
9563
9564        let tool_call_id = acp::ToolCallId::new("deny-granularity-test-1");
9565        let tool_call =
9566            acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit);
9567
9568        let permission_options =
9569            ToolPermissionContext::new(TerminalTool::NAME, vec!["git push".to_string()])
9570                .build_permission_options();
9571
9572        let connection =
9573            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
9574                tool_call_id.clone(),
9575                permission_options.clone(),
9576            )]));
9577
9578        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
9579
9580        let (conversation_view, cx) =
9581            setup_conversation_view(StubAgentServer::new(connection), cx).await;
9582        add_to_workspace(conversation_view.clone(), cx);
9583
9584        cx.update(|_window, cx| {
9585            AgentSettings::override_global(
9586                AgentSettings {
9587                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
9588                    ..AgentSettings::get_global(cx).clone()
9589                },
9590                cx,
9591            );
9592        });
9593
9594        let message_editor = message_editor(&conversation_view, cx);
9595        message_editor.update_in(cx, |editor, window, cx| {
9596            editor.set_text("Push changes", window, cx);
9597        });
9598
9599        active_thread(&conversation_view, cx)
9600            .update_in(cx, |view, window, cx| view.send(window, cx));
9601
9602        cx.run_until_parked();
9603
9604        // Use default granularity (last option = "Only this time")
9605        // Simulate clicking the Deny button
9606        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
9607            view.reject_once(&RejectOnce, window, cx)
9608        });
9609
9610        cx.run_until_parked();
9611
9612        // Verify tool call was rejected (no longer waiting for confirmation)
9613        conversation_view.read_with(cx, |conversation_view, cx| {
9614            let tool_call = conversation_view.pending_tool_call(cx);
9615            assert!(
9616                tool_call.is_none(),
9617                "Tool call should be rejected after Deny"
9618            );
9619        });
9620    }
9621
9622    #[gpui::test]
9623    async fn test_option_id_transformation_for_allow() {
9624        let permission_options = ToolPermissionContext::new(
9625            TerminalTool::NAME,
9626            vec!["cargo build --release".to_string()],
9627        )
9628        .build_permission_options();
9629
9630        let PermissionOptions::Dropdown(choices) = permission_options else {
9631            panic!("Expected dropdown permission options");
9632        };
9633
9634        let allow_ids: Vec<String> = choices
9635            .iter()
9636            .map(|choice| choice.allow.option_id.0.to_string())
9637            .collect();
9638
9639        assert!(allow_ids.contains(&"allow".to_string()));
9640        assert_eq!(
9641            allow_ids
9642                .iter()
9643                .filter(|id| *id == "always_allow:terminal")
9644                .count(),
9645            2,
9646            "Expected two always_allow:terminal IDs (one whole-tool, one pattern with sub_patterns)"
9647        );
9648    }
9649
9650    #[gpui::test]
9651    async fn test_option_id_transformation_for_deny() {
9652        let permission_options = ToolPermissionContext::new(
9653            TerminalTool::NAME,
9654            vec!["cargo build --release".to_string()],
9655        )
9656        .build_permission_options();
9657
9658        let PermissionOptions::Dropdown(choices) = permission_options else {
9659            panic!("Expected dropdown permission options");
9660        };
9661
9662        let deny_ids: Vec<String> = choices
9663            .iter()
9664            .map(|choice| choice.deny.option_id.0.to_string())
9665            .collect();
9666
9667        assert!(deny_ids.contains(&"deny".to_string()));
9668        assert_eq!(
9669            deny_ids
9670                .iter()
9671                .filter(|id| *id == "always_deny:terminal")
9672                .count(),
9673            2,
9674            "Expected two always_deny:terminal IDs (one whole-tool, one pattern with sub_patterns)"
9675        );
9676    }
9677
9678    fn flat_allow_deny_options() -> PermissionOptions {
9679        PermissionOptions::Flat(vec![
9680            acp::PermissionOption::new(
9681                acp::PermissionOptionId::new("allow"),
9682                "Yes",
9683                acp::PermissionOptionKind::AllowOnce,
9684            ),
9685            acp::PermissionOption::new(
9686                acp::PermissionOptionId::new("deny"),
9687                "No",
9688                acp::PermissionOptionKind::RejectOnce,
9689            ),
9690        ])
9691    }
9692
9693    fn sandbox_permission_options() -> PermissionOptions {
9694        PermissionOptions::Flat(vec![
9695            acp::PermissionOption::new(
9696                acp::PermissionOptionId::new("allow"),
9697                "Allow once",
9698                acp::PermissionOptionKind::AllowOnce,
9699            ),
9700            acp::PermissionOption::new(
9701                acp::PermissionOptionId::new("allow_thread"),
9702                "Allow for this thread",
9703                acp::PermissionOptionKind::AllowAlways,
9704            ),
9705            acp::PermissionOption::new(
9706                acp::PermissionOptionId::new("allow_always"),
9707                "Allow always",
9708                acp::PermissionOptionKind::AllowAlways,
9709            ),
9710            acp::PermissionOption::new(
9711                acp::PermissionOptionId::new("deny"),
9712                "Deny",
9713                acp::PermissionOptionKind::RejectOnce,
9714            ),
9715        ])
9716    }
9717
9718    #[test]
9719    fn permission_option_for_action_prefers_explicit_sandbox_allow_always() {
9720        let options = sandbox_permission_options();
9721
9722        let option =
9723            super::permission_option_for_action(&options, acp::PermissionOptionKind::AllowAlways)
9724                .unwrap();
9725
9726        assert_eq!(option.option_id.0.as_ref(), "allow_always");
9727    }
9728
9729    #[test]
9730    fn resolve_outcome_from_selection_flat_allow_picks_allow_once() {
9731        let options = flat_allow_deny_options();
9732
9733        let outcome = super::resolve_outcome_from_selection(&options, None, true).unwrap();
9734
9735        assert_eq!(outcome.option_id.0.as_ref(), "allow");
9736        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
9737    }
9738
9739    #[test]
9740    fn resolve_outcome_from_selection_flat_deny_picks_reject_once() {
9741        let options = flat_allow_deny_options();
9742
9743        let outcome = super::resolve_outcome_from_selection(&options, None, false).unwrap();
9744
9745        assert_eq!(outcome.option_id.0.as_ref(), "deny");
9746        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::RejectOnce);
9747    }
9748
9749    #[test]
9750    fn resolve_outcome_from_selection_flat_ignores_selection() {
9751        let options = flat_allow_deny_options();
9752        // Flat options never consult the granularity choice, even if one is set.
9753        let selection = thread_view::PermissionSelection::Choice(42);
9754
9755        let outcome =
9756            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
9757
9758        assert_eq!(outcome.option_id.0.as_ref(), "allow");
9759    }
9760
9761    #[test]
9762    fn resolve_outcome_from_selection_dropdown_defaults_to_last_choice_when_no_selection() {
9763        let options =
9764            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
9765                .build_permission_options();
9766
9767        let outcome = super::resolve_outcome_from_selection(&options, None, true).unwrap();
9768
9769        // Last choice is "Only this time" → option_id "allow".
9770        assert_eq!(outcome.option_id.0.as_ref(), "allow");
9771        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
9772    }
9773
9774    #[test]
9775    fn resolve_outcome_from_selection_dropdown_uses_selected_choice() {
9776        let options =
9777            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
9778                .build_permission_options();
9779        let selection = thread_view::PermissionSelection::Choice(0);
9780
9781        let outcome =
9782            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
9783
9784        // Choice 0 = "Always for terminal".
9785        assert!(outcome.option_id.0.contains("always_allow:terminal"));
9786        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowAlways);
9787    }
9788
9789    #[test]
9790    fn resolve_outcome_from_selection_dropdown_out_of_range_falls_back_to_last() {
9791        let options =
9792            ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
9793                .build_permission_options();
9794        let selection = thread_view::PermissionSelection::Choice(999);
9795
9796        let outcome =
9797            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
9798
9799        // choices.get(999) is None, falls back to choices.last() → "Only this time".
9800        assert_eq!(outcome.option_id.0.as_ref(), "allow");
9801    }
9802
9803    #[test]
9804    fn resolve_outcome_from_selection_pattern_mode_with_empty_checked_falls_back_to_last_choice() {
9805        // Pipeline commands produce `DropdownWithPatterns`, which is required for
9806        // `SelectedPatterns` to be meaningful.
9807        let options = ToolPermissionContext::new(
9808            TerminalTool::NAME,
9809            vec!["cargo test 2>&1 | tail".to_string()],
9810        )
9811        .build_permission_options();
9812        assert!(matches!(
9813            options,
9814            PermissionOptions::DropdownWithPatterns { .. }
9815        ));
9816        // Pattern mode with zero checked patterns: `build_outcome_for_checked_patterns`
9817        // returns None, so we fall through to `choice_index()` (which is None for
9818        // `SelectedPatterns`) and default to `choices.last()`.
9819        let selection = thread_view::PermissionSelection::SelectedPatterns(vec![]);
9820
9821        let outcome =
9822            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
9823
9824        assert_eq!(outcome.option_id.0.as_ref(), "allow");
9825        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
9826    }
9827
9828    #[test]
9829    fn resolve_outcome_from_selection_pattern_mode_with_checked_uses_always_with_params() {
9830        let options = ToolPermissionContext::new(
9831            TerminalTool::NAME,
9832            vec!["cargo test 2>&1 | tail".to_string()],
9833        )
9834        .build_permission_options();
9835        assert!(matches!(
9836            options,
9837            PermissionOptions::DropdownWithPatterns { .. }
9838        ));
9839        let selection = thread_view::PermissionSelection::SelectedPatterns(vec![0]);
9840
9841        let outcome =
9842            super::resolve_outcome_from_selection(&options, Some(&selection), true).unwrap();
9843
9844        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowAlways);
9845        assert!(
9846            outcome.params.is_some(),
9847            "checked patterns should attach terminal params"
9848        );
9849    }
9850
9851    #[gpui::test]
9852    async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) {
9853        init_test(cx);
9854
9855        let (conversation_view, cx) =
9856            setup_conversation_view(StubAgentServer::default_response(), cx).await;
9857        add_to_workspace(conversation_view.clone(), cx);
9858
9859        let active = active_thread(&conversation_view, cx);
9860        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
9861        let thread = cx.read(|cx| active.read(cx).thread.clone());
9862
9863        title_editor.read_with(cx, |editor, cx| {
9864            assert!(!editor.read_only(cx));
9865        });
9866
9867        cx.focus(&conversation_view);
9868        cx.focus(&title_editor);
9869
9870        cx.dispatch_action(editor::actions::DeleteLine);
9871        cx.simulate_input("My Custom Title");
9872
9873        cx.run_until_parked();
9874
9875        title_editor.read_with(cx, |editor, cx| {
9876            assert_eq!(editor.text(cx), "My Custom Title");
9877        });
9878        thread.read_with(cx, |thread, _cx| {
9879            assert_eq!(thread.title(), Some("My Custom Title".into()));
9880        });
9881    }
9882
9883    #[gpui::test]
9884    async fn test_max_tokens_error_is_rendered(cx: &mut TestAppContext) {
9885        init_test(cx);
9886
9887        let connection = StubAgentConnection::new();
9888
9889        let (conversation_view, cx) =
9890            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
9891
9892        let message_editor = message_editor(&conversation_view, cx);
9893        message_editor.update_in(cx, |editor, window, cx| {
9894            editor.set_text("Some prompt", window, cx);
9895        });
9896        active_thread(&conversation_view, cx)
9897            .update_in(cx, |view, window, cx| view.send(window, cx));
9898
9899        let session_id = conversation_view.read_with(cx, |view, cx| {
9900            view.active_thread()
9901                .unwrap()
9902                .read(cx)
9903                .thread
9904                .read(cx)
9905                .session_id()
9906                .clone()
9907        });
9908
9909        cx.run_until_parked();
9910
9911        cx.update(|_, _cx| {
9912            connection.end_turn(session_id, acp::StopReason::MaxTokens);
9913        });
9914
9915        cx.run_until_parked();
9916
9917        conversation_view.read_with(cx, |conversation_view, cx| {
9918            let state = conversation_view.active_thread().unwrap();
9919            let error = &state.read(cx).thread_error;
9920            assert!(
9921                matches!(error, Some(ThreadError::MaxOutputTokens)),
9922                "Expected ThreadError::MaxOutputTokens, got: {:?}",
9923                error.is_some()
9924            );
9925        });
9926    }
9927
9928    fn create_test_acp_thread(
9929        parent_session_id: Option<acp::SessionId>,
9930        session_id: &str,
9931        connection: Rc<dyn AgentConnection>,
9932        project: Entity<Project>,
9933        cx: &mut App,
9934    ) -> Entity<AcpThread> {
9935        let action_log = cx.new(|_| ActionLog::new(project.clone()));
9936        cx.new(|cx| {
9937            AcpThread::new(
9938                parent_session_id,
9939                None,
9940                None,
9941                connection,
9942                project,
9943                action_log,
9944                acp::SessionId::new(session_id),
9945                watch::Receiver::constant(acp::PromptCapabilities::new()),
9946                cx,
9947            )
9948        })
9949    }
9950
9951    fn request_test_tool_authorization(
9952        thread: &Entity<AcpThread>,
9953        tool_call_id: &str,
9954        option_id: &str,
9955        cx: &mut TestAppContext,
9956    ) -> Task<acp_thread::RequestPermissionOutcome> {
9957        let tool_call_id = acp::ToolCallId::new(tool_call_id);
9958        let label = format!("Tool {tool_call_id}");
9959        let option_id = acp::PermissionOptionId::new(option_id);
9960        cx.update(|cx| {
9961            thread.update(cx, |thread, cx| {
9962                thread
9963                    .request_tool_call_authorization(
9964                        acp::ToolCall::new(tool_call_id, label)
9965                            .kind(acp::ToolKind::Edit)
9966                            .into(),
9967                        PermissionOptions::Flat(vec![acp::PermissionOption::new(
9968                            option_id,
9969                            "Allow",
9970                            acp::PermissionOptionKind::AllowOnce,
9971                        )]),
9972                        acp_thread::AuthorizationKind::PermissionGrant,
9973                        cx,
9974                    )
9975                    .unwrap()
9976            })
9977        })
9978    }
9979
9980    #[gpui::test]
9981    async fn test_conversation_multiple_tool_calls_fifo_ordering(cx: &mut TestAppContext) {
9982        init_test(cx);
9983
9984        let fs = FakeFs::new(cx.executor());
9985        let project = Project::test(fs, [], cx).await;
9986        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
9987
9988        let session_id = acp::SessionId::new("session-1");
9989        let (thread, conversation) = cx.update(|cx| {
9990            let thread =
9991                create_test_acp_thread(None, "session-1", connection.clone(), project.clone(), cx);
9992            let conversation = cx.new(|cx| {
9993                let mut conversation = Conversation::default();
9994                conversation.register_thread(thread.clone(), cx);
9995                conversation
9996            });
9997            (thread, conversation)
9998        });
9999
10000        let _task1 = request_test_tool_authorization(&thread, "tc-1", "allow-1", cx);
10001        let _task2 = request_test_tool_authorization(&thread, "tc-2", "allow-2", cx);
10002
10003        cx.read(|cx| {
10004            let (_, tool_call_id, _) = conversation
10005                .read(cx)
10006                .pending_tool_call(&session_id, cx)
10007                .expect("Expected a pending tool call");
10008            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-1"));
10009        });
10010
10011        cx.update(|cx| {
10012            conversation.update(cx, |conversation, cx| {
10013                conversation.authorize_tool_call(
10014                    session_id.clone(),
10015                    acp::ToolCallId::new("tc-1"),
10016                    SelectedPermissionOutcome::new(
10017                        acp::PermissionOptionId::new("allow-1"),
10018                        acp::PermissionOptionKind::AllowOnce,
10019                    ),
10020                    cx,
10021                );
10022            });
10023        });
10024
10025        cx.run_until_parked();
10026
10027        cx.read(|cx| {
10028            let (_, tool_call_id, _) = conversation
10029                .read(cx)
10030                .pending_tool_call(&session_id, cx)
10031                .expect("Expected tc-2 to be pending after tc-1 was authorized");
10032            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-2"));
10033        });
10034
10035        cx.update(|cx| {
10036            conversation.update(cx, |conversation, cx| {
10037                conversation.authorize_tool_call(
10038                    session_id.clone(),
10039                    acp::ToolCallId::new("tc-2"),
10040                    SelectedPermissionOutcome::new(
10041                        acp::PermissionOptionId::new("allow-2"),
10042                        acp::PermissionOptionKind::AllowOnce,
10043                    ),
10044                    cx,
10045                );
10046            });
10047        });
10048
10049        cx.run_until_parked();
10050
10051        cx.read(|cx| {
10052            assert!(
10053                conversation
10054                    .read(cx)
10055                    .pending_tool_call(&session_id, cx)
10056                    .is_none(),
10057                "Expected no pending tool calls after both were authorized"
10058            );
10059        });
10060    }
10061
10062    #[gpui::test]
10063    async fn test_conversation_subagent_scoped_pending_tool_call(cx: &mut TestAppContext) {
10064        init_test(cx);
10065
10066        let fs = FakeFs::new(cx.executor());
10067        let project = Project::test(fs, [], cx).await;
10068        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
10069
10070        let parent_session_id = acp::SessionId::new("parent");
10071        let subagent_session_id = acp::SessionId::new("subagent");
10072        let (parent_thread, subagent_thread, conversation) = cx.update(|cx| {
10073            let parent_thread =
10074                create_test_acp_thread(None, "parent", connection.clone(), project.clone(), cx);
10075            let subagent_thread = create_test_acp_thread(
10076                Some(acp::SessionId::new("parent")),
10077                "subagent",
10078                connection.clone(),
10079                project.clone(),
10080                cx,
10081            );
10082            let conversation = cx.new(|cx| {
10083                let mut conversation = Conversation::default();
10084                conversation.register_thread(parent_thread.clone(), cx);
10085                conversation.register_thread(subagent_thread.clone(), cx);
10086                conversation
10087            });
10088            (parent_thread, subagent_thread, conversation)
10089        });
10090
10091        let _parent_task =
10092            request_test_tool_authorization(&parent_thread, "parent-tc", "allow-parent", cx);
10093        let _subagent_task =
10094            request_test_tool_authorization(&subagent_thread, "subagent-tc", "allow-subagent", cx);
10095
10096        // Querying with the subagent's session ID returns only the
10097        // subagent's own tool call (subagent path is scoped to its session)
10098        cx.read(|cx| {
10099            let (returned_session_id, tool_call_id, _) = conversation
10100                .read(cx)
10101                .pending_tool_call(&subagent_session_id, cx)
10102                .expect("Expected subagent's pending tool call");
10103            assert_eq!(returned_session_id, subagent_session_id);
10104            assert_eq!(tool_call_id, acp::ToolCallId::new("subagent-tc"));
10105        });
10106
10107        // Querying with the parent's session ID returns the first pending
10108        // request in FIFO order across all sessions
10109        cx.read(|cx| {
10110            let (returned_session_id, tool_call_id, _) = conversation
10111                .read(cx)
10112                .pending_tool_call(&parent_session_id, cx)
10113                .expect("Expected a pending tool call from parent query");
10114            assert_eq!(returned_session_id, parent_session_id);
10115            assert_eq!(tool_call_id, acp::ToolCallId::new("parent-tc"));
10116        });
10117    }
10118
10119    #[gpui::test]
10120    async fn test_conversation_parent_pending_tool_call_returns_first_across_threads(
10121        cx: &mut TestAppContext,
10122    ) {
10123        init_test(cx);
10124
10125        let fs = FakeFs::new(cx.executor());
10126        let project = Project::test(fs, [], cx).await;
10127        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
10128
10129        let session_id_a = acp::SessionId::new("thread-a");
10130        let session_id_b = acp::SessionId::new("thread-b");
10131        let (thread_a, thread_b, conversation) = cx.update(|cx| {
10132            let thread_a =
10133                create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx);
10134            let thread_b =
10135                create_test_acp_thread(None, "thread-b", connection.clone(), project.clone(), cx);
10136            let conversation = cx.new(|cx| {
10137                let mut conversation = Conversation::default();
10138                conversation.register_thread(thread_a.clone(), cx);
10139                conversation.register_thread(thread_b.clone(), cx);
10140                conversation
10141            });
10142            (thread_a, thread_b, conversation)
10143        });
10144
10145        let _task_a = request_test_tool_authorization(&thread_a, "tc-a", "allow-a", cx);
10146        let _task_b = request_test_tool_authorization(&thread_b, "tc-b", "allow-b", cx);
10147
10148        // Both threads are non-subagent, so pending_tool_call always returns
10149        // the first entry from permission_requests (FIFO across all sessions)
10150        cx.read(|cx| {
10151            let (returned_session_id, tool_call_id, _) = conversation
10152                .read(cx)
10153                .pending_tool_call(&session_id_a, cx)
10154                .expect("Expected a pending tool call");
10155            assert_eq!(returned_session_id, session_id_a);
10156            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
10157        });
10158
10159        // Querying with thread-b also returns thread-a's tool call,
10160        // because non-subagent queries always use permission_requests.first()
10161        cx.read(|cx| {
10162            let (returned_session_id, tool_call_id, _) = conversation
10163                .read(cx)
10164                .pending_tool_call(&session_id_b, cx)
10165                .expect("Expected a pending tool call from thread-b query");
10166            assert_eq!(
10167                returned_session_id, session_id_a,
10168                "Non-subagent queries always return the first pending request in FIFO order"
10169            );
10170            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a"));
10171        });
10172
10173        // After authorizing thread-a's tool call, thread-b's becomes first
10174        cx.update(|cx| {
10175            conversation.update(cx, |conversation, cx| {
10176                conversation.authorize_tool_call(
10177                    session_id_a.clone(),
10178                    acp::ToolCallId::new("tc-a"),
10179                    SelectedPermissionOutcome::new(
10180                        acp::PermissionOptionId::new("allow-a"),
10181                        acp::PermissionOptionKind::AllowOnce,
10182                    ),
10183                    cx,
10184                );
10185            });
10186        });
10187
10188        cx.run_until_parked();
10189
10190        cx.read(|cx| {
10191            let (returned_session_id, tool_call_id, _) = conversation
10192                .read(cx)
10193                .pending_tool_call(&session_id_b, cx)
10194                .expect("Expected thread-b's tool call after thread-a's was authorized");
10195            assert_eq!(returned_session_id, session_id_b);
10196            assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b"));
10197        });
10198    }
10199
10200    /// Set up a `ConversationView` whose active thread has a single tool call
10201    /// awaiting permission. Returns the conversation view, its active
10202    /// `ThreadView`, and the entry index of the tool call within the thread.
10203    async fn setup_pending_permission_thread<'a>(
10204        tool_call_id: &str,
10205        cx: &'a mut TestAppContext,
10206    ) -> (
10207        Entity<ConversationView>,
10208        Entity<ThreadView>,
10209        usize,
10210        &'a mut VisualTestContext,
10211    ) {
10212        let tool_call_id_value = acp::ToolCallId::new(tool_call_id);
10213        let tool_call = acp::ToolCall::new(tool_call_id_value.clone(), "Run something")
10214            .kind(acp::ToolKind::Edit);
10215
10216        let connection =
10217            StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
10218                tool_call_id_value.clone(),
10219                PermissionOptions::Flat(vec![acp::PermissionOption::new(
10220                    "allow",
10221                    "Allow",
10222                    acp::PermissionOptionKind::AllowOnce,
10223                )]),
10224            )]));
10225        connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
10226
10227        let (conversation_view, cx) =
10228            setup_conversation_view(StubAgentServer::new(connection), cx).await;
10229        add_to_workspace(conversation_view.clone(), cx);
10230
10231        cx.update(|_window, cx| {
10232            AgentSettings::override_global(
10233                AgentSettings {
10234                    notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
10235                    ..AgentSettings::get_global(cx).clone()
10236                },
10237                cx,
10238            );
10239        });
10240
10241        let message_editor = message_editor(&conversation_view, cx);
10242        message_editor.update_in(cx, |editor, window, cx| {
10243            editor.set_text("Hello", window, cx);
10244        });
10245
10246        active_thread(&conversation_view, cx)
10247            .update_in(cx, |view, window, cx| view.send(window, cx));
10248
10249        cx.run_until_parked();
10250
10251        let thread_view = active_thread(&conversation_view, cx);
10252        let entry_ix = thread_view.read_with(cx, |view, cx| {
10253            view.thread
10254                .read(cx)
10255                .entries()
10256                .iter()
10257                .position(|entry| {
10258                    matches!(
10259                        entry,
10260                        acp_thread::AgentThreadEntry::ToolCall(call)
10261                            if call.id == tool_call_id_value
10262                    )
10263                })
10264                .expect("tool call entry should exist after run_until_parked")
10265        });
10266
10267        (conversation_view, thread_view, entry_ix, cx)
10268    }
10269
10270    struct TestListView {
10271        list_state: ListState,
10272    }
10273
10274    impl Render for TestListView {
10275        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
10276            list(self.list_state.clone(), |_, _, _| {
10277                div().h(px(20.0)).w_full().into_any_element()
10278            })
10279            .size_full()
10280        }
10281    }
10282
10283    fn draw_thread_list_at(
10284        thread_view: &Entity<ThreadView>,
10285        scroll_top: ListOffset,
10286        cx: &mut VisualTestContext,
10287    ) {
10288        let list_state = thread_view.read_with(cx, |view, _cx| view.list_state.clone());
10289        list_state.scroll_to(scroll_top);
10290        cx.draw(
10291            point(px(0.0), px(0.0)),
10292            size(px(100.0), px(20.0)),
10293            |_, cx| {
10294                cx.new(|_| TestListView {
10295                    list_state: list_state.clone(),
10296                })
10297                .into_any_element()
10298            },
10299        );
10300    }
10301
10302    #[gpui::test]
10303    async fn test_permission_row_hidden_when_inline_bounds_unavailable(cx: &mut TestAppContext) {
10304        init_test(cx);
10305
10306        let (_view, thread_view, entry_ix, cx) =
10307            setup_pending_permission_thread("perm-no-bounds", cx).await;
10308
10309        // Pin the scroll top to the entry so it isn't treated as above the
10310        // viewport, forcing the unmeasured-bounds path we want to exercise.
10311        thread_view.read_with(cx, |view, _cx| {
10312            view.list_state.scroll_to(ListOffset {
10313                item_ix: entry_ix,
10314                offset_in_item: px(0.0),
10315            });
10316        });
10317        thread_view.update_in(cx, |view, window, cx| {
10318            assert!(
10319                view.render_main_agent_awaiting_permission(window, cx)
10320                    .is_none(),
10321                "Floating row should stay hidden until the inline prompt has known list bounds"
10322            );
10323        });
10324    }
10325
10326    #[gpui::test]
10327    async fn test_pending_tool_call_for_session_scopes_to_that_session(cx: &mut TestAppContext) {
10328        init_test(cx);
10329
10330        let fs = FakeFs::new(cx.executor());
10331        let project = Project::test(fs, [], cx).await;
10332        let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
10333
10334        let session_id_a = acp::SessionId::new("thread-a");
10335        let session_id_b = acp::SessionId::new("thread-b");
10336        let (thread_a, thread_b, conversation) = cx.update(|cx| {
10337            let thread_a =
10338                create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx);
10339            let thread_b =
10340                create_test_acp_thread(None, "thread-b", connection.clone(), project.clone(), cx);
10341            let conversation = cx.new(|cx| {
10342                let mut conversation = Conversation::default();
10343                conversation.register_thread(thread_a.clone(), cx);
10344                conversation.register_thread(thread_b.clone(), cx);
10345                conversation
10346            });
10347            (thread_a, thread_b, conversation)
10348        });
10349
10350        // Pending tool calls in both threads. Unlike `pending_tool_call`,
10351        // `pending_tool_call_for_session` must not fall back across threads.
10352        let _task_a = request_test_tool_authorization(&thread_a, "tc-a", "allow-a", cx);
10353        let _task_b = request_test_tool_authorization(&thread_b, "tc-b", "allow-b", cx);
10354
10355        cx.read(|cx| {
10356            let tool_call_id_a = conversation
10357                .read(cx)
10358                .pending_tool_call_for_session(&session_id_a, cx)
10359                .expect("Expected a pending tool call in thread A");
10360            assert_eq!(tool_call_id_a, acp::ToolCallId::new("tc-a"));
10361
10362            let tool_call_id_b = conversation
10363                .read(cx)
10364                .pending_tool_call_for_session(&session_id_b, cx)
10365                .expect("Expected a pending tool call in thread B");
10366            assert_eq!(tool_call_id_b, acp::ToolCallId::new("tc-b"));
10367        });
10368    }
10369
10370    #[gpui::test]
10371    async fn test_permission_row_scroll_to_dismisses_row(cx: &mut TestAppContext) {
10372        init_test(cx);
10373
10374        let (_view, thread_view, entry_ix, cx) =
10375            setup_pending_permission_thread("perm-scroll", cx).await;
10376
10377        // Start off-screen below the viewport. The row is visible because the
10378        // item has bounds that do not intersect the viewport.
10379        draw_thread_list_at(
10380            &thread_view,
10381            ListOffset {
10382                item_ix: 0,
10383                offset_in_item: px(0.0),
10384            },
10385            cx,
10386        );
10387        thread_view.read_with(cx, |view, _cx| {
10388            assert!(
10389                view.list_state.bounds_for_item(entry_ix).is_some(),
10390                "The tool call entry must be measured for this test to exercise the\
10391                 \"entry below viewport\" branch. If list overdraw stops measuring\
10392                 offscreen items, this test needs to drive measurement another way."
10393            );
10394        });
10395        thread_view.update_in(cx, |view, window, cx| {
10396            assert!(
10397                view.render_main_agent_awaiting_permission(window, cx)
10398                    .is_some()
10399            );
10400        });
10401
10402        // Simulate clicking "Scroll to": the list scrolls to the entry and the
10403        // measured item bounds intersect the viewport.
10404        draw_thread_list_at(
10405            &thread_view,
10406            ListOffset {
10407                item_ix: entry_ix,
10408                offset_in_item: px(0.0),
10409            },
10410            cx,
10411        );
10412
10413        thread_view.update_in(cx, |view, window, cx| {
10414            assert!(
10415                view.render_main_agent_awaiting_permission(window, cx)
10416                    .is_none(),
10417                "Floating row should disappear after scrolling brings the inline prompt into view"
10418            );
10419        });
10420    }
10421
10422    #[gpui::test]
10423    async fn test_permission_row_does_not_flicker_when_activity_bar_squeezes_list(
10424        cx: &mut TestAppContext,
10425    ) {
10426        init_test(cx);
10427
10428        let (_view, thread_view, _entry_ix, cx) =
10429            setup_pending_permission_thread("perm-flicker", cx).await;
10430
10431        // Give the pending tool call tall content (like a full plan awaiting
10432        // approval), so the floating row embedding it dwarfs the panel.
10433        let thread = thread_view.read_with(cx, |view, _cx| view.thread.clone());
10434        thread.update(cx, |thread, cx| {
10435            thread
10436                .handle_session_update(
10437                    acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
10438                        acp::ToolCallId::new("perm-flicker"),
10439                        acp::ToolCallUpdateFields::new().content(vec![
10440                            acp::ToolCallContent::Content(acp::Content::new(
10441                                acp::ContentBlock::Text(acp::TextContent::new(
10442                                    "Plan step\n\n".repeat(100),
10443                                )),
10444                            )),
10445                        ]),
10446                    )),
10447                    cx,
10448                )
10449                .expect("tool call content update should be accepted");
10450        });
10451        cx.run_until_parked();
10452
10453        // Park the inline prompt below the viewport so the floating row renders.
10454        thread_view.read_with(cx, |view, _cx| {
10455            view.list_state.scroll_to(ListOffset {
10456                item_ix: 0,
10457                offset_in_item: px(0.0),
10458            });
10459        });
10460
10461        // Drive several real window draws. Each draw lays out the activity bar
10462        // (containing the floating row) and the conversation list together, so
10463        // the row's height feeds back into the list viewport height that the
10464        // next frame's visibility decision is based on. Since showing the row
10465        // squeezes the list to zero height, a decision that treats a
10466        // zero-height viewport as "unknown" makes the row's visibility
10467        // oscillate from frame to frame, flickering between the conversation
10468        // and the permission prompt.
10469        let mut row_visibility = Vec::new();
10470        for _ in 0..4 {
10471            thread_view.update(cx, |_, cx| cx.notify());
10472            cx.run_until_parked();
10473            thread_view.update_in(cx, |view, window, cx| {
10474                row_visibility.push(
10475                    view.render_main_agent_awaiting_permission(window, cx)
10476                        .is_some(),
10477                );
10478            });
10479        }
10480        assert_eq!(
10481            row_visibility,
10482            vec![true; 4],
10483            "Floating row visibility must be stable across frames (false entries mean flicker)"
10484        );
10485    }
10486
10487    #[gpui::test]
10488    async fn test_permission_row_shown_when_inline_prompt_is_above_viewport(
10489        cx: &mut TestAppContext,
10490    ) {
10491        init_test(cx);
10492
10493        let (_view, thread_view, entry_ix, cx) =
10494            setup_pending_permission_thread("perm-above", cx).await;
10495
10496        let thread = thread_view.read_with(cx, |view, _cx| view.thread.clone());
10497        thread.update(cx, |thread, cx| {
10498            let result = thread.handle_session_update(
10499                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
10500                    "More content".into(),
10501                )),
10502                cx,
10503            );
10504            assert!(
10505                result.is_ok(),
10506                "following assistant message should be accepted"
10507            );
10508        });
10509
10510        draw_thread_list_at(
10511            &thread_view,
10512            ListOffset {
10513                item_ix: entry_ix + 1,
10514                offset_in_item: px(0.0),
10515            },
10516            cx,
10517        );
10518        thread_view.read_with(cx, |view, _cx| {
10519            assert!(
10520                entry_ix < view.list_state.logical_scroll_top().item_ix,
10521                "The tool call entry should be above the logical scroll top"
10522            );
10523        });
10524        thread_view.update_in(cx, |view, window, cx| {
10525            assert!(
10526                view.render_main_agent_awaiting_permission(window, cx)
10527                    .is_some(),
10528                "Floating row should be visible when the inline prompt is above the viewport"
10529            );
10530        });
10531
10532        // Scrolling up to the entry brings it back into view.
10533        draw_thread_list_at(
10534            &thread_view,
10535            ListOffset {
10536                item_ix: entry_ix,
10537                offset_in_item: px(0.0),
10538            },
10539            cx,
10540        );
10541        thread_view.update_in(cx, |view, window, cx| {
10542            assert!(
10543                view.render_main_agent_awaiting_permission(window, cx)
10544                    .is_none(),
10545                "Floating row should disappear after scrolling brings the inline prompt into view"
10546            );
10547        });
10548    }
10549
10550    #[gpui::test]
10551    async fn test_permission_row_disappears_when_authorized(cx: &mut TestAppContext) {
10552        init_test(cx);
10553
10554        let (conversation_view, thread_view, _entry_ix, cx) =
10555            setup_pending_permission_thread("perm-allow", cx).await;
10556
10557        // Park the inline prompt below the viewport so the floating row would render.
10558        draw_thread_list_at(
10559            &thread_view,
10560            ListOffset {
10561                item_ix: 0,
10562                offset_in_item: px(0.0),
10563            },
10564            cx,
10565        );
10566        thread_view.update_in(cx, |view, window, cx| {
10567            assert!(
10568                view.render_main_agent_awaiting_permission(window, cx)
10569                    .is_some(),
10570                "Floating row should be visible before authorizing"
10571            );
10572        });
10573
10574        // Dispatch the same AuthorizeToolCall action the row's Allow button
10575        // wires up.
10576        conversation_view.update_in(cx, |_, window, cx| {
10577            window.dispatch_action(
10578                crate::AuthorizeToolCall {
10579                    tool_call_id: "perm-allow".to_string(),
10580                    option_id: "allow".to_string(),
10581                    option_kind: "AllowOnce".to_string(),
10582                }
10583                .boxed_clone(),
10584                cx,
10585            );
10586        });
10587        cx.run_until_parked();
10588
10589        conversation_view.read_with(cx, |view, cx| {
10590            assert!(
10591                view.pending_tool_call(cx).is_none(),
10592                "Tool call should no longer be pending after Allow is clicked"
10593            );
10594        });
10595        thread_view.update_in(cx, |view, window, cx| {
10596            assert!(
10597                view.render_main_agent_awaiting_permission(window, cx)
10598                    .is_none(),
10599                "Floating row should disappear once the permission is granted"
10600            );
10601        });
10602    }
10603
10604    #[gpui::test]
10605    async fn test_permission_row_ignores_subagent_requests(cx: &mut TestAppContext) {
10606        init_test(cx);
10607
10608        // Build a baseline ConversationView with no permission requests, so we
10609        // have a real `ThreadView` to call `render_main_agent_awaiting_permission` on.
10610        let (conversation_view, cx) =
10611            setup_conversation_view(StubAgentServer::default_response(), cx).await;
10612        add_to_workspace(conversation_view.clone(), cx);
10613
10614        let message_editor = message_editor(&conversation_view, cx);
10615        message_editor.update_in(cx, |editor, window, cx| {
10616            editor.set_text("Hello", window, cx);
10617        });
10618        active_thread(&conversation_view, cx)
10619            .update_in(cx, |view, window, cx| view.send(window, cx));
10620        cx.run_until_parked();
10621
10622        let thread_view = active_thread(&conversation_view, cx);
10623        let parent_session_id =
10624            thread_view.read_with(cx, |view, cx| view.thread.read(cx).session_id().clone());
10625        let conversation = thread_view.read_with(cx, |view, _cx| view.conversation.clone());
10626
10627        // Attach a subagent thread with a pending tool-call permission request.
10628        let fs = FakeFs::new(cx.executor());
10629        let project = Project::test(fs, [], cx).await;
10630        let stub: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
10631        let subagent_thread = cx.update(|_window, cx| {
10632            create_test_acp_thread(
10633                Some(parent_session_id.clone()),
10634                "subagent",
10635                stub,
10636                project,
10637                cx,
10638            )
10639        });
10640        conversation.update(cx, |conversation, cx| {
10641            conversation.register_thread(subagent_thread.clone(), cx);
10642        });
10643        let _subagent_task =
10644            request_test_tool_authorization(&subagent_thread, "sub-tc", "allow-sub", cx);
10645        cx.run_until_parked();
10646
10647        cx.read(|cx| {
10648            assert!(
10649                conversation
10650                    .read(cx)
10651                    .pending_tool_call_for_session(&parent_session_id, cx)
10652                    .is_none(),
10653                "Subagent requests must not surface as pending in the parent session"
10654            );
10655            assert!(
10656                !conversation
10657                    .read(cx)
10658                    .subagents_awaiting_permission(cx)
10659                    .is_empty(),
10660                "Subagent permission row should still see the pending request"
10661            );
10662        });
10663
10664        thread_view.update_in(cx, |view, window, cx| {
10665            assert!(
10666                view.render_main_agent_awaiting_permission(window, cx)
10667                    .is_none(),
10668                "Subagent permission requests should not trigger the main-agent floating row"
10669            );
10670        });
10671    }
10672
10673    #[gpui::test]
10674    async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) {
10675        init_test(cx);
10676
10677        let (conversation_view, cx) =
10678            setup_conversation_view(StubAgentServer::default_response(), cx).await;
10679
10680        // Add a plain-text message to the queue directly.
10681        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
10682            thread.add_to_queue(
10683                vec![acp::ContentBlock::Text(acp::TextContent::new(
10684                    "queued message".to_string(),
10685                ))],
10686                vec![],
10687                window,
10688                cx,
10689            );
10690            // Main editor must be empty for this path — it is by default, but
10691            // assert to make the precondition explicit.
10692            assert!(thread.message_editor.read(cx).is_empty(cx));
10693            let id = thread.message_queue.first_id().unwrap();
10694            thread.move_queued_message_to_main_editor(id, None, None, window, cx);
10695        });
10696
10697        cx.run_until_parked();
10698
10699        // Queue should now be empty.
10700        let queue_len = active_thread(&conversation_view, cx)
10701            .read_with(cx, |thread, _cx| thread.message_queue.len());
10702        assert_eq!(queue_len, 0, "Queue should be empty after move");
10703
10704        // Main editor should contain the queued message text.
10705        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
10706        assert_eq!(
10707            text, "queued message",
10708            "Main editor should contain the moved queued message"
10709        );
10710    }
10711
10712    #[gpui::test]
10713    async fn test_move_queued_message_to_non_empty_main_editor(cx: &mut TestAppContext) {
10714        init_test(cx);
10715
10716        let (conversation_view, cx) =
10717            setup_conversation_view(StubAgentServer::default_response(), cx).await;
10718
10719        // Seed the main editor with existing content.
10720        message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| {
10721            editor.set_message(
10722                vec![acp::ContentBlock::Text(acp::TextContent::new(
10723                    "existing content".to_string(),
10724                ))],
10725                window,
10726                cx,
10727            );
10728        });
10729
10730        // Add a plain-text message to the queue.
10731        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
10732            thread.add_to_queue(
10733                vec![acp::ContentBlock::Text(acp::TextContent::new(
10734                    "queued message".to_string(),
10735                ))],
10736                vec![],
10737                window,
10738                cx,
10739            );
10740            let id = thread.message_queue.first_id().unwrap();
10741            thread.move_queued_message_to_main_editor(id, None, None, window, cx);
10742        });
10743
10744        cx.run_until_parked();
10745
10746        // Queue should now be empty.
10747        let queue_len = active_thread(&conversation_view, cx)
10748            .read_with(cx, |thread, _cx| thread.message_queue.len());
10749        assert_eq!(queue_len, 0, "Queue should be empty after move");
10750
10751        // Main editor should contain existing content + separator + queued content.
10752        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
10753        assert_eq!(
10754            text, "existing content\n\nqueued message",
10755            "Main editor should have existing content and queued message separated by two newlines"
10756        );
10757    }
10758
10759    #[gpui::test]
10760    async fn test_move_up_in_empty_editor_restores_last_queued_message(cx: &mut TestAppContext) {
10761        init_test(cx);
10762
10763        let (conversation_view, cx) =
10764            setup_conversation_view(StubAgentServer::default_response(), cx).await;
10765        add_to_workspace(conversation_view.clone(), cx);
10766
10767        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
10768            thread.add_to_queue(
10769                vec![acp::ContentBlock::Text(acp::TextContent::new(
10770                    "first queued".to_string(),
10771                ))],
10772                vec![],
10773                window,
10774                cx,
10775            );
10776            thread.add_to_queue(
10777                vec![acp::ContentBlock::Text(acp::TextContent::new(
10778                    "second queued".to_string(),
10779                ))],
10780                vec![],
10781                window,
10782                cx,
10783            );
10784        });
10785        cx.run_until_parked();
10786
10787        let editor = message_editor(&conversation_view, cx);
10788        cx.focus(&editor);
10789
10790        editor.update_in(cx, |_editor, window, cx| {
10791            window.dispatch_action(Box::new(zed_actions::editor::MoveUp), cx);
10792        });
10793        cx.run_until_parked();
10794
10795        let queue_len = active_thread(&conversation_view, cx)
10796            .read_with(cx, |thread, _cx| thread.message_queue.len());
10797        assert_eq!(
10798            queue_len, 1,
10799            "Up arrow should pull the last queued message out of the queue"
10800        );
10801        let text = editor.update(cx, |editor, cx| editor.text(cx));
10802        assert_eq!(
10803            text, "second queued",
10804            "Main editor should contain the last queued message"
10805        );
10806
10807        // With a non-empty editor, another MoveUp must not consume the queue.
10808        editor.update_in(cx, |_editor, window, cx| {
10809            window.dispatch_action(Box::new(zed_actions::editor::MoveUp), cx);
10810        });
10811        cx.run_until_parked();
10812
10813        let queue_len = active_thread(&conversation_view, cx)
10814            .read_with(cx, |thread, _cx| thread.message_queue.len());
10815        assert_eq!(queue_len, 1, "Queue should be untouched");
10816        let text = editor.update(cx, |editor, cx| editor.text(cx));
10817        assert_eq!(text, "second queued");
10818    }
10819
10820    #[gpui::test]
10821    async fn test_paste_text_into_queued_message_promotes_to_main_editor(cx: &mut TestAppContext) {
10822        init_test(cx);
10823
10824        let (conversation_view, cx) =
10825            paste_into_queued_message(cx, ClipboardItem::new_string("PASTED".to_string())).await;
10826
10827        let queue_len = active_thread(&conversation_view, cx)
10828            .read_with(cx, |thread, _cx| thread.message_queue.len());
10829        assert_eq!(queue_len, 0);
10830
10831        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
10832        assert_eq!(text, "queued PASTEDmessage");
10833    }
10834
10835    #[gpui::test]
10836    async fn test_paste_image_into_queued_message_promotes_to_main_editor(cx: &mut TestAppContext) {
10837        init_test(cx);
10838
10839        use base64::Engine as _;
10840        use std::io::Write as _;
10841        let png_bytes = base64::prelude::BASE64_STANDARD
10842            .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
10843            .unwrap();
10844        let mut image_file = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
10845        image_file.write_all(&png_bytes).unwrap();
10846
10847        let (conversation_view, cx) = paste_into_queued_message(
10848            cx,
10849            ClipboardItem {
10850                entries: vec![gpui::ClipboardEntry::ExternalPaths(gpui::ExternalPaths(
10851                    vec![image_file.path().to_path_buf()].into(),
10852                ))],
10853            },
10854        )
10855        .await;
10856
10857        let queue_len = active_thread(&conversation_view, cx)
10858            .read_with(cx, |thread, _cx| thread.message_queue.len());
10859        assert_eq!(queue_len, 0);
10860
10861        let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
10862        let image_name = image_file.path().file_name().unwrap().to_string_lossy();
10863        let expected_uri = acp_thread::MentionUri::PastedImage {
10864            name: image_name.to_string(),
10865        }
10866        .to_uri()
10867        .to_string();
10868        assert_eq!(
10869            text,
10870            format!("queued [@{image_name}]({expected_uri}) message"),
10871        );
10872    }
10873
10874    async fn paste_into_queued_message(
10875        cx: &mut TestAppContext,
10876        clipboard: ClipboardItem,
10877    ) -> (Entity<ConversationView>, &mut VisualTestContext) {
10878        let (conversation_view, cx) =
10879            setup_conversation_view(StubAgentServer::default_response(), cx).await;
10880        add_to_workspace(conversation_view.clone(), cx);
10881
10882        active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| {
10883            thread
10884                .session_capabilities
10885                .write()
10886                .set_prompt_capabilities(acp::PromptCapabilities::new().image(true));
10887            thread.add_to_queue(
10888                vec![acp::ContentBlock::Text(acp::TextContent::new(
10889                    "queued message".to_string(),
10890                ))],
10891                vec![],
10892                window,
10893                cx,
10894            );
10895        });
10896        conversation_view.update(cx, |_, cx| cx.notify());
10897        cx.run_until_parked();
10898
10899        let queued_editor = active_thread(&conversation_view, cx).read_with(cx, |thread, _cx| {
10900            thread
10901                .message_queue
10902                .first()
10903                .map(|entry| entry.editor.clone())
10904                .expect("queued message editor not created")
10905        });
10906
10907        cx.write_to_clipboard(clipboard);
10908
10909        queued_editor.update_in(cx, |message_editor, window, cx| {
10910            message_editor.editor().update(cx, |editor, cx| {
10911                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
10912                    selections.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(7)]);
10913                });
10914            });
10915            message_editor.paste(&Paste, window, cx);
10916        });
10917        cx.run_until_parked();
10918
10919        (conversation_view, cx)
10920    }
10921
10922    #[gpui::test]
10923    async fn test_close_all_sessions_skips_when_unsupported(cx: &mut TestAppContext) {
10924        init_test(cx);
10925
10926        let fs = FakeFs::new(cx.executor());
10927        let project = Project::test(fs, [], cx).await;
10928        let (multi_workspace, cx) =
10929            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10930        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10931
10932        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
10933        let connection_store =
10934            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
10935
10936        // StubAgentConnection defaults to supports_close_session() -> false
10937        let conversation_view = cx.update(|window, cx| {
10938            cx.new(|cx| {
10939                ConversationView::new(
10940                    Rc::new(StubAgentServer::default_response()),
10941                    connection_store,
10942                    Agent::Custom { id: "Test".into() },
10943                    None,
10944                    None,
10945                    None,
10946                    None,
10947                    None,
10948                    workspace.downgrade(),
10949                    project,
10950                    Some(thread_store),
10951                    AgentThreadSource::AgentPanel,
10952                    window,
10953                    cx,
10954                )
10955            })
10956        });
10957
10958        cx.run_until_parked();
10959
10960        conversation_view.read_with(cx, |view, _cx| {
10961            let connected = view.as_connected().expect("Should be connected");
10962            assert!(
10963                !connected.threads.is_empty(),
10964                "There should be at least one thread"
10965            );
10966            assert!(
10967                !connected.connection.supports_close_session(),
10968                "StubAgentConnection should not support close"
10969            );
10970        });
10971
10972        conversation_view
10973            .update(cx, |view, cx| {
10974                view.as_connected()
10975                    .expect("Should be connected")
10976                    .close_all_sessions(cx)
10977            })
10978            .await;
10979    }
10980
10981    #[gpui::test]
10982    async fn test_close_all_sessions_calls_close_when_supported(cx: &mut TestAppContext) {
10983        init_test(cx);
10984
10985        let (conversation_view, cx) =
10986            setup_conversation_view(StubAgentServer::new(CloseCapableConnection::new()), cx).await;
10987
10988        cx.run_until_parked();
10989
10990        let close_capable = conversation_view.read_with(cx, |view, _cx| {
10991            let connected = view.as_connected().expect("Should be connected");
10992            assert!(
10993                !connected.threads.is_empty(),
10994                "There should be at least one thread"
10995            );
10996            assert!(
10997                connected.connection.supports_close_session(),
10998                "CloseCapableConnection should support close"
10999            );
11000            connected
11001                .connection
11002                .clone()
11003                .into_any()
11004                .downcast::<CloseCapableConnection>()
11005                .expect("Should be CloseCapableConnection")
11006        });
11007
11008        conversation_view
11009            .update(cx, |view, cx| {
11010                view.as_connected()
11011                    .expect("Should be connected")
11012                    .close_all_sessions(cx)
11013            })
11014            .await;
11015
11016        let closed_count = close_capable.closed_sessions.lock().len();
11017        assert!(
11018            closed_count > 0,
11019            "close_session should have been called for each thread"
11020        );
11021    }
11022
11023    #[gpui::test]
11024    async fn test_close_session_returns_error_when_unsupported(cx: &mut TestAppContext) {
11025        init_test(cx);
11026
11027        let (conversation_view, cx) =
11028            setup_conversation_view(StubAgentServer::default_response(), cx).await;
11029
11030        cx.run_until_parked();
11031
11032        let result = conversation_view
11033            .update(cx, |view, cx| {
11034                let connected = view.as_connected().expect("Should be connected");
11035                assert!(
11036                    !connected.connection.supports_close_session(),
11037                    "StubAgentConnection should not support close"
11038                );
11039                let thread_view = connected
11040                    .threads
11041                    .values()
11042                    .next()
11043                    .expect("Should have at least one thread");
11044                let session_id = thread_view.read(cx).thread.read(cx).session_id().clone();
11045                connected.connection.clone().close_session(&session_id, cx)
11046            })
11047            .await;
11048
11049        assert!(
11050            result.is_err(),
11051            "close_session should return an error when close is not supported"
11052        );
11053        assert!(
11054            result.unwrap_err().to_string().contains("not supported"),
11055            "Error message should indicate that closing is not supported"
11056        );
11057    }
11058
11059    #[derive(Clone)]
11060    struct CloseCapableConnection {
11061        closed_sessions: Arc<Mutex<Vec<acp::SessionId>>>,
11062    }
11063
11064    impl CloseCapableConnection {
11065        fn new() -> Self {
11066            Self {
11067                closed_sessions: Arc::new(Mutex::new(Vec::new())),
11068            }
11069        }
11070    }
11071
11072    impl AgentConnection for CloseCapableConnection {
11073        fn agent_id(&self) -> AgentId {
11074            AgentId::new("close-capable")
11075        }
11076
11077        fn telemetry_id(&self) -> SharedString {
11078            "close-capable".into()
11079        }
11080
11081        fn new_session(
11082            self: Rc<Self>,
11083            project: Entity<Project>,
11084            work_dirs: PathList,
11085            cx: &mut gpui::App,
11086        ) -> Task<gpui::Result<Entity<AcpThread>>> {
11087            let action_log = cx.new(|_| ActionLog::new(project.clone()));
11088            let thread = cx.new(|cx| {
11089                AcpThread::new(
11090                    None,
11091                    Some("CloseCapableConnection".into()),
11092                    Some(work_dirs),
11093                    self,
11094                    project,
11095                    action_log,
11096                    acp::SessionId::new("close-capable-session"),
11097                    watch::Receiver::constant(
11098                        acp::PromptCapabilities::new()
11099                            .image(true)
11100                            .audio(true)
11101                            .embedded_context(true),
11102                    ),
11103                    cx,
11104                )
11105            });
11106            Task::ready(Ok(thread))
11107        }
11108
11109        fn supports_close_session(&self) -> bool {
11110            true
11111        }
11112
11113        fn close_session(
11114            self: Rc<Self>,
11115            session_id: &acp::SessionId,
11116            _cx: &mut App,
11117        ) -> Task<Result<()>> {
11118            self.closed_sessions.lock().push(session_id.clone());
11119            Task::ready(Ok(()))
11120        }
11121
11122        fn auth_methods(&self) -> &[acp::AuthMethod] {
11123            &[]
11124        }
11125
11126        fn authenticate(
11127            &self,
11128            _method_id: acp::AuthMethodId,
11129            _cx: &mut App,
11130        ) -> Task<gpui::Result<()>> {
11131            Task::ready(Ok(()))
11132        }
11133
11134        fn prompt(
11135            &self,
11136            _params: acp::PromptRequest,
11137            _cx: &mut App,
11138        ) -> Task<gpui::Result<acp::PromptResponse>> {
11139            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
11140        }
11141
11142        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
11143
11144        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
11145            self
11146        }
11147    }
11148}
11149
Served at tenant.openagents/omega Member data and write actions are omitted.