Skip to repository content11149 lines · 406.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:55:26.756Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
conversation_view.rs
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(¬ification_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(¤t_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