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