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