1use std::{
2 path::PathBuf,
3 rc::Rc,
4 sync::{
5 Arc,
6 atomic::{AtomicBool, Ordering},
7 },
8 time::Duration,
9};
10
11use acp_thread::{AcpThread, MentionUri, ThreadStatus};
12use agent::{ContextServerRegistry, SharedThread, ThreadStore};
13use agent_client_protocol as acp;
14use agent_servers::AgentServer;
15use collections::HashSet;
16use db::kvp::{Dismissable, KeyValueStore};
17use itertools::Itertools;
18use project::AgentId;
19use serde::{Deserialize, Serialize};
20use settings::{LanguageModelProviderSetting, LanguageModelSelection};
21
22use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _};
23use zed_actions::agent::{
24 AddSelectionToThread, ConflictContent, OpenClaudeAgentOnboardingModal, ReauthenticateAgent,
25 ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff,
26};
27
28use crate::{
29 AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
30 Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, OpenActiveThreadAsMarkdown,
31 OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn,
32 ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
33 agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
34 conversation_view::{AcpThreadViewEvent, ThreadView},
35 ui::EndTrialUpsell,
36};
37use crate::{
38 Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
39 NewNativeAgentThreadFromSummary,
40};
41use crate::{
42 DEFAULT_THREAD_TITLE,
43 ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal, HoldForDefault},
44};
45use crate::{ExpandMessageEditor, ThreadHistoryView};
46use crate::{ManageProfiles, ThreadHistoryViewEvent};
47use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
48use agent_settings::AgentSettings;
49use ai_onboarding::AgentPanelOnboarding;
50use anyhow::{Context as _, Result, anyhow};
51use client::UserStore;
52use cloud_api_types::Plan;
53use collections::HashMap;
54use editor::Editor;
55use extension::ExtensionEvents;
56use extension_host::ExtensionStore;
57use fs::Fs;
58use gpui::{
59 Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
60 DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
61 Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
62};
63use language::LanguageRegistry;
64use language_model::LanguageModelRegistry;
65use project::project_settings::ProjectSettings;
66use project::{Project, ProjectPath, Worktree};
67use prompt_store::{PromptStore, UserPromptId};
68use rules_library::{RulesLibrary, open_rules_library};
69use settings::{Settings, update_settings_file};
70use theme_settings::ThemeSettings;
71use ui::{
72 Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide,
73 PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
74};
75use util::{ResultExt as _, debug_panic};
76use workspace::{
77 CollaboratorId, DraggedSelection, DraggedTab, OpenMode, OpenResult, PathList,
78 SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId,
79 dock::{DockPosition, Panel, PanelEvent},
80};
81use zed_actions::{
82 DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
83 agent::{OpenAcpOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding},
84 assistant::{OpenRulesLibrary, Toggle, ToggleFocus},
85};
86
87const AGENT_PANEL_KEY: &str = "agent_panel";
88const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
89
90fn read_serialized_panel(
91 workspace_id: workspace::WorkspaceId,
92 kvp: &KeyValueStore,
93) -> Option<SerializedAgentPanel> {
94 let scope = kvp.scoped(AGENT_PANEL_KEY);
95 let key = i64::from(workspace_id).to_string();
96 scope
97 .read(&key)
98 .log_err()
99 .flatten()
100 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
101}
102
103async fn save_serialized_panel(
104 workspace_id: workspace::WorkspaceId,
105 panel: SerializedAgentPanel,
106 kvp: KeyValueStore,
107) -> Result<()> {
108 let scope = kvp.scoped(AGENT_PANEL_KEY);
109 let key = i64::from(workspace_id).to_string();
110 scope.write(key, serde_json::to_string(&panel)?).await?;
111 Ok(())
112}
113
114/// Migration: reads the original single-panel format stored under the
115/// `"agent_panel"` KVP key before per-workspace keying was introduced.
116fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option<SerializedAgentPanel> {
117 kvp.read_kvp(AGENT_PANEL_KEY)
118 .log_err()
119 .flatten()
120 .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
121}
122
123#[derive(Serialize, Deserialize, Debug)]
124struct SerializedAgentPanel {
125 selected_agent: Option<Agent>,
126 #[serde(default)]
127 last_active_thread: Option<SerializedActiveThread>,
128 #[serde(default)]
129 start_thread_in: Option<StartThreadIn>,
130}
131
132#[derive(Serialize, Deserialize, Debug)]
133struct SerializedActiveThread {
134 session_id: String,
135 agent_type: Agent,
136 title: Option<String>,
137 work_dirs: Option<SerializedPathList>,
138}
139
140pub fn init(cx: &mut App) {
141 cx.observe_new(
142 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
143 workspace
144 .register_action(|workspace, action: &NewThread, window, cx| {
145 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
146 panel.update(cx, |panel, cx| panel.new_thread(action, window, cx));
147 workspace.focus_panel::<AgentPanel>(window, cx);
148 }
149 })
150 .register_action(
151 |workspace, action: &NewNativeAgentThreadFromSummary, window, cx| {
152 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
153 panel.update(cx, |panel, cx| {
154 panel.new_native_agent_thread_from_summary(action, window, cx)
155 });
156 workspace.focus_panel::<AgentPanel>(window, cx);
157 }
158 },
159 )
160 .register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
161 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
162 workspace.focus_panel::<AgentPanel>(window, cx);
163 panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx));
164 }
165 })
166 .register_action(|workspace, _: &OpenHistory, window, cx| {
167 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
168 workspace.focus_panel::<AgentPanel>(window, cx);
169 panel.update(cx, |panel, cx| panel.open_history(window, cx));
170 }
171 })
172 .register_action(|workspace, _: &OpenSettings, window, cx| {
173 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
174 workspace.focus_panel::<AgentPanel>(window, cx);
175 panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
176 }
177 })
178 .register_action(|workspace, action: &NewExternalAgentThread, window, cx| {
179 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
180 workspace.focus_panel::<AgentPanel>(window, cx);
181 panel.update(cx, |panel, cx| {
182 panel.external_thread(
183 action.agent.clone(),
184 None,
185 None,
186 None,
187 None,
188 true,
189 window,
190 cx,
191 )
192 });
193 }
194 })
195 .register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
196 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
197 workspace.focus_panel::<AgentPanel>(window, cx);
198 panel.update(cx, |panel, cx| {
199 panel.deploy_rules_library(action, window, cx)
200 });
201 }
202 })
203 .register_action(|workspace, _: &Follow, window, cx| {
204 workspace.follow(CollaboratorId::Agent, window, cx);
205 })
206 .register_action(|workspace, _: &OpenAgentDiff, window, cx| {
207 let thread = workspace
208 .panel::<AgentPanel>(cx)
209 .and_then(|panel| panel.read(cx).active_conversation_view().cloned())
210 .and_then(|conversation| {
211 conversation
212 .read(cx)
213 .active_thread()
214 .map(|r| r.read(cx).thread.clone())
215 });
216
217 if let Some(thread) = thread {
218 AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
219 }
220 })
221 .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
222 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
223 workspace.focus_panel::<AgentPanel>(window, cx);
224 panel.update(cx, |panel, cx| {
225 panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
226 });
227 }
228 })
229 .register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
230 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
231 workspace.focus_panel::<AgentPanel>(window, cx);
232 panel.update(cx, |panel, cx| {
233 panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
234 });
235 }
236 })
237 .register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
238 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
239 workspace.focus_panel::<AgentPanel>(window, cx);
240 panel.update(cx, |panel, cx| {
241 panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
242 });
243 }
244 })
245 .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
246 AcpOnboardingModal::toggle(workspace, window, cx)
247 })
248 .register_action(
249 |workspace, _: &OpenClaudeAgentOnboardingModal, window, cx| {
250 ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
251 },
252 )
253 .register_action(|_workspace, _: &ResetOnboarding, window, cx| {
254 window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
255 window.refresh();
256 })
257 .register_action(|workspace, _: &ResetTrialUpsell, _window, cx| {
258 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
259 panel.update(cx, |panel, _| {
260 panel
261 .on_boarding_upsell_dismissed
262 .store(false, Ordering::Release);
263 });
264 }
265 OnboardingUpsell::set_dismissed(false, cx);
266 })
267 .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
268 TrialEndUpsell::set_dismissed(false, cx);
269 })
270 .register_action(|workspace, _: &ResetAgentZoom, window, cx| {
271 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
272 panel.update(cx, |panel, cx| {
273 panel.reset_agent_zoom(window, cx);
274 });
275 }
276 })
277 .register_action(|workspace, _: &CopyThreadToClipboard, window, cx| {
278 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
279 panel.update(cx, |panel, cx| {
280 panel.copy_thread_to_clipboard(window, cx);
281 });
282 }
283 })
284 .register_action(|workspace, _: &LoadThreadFromClipboard, window, cx| {
285 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
286 workspace.focus_panel::<AgentPanel>(window, cx);
287 panel.update(cx, |panel, cx| {
288 panel.load_thread_from_clipboard(window, cx);
289 });
290 }
291 })
292 .register_action(|workspace, action: &ReviewBranchDiff, window, cx| {
293 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
294 return;
295 };
296
297 let mention_uri = MentionUri::GitDiff {
298 base_ref: action.base_ref.to_string(),
299 };
300 let diff_uri = mention_uri.to_uri().to_string();
301
302 let content_blocks = vec![
303 acp::ContentBlock::Text(acp::TextContent::new(
304 "Please review this branch diff carefully. Point out any issues, \
305 potential bugs, or improvement opportunities you find.\n\n"
306 .to_string(),
307 )),
308 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
309 acp::EmbeddedResourceResource::TextResourceContents(
310 acp::TextResourceContents::new(
311 action.diff_text.to_string(),
312 diff_uri,
313 ),
314 ),
315 )),
316 ];
317
318 workspace.focus_panel::<AgentPanel>(window, cx);
319
320 panel.update(cx, |panel, cx| {
321 panel.external_thread(
322 None,
323 None,
324 None,
325 None,
326 Some(AgentInitialContent::ContentBlock {
327 blocks: content_blocks,
328 auto_submit: true,
329 }),
330 true,
331 window,
332 cx,
333 );
334 });
335 })
336 .register_action(
337 |workspace, action: &ResolveConflictsWithAgent, window, cx| {
338 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
339 return;
340 };
341
342 let content_blocks = build_conflict_resolution_prompt(&action.conflicts);
343
344 workspace.focus_panel::<AgentPanel>(window, cx);
345
346 panel.update(cx, |panel, cx| {
347 panel.external_thread(
348 None,
349 None,
350 None,
351 None,
352 Some(AgentInitialContent::ContentBlock {
353 blocks: content_blocks,
354 auto_submit: true,
355 }),
356 true,
357 window,
358 cx,
359 );
360 });
361 },
362 )
363 .register_action(
364 |workspace, action: &ResolveConflictedFilesWithAgent, window, cx| {
365 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
366 return;
367 };
368
369 let content_blocks =
370 build_conflicted_files_resolution_prompt(&action.conflicted_file_paths);
371
372 workspace.focus_panel::<AgentPanel>(window, cx);
373
374 panel.update(cx, |panel, cx| {
375 panel.external_thread(
376 None,
377 None,
378 None,
379 None,
380 Some(AgentInitialContent::ContentBlock {
381 blocks: content_blocks,
382 auto_submit: true,
383 }),
384 true,
385 window,
386 cx,
387 );
388 });
389 },
390 )
391 .register_action(|workspace, action: &StartThreadIn, window, cx| {
392 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
393 panel.update(cx, |panel, cx| {
394 panel.set_start_thread_in(action, window, cx);
395 });
396 }
397 })
398 .register_action(|workspace, _: &CycleStartThreadIn, window, cx| {
399 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
400 panel.update(cx, |panel, cx| {
401 panel.cycle_start_thread_in(window, cx);
402 });
403 }
404 })
405 .register_action(
406 |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| {
407 let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
408 return;
409 };
410
411 if !panel.focus_handle(cx).contains_focused(window, cx) {
412 workspace.toggle_panel_focus::<AgentPanel>(window, cx);
413 }
414
415 panel.update(cx, |_, cx| {
416 cx.defer_in(window, move |panel, window, cx| {
417 if let Some(conversation_view) = panel.active_conversation_view() {
418 conversation_view.update(cx, |conversation_view, cx| {
419 conversation_view.insert_selections(window, cx);
420 });
421 }
422 });
423 });
424 },
425 );
426 },
427 )
428 .detach();
429}
430
431fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock {
432 let mention_uri = MentionUri::MergeConflict {
433 file_path: conflict.file_path.clone(),
434 };
435 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
436 acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new(
437 conflict.conflict_text.clone(),
438 mention_uri.to_uri().to_string(),
439 )),
440 ))
441}
442
443fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec<acp::ContentBlock> {
444 if conflicts.is_empty() {
445 return Vec::new();
446 }
447
448 let mut blocks = Vec::new();
449
450 if conflicts.len() == 1 {
451 let conflict = &conflicts[0];
452
453 blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
454 "Please resolve the following merge conflict in ",
455 )));
456 let mention = MentionUri::File {
457 abs_path: PathBuf::from(conflict.file_path.clone()),
458 };
459 blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
460 mention.name(),
461 mention.to_uri(),
462 )));
463
464 blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
465 indoc::formatdoc!(
466 "\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs).
467
468 Analyze both versions carefully and resolve the conflict by editing \
469 the file directly. Choose the resolution that best preserves the intent \
470 of both changes, or combine them if appropriate.
471
472 ",
473 ours = conflict.ours_branch_name,
474 theirs = conflict.theirs_branch_name,
475 ),
476 )));
477 } else {
478 let n = conflicts.len();
479 let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect();
480 let ours = &conflicts[0].ours_branch_name;
481 let theirs = &conflicts[0].theirs_branch_name;
482 blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
483 indoc::formatdoc!(
484 "Please resolve all {n} merge conflicts below.
485
486 The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs).
487
488 For each conflict, analyze both versions carefully and resolve them \
489 by editing the file{suffix} directly. Choose resolutions that best preserve \
490 the intent of both changes, or combine them if appropriate.
491
492 ",
493 suffix = if unique_files.len() > 1 { "s" } else { "" },
494 ),
495 )));
496 }
497
498 for conflict in conflicts {
499 blocks.push(conflict_resource_block(conflict));
500 }
501
502 blocks
503}
504
505fn build_conflicted_files_resolution_prompt(
506 conflicted_file_paths: &[String],
507) -> Vec<acp::ContentBlock> {
508 if conflicted_file_paths.is_empty() {
509 return Vec::new();
510 }
511
512 let instruction = indoc::indoc!(
513 "The following files have unresolved merge conflicts. Please open each \
514 file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \
515 and resolve every conflict by editing the files directly.
516
517 Choose resolutions that best preserve the intent of both changes, \
518 or combine them if appropriate.
519
520 Files with conflicts:
521 ",
522 );
523
524 let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))];
525 for path in conflicted_file_paths {
526 let mention = MentionUri::File {
527 abs_path: PathBuf::from(path),
528 };
529 content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
530 mention.name(),
531 mention.to_uri(),
532 )));
533 content.push(acp::ContentBlock::Text(acp::TextContent::new("\n")));
534 }
535 content
536}
537
538enum ActiveView {
539 Uninitialized,
540 AgentThread {
541 conversation_view: Entity<ConversationView>,
542 },
543 History {
544 view: Entity<ThreadHistoryView>,
545 },
546 Configuration,
547}
548
549enum WhichFontSize {
550 AgentFont,
551 None,
552}
553
554impl StartThreadIn {
555 fn label(&self) -> SharedString {
556 match self {
557 Self::LocalProject => "Current Worktree".into(),
558 Self::NewWorktree => "New Git Worktree".into(),
559 }
560 }
561}
562
563#[derive(Clone, Debug)]
564#[allow(dead_code)]
565pub enum WorktreeCreationStatus {
566 Creating,
567 Error(SharedString),
568}
569
570impl ActiveView {
571 pub fn which_font_size_used(&self) -> WhichFontSize {
572 match self {
573 ActiveView::Uninitialized
574 | ActiveView::AgentThread { .. }
575 | ActiveView::History { .. } => WhichFontSize::AgentFont,
576 ActiveView::Configuration => WhichFontSize::None,
577 }
578 }
579}
580
581pub struct AgentPanel {
582 workspace: WeakEntity<Workspace>,
583 /// Workspace id is used as a database key
584 workspace_id: Option<WorkspaceId>,
585 user_store: Entity<UserStore>,
586 project: Entity<Project>,
587 fs: Arc<dyn Fs>,
588 language_registry: Arc<LanguageRegistry>,
589 thread_store: Entity<ThreadStore>,
590 prompt_store: Option<Entity<PromptStore>>,
591 connection_store: Entity<AgentConnectionStore>,
592 context_server_registry: Entity<ContextServerRegistry>,
593 configuration: Option<Entity<AgentConfiguration>>,
594 configuration_subscription: Option<Subscription>,
595 focus_handle: FocusHandle,
596 active_view: ActiveView,
597 previous_view: Option<ActiveView>,
598 background_threads: HashMap<acp::SessionId, Entity<ConversationView>>,
599 new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
600 start_thread_in_menu_handle: PopoverMenuHandle<ContextMenu>,
601 agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
602 agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
603 agent_navigation_menu: Option<Entity<ContextMenu>>,
604 _extension_subscription: Option<Subscription>,
605 _project_subscription: Subscription,
606 zoomed: bool,
607 pending_serialization: Option<Task<Result<()>>>,
608 onboarding: Entity<AgentPanelOnboarding>,
609 selected_agent: Agent,
610 start_thread_in: StartThreadIn,
611 worktree_creation_status: Option<WorktreeCreationStatus>,
612 _thread_view_subscription: Option<Subscription>,
613 _active_thread_focus_subscription: Option<Subscription>,
614 _worktree_creation_task: Option<Task<()>>,
615 show_trust_workspace_message: bool,
616 on_boarding_upsell_dismissed: AtomicBool,
617 _active_view_observation: Option<Subscription>,
618}
619
620impl AgentPanel {
621 fn serialize(&mut self, cx: &mut App) {
622 let Some(workspace_id) = self.workspace_id else {
623 return;
624 };
625
626 let selected_agent = self.selected_agent.clone();
627 let start_thread_in = Some(self.start_thread_in);
628
629 let last_active_thread = self.active_agent_thread(cx).map(|thread| {
630 let thread = thread.read(cx);
631 let title = thread.title();
632 let work_dirs = thread.work_dirs().cloned();
633 SerializedActiveThread {
634 session_id: thread.session_id().0.to_string(),
635 agent_type: self.selected_agent.clone(),
636 title: title.map(|t| t.to_string()),
637 work_dirs: work_dirs.map(|dirs| dirs.serialize()),
638 }
639 });
640
641 let kvp = KeyValueStore::global(cx);
642 self.pending_serialization = Some(cx.background_spawn(async move {
643 save_serialized_panel(
644 workspace_id,
645 SerializedAgentPanel {
646 selected_agent: Some(selected_agent),
647 last_active_thread,
648 start_thread_in,
649 },
650 kvp,
651 )
652 .await?;
653 anyhow::Ok(())
654 }));
655 }
656
657 pub fn load(
658 workspace: WeakEntity<Workspace>,
659 mut cx: AsyncWindowContext,
660 ) -> Task<Result<Entity<Self>>> {
661 let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
662 let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)).ok();
663 cx.spawn(async move |cx| {
664 let prompt_store = match prompt_store {
665 Ok(prompt_store) => prompt_store.await.ok(),
666 Err(_) => None,
667 };
668 let workspace_id = workspace
669 .read_with(cx, |workspace, _| workspace.database_id())
670 .ok()
671 .flatten();
672
673 let serialized_panel = cx
674 .background_spawn(async move {
675 kvp.and_then(|kvp| {
676 workspace_id
677 .and_then(|id| read_serialized_panel(id, &kvp))
678 .or_else(|| read_legacy_serialized_panel(&kvp))
679 })
680 })
681 .await;
682
683 let last_active_thread = if let Some(thread_info) = serialized_panel
684 .as_ref()
685 .and_then(|p| p.last_active_thread.as_ref())
686 {
687 if thread_info.agent_type.is_native() {
688 let session_id = acp::SessionId::new(thread_info.session_id.clone());
689 let load_result = cx.update(|_window, cx| {
690 let thread_store = ThreadStore::global(cx);
691 thread_store.update(cx, |store, cx| store.load_thread(session_id, cx))
692 });
693 let thread_exists = if let Ok(task) = load_result {
694 task.await.ok().flatten().is_some()
695 } else {
696 false
697 };
698 if thread_exists {
699 Some(thread_info)
700 } else {
701 log::warn!(
702 "last active thread {} not found in database, skipping restoration",
703 thread_info.session_id
704 );
705 None
706 }
707 } else {
708 Some(thread_info)
709 }
710 } else {
711 None
712 };
713
714 let panel = workspace.update_in(cx, |workspace, window, cx| {
715 let panel =
716 cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
717
718 if let Some(serialized_panel) = &serialized_panel {
719 panel.update(cx, |panel, cx| {
720 if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
721 panel.selected_agent = selected_agent;
722 }
723 if let Some(start_thread_in) = serialized_panel.start_thread_in {
724 let is_worktree_flag_enabled =
725 cx.has_flag::<AgentV2FeatureFlag>();
726 let is_valid = match &start_thread_in {
727 StartThreadIn::LocalProject => true,
728 StartThreadIn::NewWorktree => {
729 let project = panel.project.read(cx);
730 is_worktree_flag_enabled && !project.is_via_collab()
731 }
732 };
733 if is_valid {
734 panel.start_thread_in = start_thread_in;
735 } else {
736 log::info!(
737 "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
738 start_thread_in,
739 );
740 }
741 }
742 cx.notify();
743 });
744 }
745
746 if let Some(thread_info) = last_active_thread {
747 let agent = thread_info.agent_type.clone();
748 panel.update(cx, |panel, cx| {
749 panel.selected_agent = agent.clone();
750 panel.load_agent_thread(
751 agent,
752 thread_info.session_id.clone().into(),
753 thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)),
754 thread_info.title.as_ref().map(|t| t.clone().into()),
755 false,
756 window,
757 cx,
758 );
759 });
760 }
761 panel
762 })?;
763
764 Ok(panel)
765 })
766 }
767
768 pub(crate) fn new(
769 workspace: &Workspace,
770 prompt_store: Option<Entity<PromptStore>>,
771 window: &mut Window,
772 cx: &mut Context<Self>,
773 ) -> Self {
774 let fs = workspace.app_state().fs.clone();
775 let user_store = workspace.app_state().user_store.clone();
776 let project = workspace.project();
777 let language_registry = project.read(cx).languages().clone();
778 let client = workspace.client().clone();
779 let workspace_id = workspace.database_id();
780 let workspace = workspace.weak_handle();
781
782 let context_server_registry =
783 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
784
785 let thread_store = ThreadStore::global(cx);
786
787 let active_view = ActiveView::Uninitialized;
788
789 let weak_panel = cx.entity().downgrade();
790
791 window.defer(cx, move |window, cx| {
792 let panel = weak_panel.clone();
793 let agent_navigation_menu =
794 ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| {
795 if let Some(panel) = panel.upgrade() {
796 if let Some(history) = panel
797 .update(cx, |panel, cx| panel.history_for_selected_agent(window, cx))
798 {
799 menu = Self::populate_recently_updated_menu_section(
800 menu, panel, history, cx,
801 );
802 menu = menu.action("View All", Box::new(OpenHistory));
803 }
804 }
805
806 menu = menu
807 .fixed_width(px(320.).into())
808 .keep_open_on_confirm(false)
809 .key_context("NavigationMenu");
810
811 menu
812 });
813 weak_panel
814 .update(cx, |panel, cx| {
815 cx.subscribe_in(
816 &agent_navigation_menu,
817 window,
818 |_, menu, _: &DismissEvent, window, cx| {
819 menu.update(cx, |menu, _| {
820 menu.clear_selected();
821 });
822 cx.focus_self(window);
823 },
824 )
825 .detach();
826 panel.agent_navigation_menu = Some(agent_navigation_menu);
827 })
828 .ok();
829 });
830
831 let weak_panel = cx.entity().downgrade();
832 let onboarding = cx.new(|cx| {
833 AgentPanelOnboarding::new(
834 user_store.clone(),
835 client,
836 move |_window, cx| {
837 weak_panel
838 .update(cx, |panel, _| {
839 panel
840 .on_boarding_upsell_dismissed
841 .store(true, Ordering::Release);
842 })
843 .ok();
844 OnboardingUpsell::set_dismissed(true, cx);
845 },
846 cx,
847 )
848 });
849
850 // Subscribe to extension events to sync agent servers when extensions change
851 let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
852 {
853 Some(
854 cx.subscribe(&extension_events, |this, _source, event, cx| match event {
855 extension::Event::ExtensionInstalled(_)
856 | extension::Event::ExtensionUninstalled(_)
857 | extension::Event::ExtensionsInstalledChanged => {
858 this.sync_agent_servers_from_extensions(cx);
859 }
860 _ => {}
861 }),
862 )
863 } else {
864 None
865 };
866
867 let connection_store = cx.new(|cx| {
868 let mut store = AgentConnectionStore::new(project.clone(), cx);
869 // Register the native agent right away, so that it is available for
870 // the inline assistant etc.
871 store.request_connection(
872 Agent::NativeAgent,
873 Agent::NativeAgent.server(fs.clone(), thread_store.clone()),
874 cx,
875 );
876 store
877 });
878 let _project_subscription =
879 cx.subscribe(&project, |this, _project, event, cx| match event {
880 project::Event::WorktreeAdded(_)
881 | project::Event::WorktreeRemoved(_)
882 | project::Event::WorktreeOrderChanged => {
883 this.update_thread_work_dirs(cx);
884 }
885 _ => {}
886 });
887
888 let mut panel = Self {
889 workspace_id,
890 active_view,
891 workspace,
892 user_store,
893 project: project.clone(),
894 fs: fs.clone(),
895 language_registry,
896 prompt_store,
897 connection_store,
898 configuration: None,
899 configuration_subscription: None,
900 focus_handle: cx.focus_handle(),
901 context_server_registry,
902 previous_view: None,
903 background_threads: HashMap::default(),
904 new_thread_menu_handle: PopoverMenuHandle::default(),
905 start_thread_in_menu_handle: PopoverMenuHandle::default(),
906 agent_panel_menu_handle: PopoverMenuHandle::default(),
907 agent_navigation_menu_handle: PopoverMenuHandle::default(),
908 agent_navigation_menu: None,
909 _extension_subscription: extension_subscription,
910 _project_subscription,
911 zoomed: false,
912 pending_serialization: None,
913 onboarding,
914 thread_store,
915 selected_agent: Agent::default(),
916 start_thread_in: StartThreadIn::default(),
917 worktree_creation_status: None,
918 _thread_view_subscription: None,
919 _active_thread_focus_subscription: None,
920 _worktree_creation_task: None,
921 show_trust_workspace_message: false,
922 on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
923 _active_view_observation: None,
924 };
925
926 // Initial sync of agent servers from extensions
927 panel.sync_agent_servers_from_extensions(cx);
928 panel
929 }
930
931 pub fn toggle_focus(
932 workspace: &mut Workspace,
933 _: &ToggleFocus,
934 window: &mut Window,
935 cx: &mut Context<Workspace>,
936 ) {
937 if workspace
938 .panel::<Self>(cx)
939 .is_some_and(|panel| panel.read(cx).enabled(cx))
940 {
941 workspace.toggle_panel_focus::<Self>(window, cx);
942 }
943 }
944
945 pub fn toggle(
946 workspace: &mut Workspace,
947 _: &Toggle,
948 window: &mut Window,
949 cx: &mut Context<Workspace>,
950 ) {
951 if workspace
952 .panel::<Self>(cx)
953 .is_some_and(|panel| panel.read(cx).enabled(cx))
954 {
955 if !workspace.toggle_panel_focus::<Self>(window, cx) {
956 workspace.close_panel::<Self>(window, cx);
957 }
958 }
959 }
960
961 pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
962 &self.prompt_store
963 }
964
965 pub fn thread_store(&self) -> &Entity<ThreadStore> {
966 &self.thread_store
967 }
968
969 pub fn connection_store(&self) -> &Entity<AgentConnectionStore> {
970 &self.connection_store
971 }
972
973 pub fn open_thread(
974 &mut self,
975 session_id: acp::SessionId,
976 work_dirs: Option<PathList>,
977 title: Option<SharedString>,
978 window: &mut Window,
979 cx: &mut Context<Self>,
980 ) {
981 self.external_thread(
982 Some(crate::Agent::NativeAgent),
983 Some(session_id),
984 work_dirs,
985 title,
986 None,
987 true,
988 window,
989 cx,
990 );
991 }
992
993 pub(crate) fn context_server_registry(&self) -> &Entity<ContextServerRegistry> {
994 &self.context_server_registry
995 }
996
997 pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
998 let workspace_read = workspace.read(cx);
999
1000 workspace_read
1001 .panel::<AgentPanel>(cx)
1002 .map(|panel| {
1003 let panel_id = Entity::entity_id(&panel);
1004
1005 workspace_read.all_docks().iter().any(|dock| {
1006 dock.read(cx)
1007 .visible_panel()
1008 .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
1009 })
1010 })
1011 .unwrap_or(false)
1012 }
1013
1014 pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
1015 self.reset_start_thread_in_to_default(cx);
1016 self.external_thread(None, None, None, None, None, true, window, cx);
1017 }
1018
1019 fn new_native_agent_thread_from_summary(
1020 &mut self,
1021 action: &NewNativeAgentThreadFromSummary,
1022 window: &mut Window,
1023 cx: &mut Context<Self>,
1024 ) {
1025 let session_id = action.from_session_id.clone();
1026
1027 let Some(history) = self
1028 .connection_store
1029 .read(cx)
1030 .entry(&Agent::NativeAgent)
1031 .and_then(|e| e.read(cx).history().cloned())
1032 else {
1033 debug_panic!("Native agent is not registered");
1034 return;
1035 };
1036
1037 cx.spawn_in(window, async move |this, cx| {
1038 this.update_in(cx, |this, window, cx| {
1039 let thread = history
1040 .read(cx)
1041 .session_for_id(&session_id)
1042 .context("Session not found")?;
1043
1044 this.external_thread(
1045 Some(Agent::NativeAgent),
1046 None,
1047 None,
1048 None,
1049 Some(AgentInitialContent::ThreadSummary {
1050 session_id: thread.session_id,
1051 title: thread.title,
1052 }),
1053 true,
1054 window,
1055 cx,
1056 );
1057 anyhow::Ok(())
1058 })
1059 })
1060 .detach_and_log_err(cx);
1061 }
1062
1063 fn external_thread(
1064 &mut self,
1065 agent_choice: Option<crate::Agent>,
1066 resume_session_id: Option<acp::SessionId>,
1067 work_dirs: Option<PathList>,
1068 title: Option<SharedString>,
1069 initial_content: Option<AgentInitialContent>,
1070 focus: bool,
1071 window: &mut Window,
1072 cx: &mut Context<Self>,
1073 ) {
1074 let workspace = self.workspace.clone();
1075 let project = self.project.clone();
1076 let fs = self.fs.clone();
1077 let is_via_collab = self.project.read(cx).is_via_collab();
1078
1079 const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
1080
1081 #[derive(Serialize, Deserialize)]
1082 struct LastUsedExternalAgent {
1083 agent: crate::Agent,
1084 }
1085
1086 let thread_store = self.thread_store.clone();
1087 let kvp = KeyValueStore::global(cx);
1088
1089 if let Some(agent) = agent_choice {
1090 cx.background_spawn({
1091 let agent = agent.clone();
1092 let kvp = kvp;
1093 async move {
1094 if let Some(serialized) =
1095 serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
1096 {
1097 kvp.write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
1098 .await
1099 .log_err();
1100 }
1101 }
1102 })
1103 .detach();
1104
1105 let server = agent.server(fs, thread_store);
1106 self.create_agent_thread(
1107 server,
1108 resume_session_id,
1109 work_dirs,
1110 title,
1111 initial_content,
1112 workspace,
1113 project,
1114 agent,
1115 focus,
1116 window,
1117 cx,
1118 );
1119 } else {
1120 cx.spawn_in(window, async move |this, cx| {
1121 let ext_agent = if is_via_collab {
1122 Agent::NativeAgent
1123 } else {
1124 cx.background_spawn(async move { kvp.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY) })
1125 .await
1126 .log_err()
1127 .flatten()
1128 .and_then(|value| {
1129 serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
1130 })
1131 .map(|agent| agent.agent)
1132 .unwrap_or(Agent::NativeAgent)
1133 };
1134
1135 let server = ext_agent.server(fs, thread_store);
1136 this.update_in(cx, |agent_panel, window, cx| {
1137 agent_panel.create_agent_thread(
1138 server,
1139 resume_session_id,
1140 work_dirs,
1141 title,
1142 initial_content,
1143 workspace,
1144 project,
1145 ext_agent,
1146 focus,
1147 window,
1148 cx,
1149 );
1150 })?;
1151
1152 anyhow::Ok(())
1153 })
1154 .detach_and_log_err(cx);
1155 }
1156 }
1157
1158 fn deploy_rules_library(
1159 &mut self,
1160 action: &OpenRulesLibrary,
1161 _window: &mut Window,
1162 cx: &mut Context<Self>,
1163 ) {
1164 open_rules_library(
1165 self.language_registry.clone(),
1166 Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
1167 action
1168 .prompt_to_select
1169 .map(|uuid| UserPromptId(uuid).into()),
1170 cx,
1171 )
1172 .detach_and_log_err(cx);
1173 }
1174
1175 fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1176 let Some(conversation_view) = self.active_conversation_view() else {
1177 return;
1178 };
1179
1180 let Some(active_thread) = conversation_view.read(cx).active_thread().cloned() else {
1181 return;
1182 };
1183
1184 active_thread.update(cx, |active_thread, cx| {
1185 active_thread.expand_message_editor(&ExpandMessageEditor, window, cx);
1186 active_thread.focus_handle(cx).focus(window, cx);
1187 })
1188 }
1189
1190 fn has_history_for_selected_agent(&self, cx: &App) -> bool {
1191 match &self.selected_agent {
1192 Agent::NativeAgent => true,
1193 Agent::Custom { .. } => self
1194 .connection_store
1195 .read(cx)
1196 .entry(&self.selected_agent)
1197 .map_or(false, |entry| entry.read(cx).history().is_some()),
1198 }
1199 }
1200
1201 fn history_for_selected_agent(
1202 &self,
1203 window: &mut Window,
1204 cx: &mut Context<Self>,
1205 ) -> Option<Entity<ThreadHistoryView>> {
1206 let agent = self.selected_agent.clone();
1207 let history = self
1208 .connection_store
1209 .read(cx)
1210 .entry(&agent)?
1211 .read(cx)
1212 .history()?
1213 .clone();
1214 Some(self.create_thread_history_view(agent, history, window, cx))
1215 }
1216
1217 fn create_thread_history_view(
1218 &self,
1219 agent: Agent,
1220 history: Entity<ThreadHistory>,
1221 window: &mut Window,
1222 cx: &mut Context<Self>,
1223 ) -> Entity<ThreadHistoryView> {
1224 let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx));
1225 cx.subscribe_in(
1226 &view,
1227 window,
1228 move |this, _, event, window, cx| match event {
1229 ThreadHistoryViewEvent::Open(thread) => {
1230 this.load_agent_thread(
1231 agent.clone(),
1232 thread.session_id.clone(),
1233 thread.work_dirs.clone(),
1234 thread.title.clone(),
1235 true,
1236 window,
1237 cx,
1238 );
1239 }
1240 },
1241 )
1242 .detach();
1243 view
1244 }
1245
1246 fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1247 let Some(view) = self.history_for_selected_agent(window, cx) else {
1248 return;
1249 };
1250
1251 if let ActiveView::History { view: active_view } = &self.active_view {
1252 if active_view == &view {
1253 if let Some(previous_view) = self.previous_view.take() {
1254 self.set_active_view(previous_view, true, window, cx);
1255 }
1256 return;
1257 }
1258 }
1259
1260 self.set_active_view(ActiveView::History { view }, true, window, cx);
1261 cx.notify();
1262 }
1263
1264 pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
1265 match self.active_view {
1266 ActiveView::Configuration | ActiveView::History { .. } => {
1267 if let Some(previous_view) = self.previous_view.take() {
1268 self.set_active_view(previous_view, true, window, cx);
1269 }
1270 cx.notify();
1271 }
1272 _ => {}
1273 }
1274 }
1275
1276 pub fn toggle_navigation_menu(
1277 &mut self,
1278 _: &ToggleNavigationMenu,
1279 window: &mut Window,
1280 cx: &mut Context<Self>,
1281 ) {
1282 if !self.has_history_for_selected_agent(cx) {
1283 return;
1284 }
1285 self.agent_navigation_menu_handle.toggle(window, cx);
1286 }
1287
1288 pub fn toggle_options_menu(
1289 &mut self,
1290 _: &ToggleOptionsMenu,
1291 window: &mut Window,
1292 cx: &mut Context<Self>,
1293 ) {
1294 self.agent_panel_menu_handle.toggle(window, cx);
1295 }
1296
1297 pub fn toggle_new_thread_menu(
1298 &mut self,
1299 _: &ToggleNewThreadMenu,
1300 window: &mut Window,
1301 cx: &mut Context<Self>,
1302 ) {
1303 self.new_thread_menu_handle.toggle(window, cx);
1304 }
1305
1306 pub fn increase_font_size(
1307 &mut self,
1308 action: &IncreaseBufferFontSize,
1309 _: &mut Window,
1310 cx: &mut Context<Self>,
1311 ) {
1312 self.handle_font_size_action(action.persist, px(1.0), cx);
1313 }
1314
1315 pub fn decrease_font_size(
1316 &mut self,
1317 action: &DecreaseBufferFontSize,
1318 _: &mut Window,
1319 cx: &mut Context<Self>,
1320 ) {
1321 self.handle_font_size_action(action.persist, px(-1.0), cx);
1322 }
1323
1324 fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
1325 match self.active_view.which_font_size_used() {
1326 WhichFontSize::AgentFont => {
1327 if persist {
1328 update_settings_file(self.fs.clone(), cx, move |settings, cx| {
1329 let agent_ui_font_size =
1330 ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta;
1331 let agent_buffer_font_size =
1332 ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta;
1333
1334 let _ = settings.theme.agent_ui_font_size.insert(
1335 f32::from(theme_settings::clamp_font_size(agent_ui_font_size)).into(),
1336 );
1337 let _ = settings.theme.agent_buffer_font_size.insert(
1338 f32::from(theme_settings::clamp_font_size(agent_buffer_font_size))
1339 .into(),
1340 );
1341 });
1342 } else {
1343 theme_settings::adjust_agent_ui_font_size(cx, |size| size + delta);
1344 theme_settings::adjust_agent_buffer_font_size(cx, |size| size + delta);
1345 }
1346 }
1347 WhichFontSize::None => {}
1348 }
1349 }
1350
1351 pub fn reset_font_size(
1352 &mut self,
1353 action: &ResetBufferFontSize,
1354 _: &mut Window,
1355 cx: &mut Context<Self>,
1356 ) {
1357 if action.persist {
1358 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1359 settings.theme.agent_ui_font_size = None;
1360 settings.theme.agent_buffer_font_size = None;
1361 });
1362 } else {
1363 theme_settings::reset_agent_ui_font_size(cx);
1364 theme_settings::reset_agent_buffer_font_size(cx);
1365 }
1366 }
1367
1368 pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1369 theme_settings::reset_agent_ui_font_size(cx);
1370 theme_settings::reset_agent_buffer_font_size(cx);
1371 }
1372
1373 pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
1374 if self.zoomed {
1375 cx.emit(PanelEvent::ZoomOut);
1376 } else {
1377 if !self.focus_handle(cx).contains_focused(window, cx) {
1378 cx.focus_self(window);
1379 }
1380 cx.emit(PanelEvent::ZoomIn);
1381 }
1382 }
1383
1384 pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1385 let agent_server_store = self.project.read(cx).agent_server_store().clone();
1386 let context_server_store = self.project.read(cx).context_server_store();
1387 let fs = self.fs.clone();
1388
1389 self.set_active_view(ActiveView::Configuration, true, window, cx);
1390 self.configuration = Some(cx.new(|cx| {
1391 AgentConfiguration::new(
1392 fs,
1393 agent_server_store,
1394 self.connection_store.clone(),
1395 context_server_store,
1396 self.context_server_registry.clone(),
1397 self.language_registry.clone(),
1398 self.workspace.clone(),
1399 window,
1400 cx,
1401 )
1402 }));
1403
1404 if let Some(configuration) = self.configuration.as_ref() {
1405 self.configuration_subscription = Some(cx.subscribe_in(
1406 configuration,
1407 window,
1408 Self::handle_agent_configuration_event,
1409 ));
1410
1411 configuration.focus_handle(cx).focus(window, cx);
1412 }
1413 }
1414
1415 pub(crate) fn open_active_thread_as_markdown(
1416 &mut self,
1417 _: &OpenActiveThreadAsMarkdown,
1418 window: &mut Window,
1419 cx: &mut Context<Self>,
1420 ) {
1421 if let Some(workspace) = self.workspace.upgrade()
1422 && let Some(conversation_view) = self.active_conversation_view()
1423 && let Some(active_thread) = conversation_view.read(cx).active_thread().cloned()
1424 {
1425 active_thread.update(cx, |thread, cx| {
1426 thread
1427 .open_thread_as_markdown(workspace, window, cx)
1428 .detach_and_log_err(cx);
1429 });
1430 }
1431 }
1432
1433 fn copy_thread_to_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1434 let Some(thread) = self.active_native_agent_thread(cx) else {
1435 Self::show_deferred_toast(&self.workspace, "No active native thread to copy", cx);
1436 return;
1437 };
1438
1439 let workspace = self.workspace.clone();
1440 let load_task = thread.read(cx).to_db(cx);
1441
1442 cx.spawn_in(window, async move |_this, cx| {
1443 let db_thread = load_task.await;
1444 let shared_thread = SharedThread::from_db_thread(&db_thread);
1445 let thread_data = shared_thread.to_bytes()?;
1446 let encoded = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &thread_data);
1447
1448 cx.update(|_window, cx| {
1449 cx.write_to_clipboard(ClipboardItem::new_string(encoded));
1450 if let Some(workspace) = workspace.upgrade() {
1451 workspace.update(cx, |workspace, cx| {
1452 struct ThreadCopiedToast;
1453 workspace.show_toast(
1454 workspace::Toast::new(
1455 workspace::notifications::NotificationId::unique::<ThreadCopiedToast>(),
1456 "Thread copied to clipboard (base64 encoded)",
1457 )
1458 .autohide(),
1459 cx,
1460 );
1461 });
1462 }
1463 })?;
1464
1465 anyhow::Ok(())
1466 })
1467 .detach_and_log_err(cx);
1468 }
1469
1470 fn show_deferred_toast(
1471 workspace: &WeakEntity<workspace::Workspace>,
1472 message: &'static str,
1473 cx: &mut App,
1474 ) {
1475 let workspace = workspace.clone();
1476 cx.defer(move |cx| {
1477 if let Some(workspace) = workspace.upgrade() {
1478 workspace.update(cx, |workspace, cx| {
1479 struct ClipboardToast;
1480 workspace.show_toast(
1481 workspace::Toast::new(
1482 workspace::notifications::NotificationId::unique::<ClipboardToast>(),
1483 message,
1484 )
1485 .autohide(),
1486 cx,
1487 );
1488 });
1489 }
1490 });
1491 }
1492
1493 fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1494 let Some(clipboard) = cx.read_from_clipboard() else {
1495 Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx);
1496 return;
1497 };
1498
1499 let Some(encoded) = clipboard.text() else {
1500 Self::show_deferred_toast(&self.workspace, "Clipboard does not contain text", cx);
1501 return;
1502 };
1503
1504 let thread_data = match base64::Engine::decode(&base64::prelude::BASE64_STANDARD, &encoded)
1505 {
1506 Ok(data) => data,
1507 Err(_) => {
1508 Self::show_deferred_toast(
1509 &self.workspace,
1510 "Failed to decode clipboard content (expected base64)",
1511 cx,
1512 );
1513 return;
1514 }
1515 };
1516
1517 let shared_thread = match SharedThread::from_bytes(&thread_data) {
1518 Ok(thread) => thread,
1519 Err(_) => {
1520 Self::show_deferred_toast(
1521 &self.workspace,
1522 "Failed to parse thread data from clipboard",
1523 cx,
1524 );
1525 return;
1526 }
1527 };
1528
1529 let db_thread = shared_thread.to_db_thread();
1530 let session_id = acp::SessionId::new(uuid::Uuid::new_v4().to_string());
1531 let thread_store = self.thread_store.clone();
1532 let title = db_thread.title.clone();
1533 let workspace = self.workspace.clone();
1534
1535 cx.spawn_in(window, async move |this, cx| {
1536 thread_store
1537 .update(&mut cx.clone(), |store, cx| {
1538 store.save_thread(session_id.clone(), db_thread, Default::default(), cx)
1539 })
1540 .await?;
1541
1542 this.update_in(cx, |this, window, cx| {
1543 this.open_thread(session_id, None, Some(title), window, cx);
1544 })?;
1545
1546 this.update_in(cx, |_, _window, cx| {
1547 if let Some(workspace) = workspace.upgrade() {
1548 workspace.update(cx, |workspace, cx| {
1549 struct ThreadLoadedToast;
1550 workspace.show_toast(
1551 workspace::Toast::new(
1552 workspace::notifications::NotificationId::unique::<ThreadLoadedToast>(),
1553 "Thread loaded from clipboard",
1554 )
1555 .autohide(),
1556 cx,
1557 );
1558 });
1559 }
1560 })?;
1561
1562 anyhow::Ok(())
1563 })
1564 .detach_and_log_err(cx);
1565 }
1566
1567 fn handle_agent_configuration_event(
1568 &mut self,
1569 _entity: &Entity<AgentConfiguration>,
1570 event: &AssistantConfigurationEvent,
1571 window: &mut Window,
1572 cx: &mut Context<Self>,
1573 ) {
1574 match event {
1575 AssistantConfigurationEvent::NewThread(provider) => {
1576 if LanguageModelRegistry::read_global(cx)
1577 .default_model()
1578 .is_none_or(|model| model.provider.id() != provider.id())
1579 && let Some(model) = provider.default_model(cx)
1580 {
1581 update_settings_file(self.fs.clone(), cx, move |settings, _| {
1582 let provider = model.provider_id().0.to_string();
1583 let enable_thinking = model.supports_thinking();
1584 let effort = model
1585 .default_effort_level()
1586 .map(|effort| effort.value.to_string());
1587 let model = model.id().0.to_string();
1588 settings
1589 .agent
1590 .get_or_insert_default()
1591 .set_model(LanguageModelSelection {
1592 provider: LanguageModelProviderSetting(provider),
1593 model,
1594 enable_thinking,
1595 effort,
1596 })
1597 });
1598 }
1599
1600 self.new_thread(&NewThread, window, cx);
1601 if let Some((thread, model)) = self
1602 .active_native_agent_thread(cx)
1603 .zip(provider.default_model(cx))
1604 {
1605 thread.update(cx, |thread, cx| {
1606 thread.set_model(model, cx);
1607 });
1608 }
1609 }
1610 }
1611 }
1612
1613 pub fn workspace_id(&self) -> Option<WorkspaceId> {
1614 self.workspace_id
1615 }
1616
1617 pub fn background_threads(&self) -> &HashMap<acp::SessionId, Entity<ConversationView>> {
1618 &self.background_threads
1619 }
1620
1621 pub fn active_conversation_view(&self) -> Option<&Entity<ConversationView>> {
1622 match &self.active_view {
1623 ActiveView::AgentThread { conversation_view } => Some(conversation_view),
1624 _ => None,
1625 }
1626 }
1627
1628 pub fn active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
1629 let server_view = self.active_conversation_view()?;
1630 server_view.read(cx).active_thread().cloned()
1631 }
1632
1633 pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
1634 match &self.active_view {
1635 ActiveView::AgentThread {
1636 conversation_view, ..
1637 } => conversation_view
1638 .read(cx)
1639 .active_thread()
1640 .map(|r| r.read(cx).thread.clone()),
1641 _ => None,
1642 }
1643 }
1644
1645 /// Returns the primary thread views for all retained connections: the
1646 pub fn is_background_thread(&self, session_id: &acp::SessionId) -> bool {
1647 self.background_threads.contains_key(session_id)
1648 }
1649
1650 pub fn cancel_thread(&self, session_id: &acp::SessionId, cx: &mut Context<Self>) -> bool {
1651 let conversation_views = self
1652 .active_conversation_view()
1653 .into_iter()
1654 .chain(self.background_threads.values());
1655
1656 for conversation_view in conversation_views {
1657 if let Some(thread_view) = conversation_view.read(cx).thread_view(session_id) {
1658 thread_view.update(cx, |view, cx| view.cancel_generation(cx));
1659 return true;
1660 }
1661 }
1662 false
1663 }
1664
1665 /// active thread plus any background threads that are still running or
1666 /// completed but unseen.
1667 pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
1668 let mut views = Vec::new();
1669
1670 if let Some(server_view) = self.active_conversation_view() {
1671 if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
1672 views.push(thread_view);
1673 }
1674 }
1675
1676 for server_view in self.background_threads.values() {
1677 if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
1678 views.push(thread_view);
1679 }
1680 }
1681
1682 views
1683 }
1684
1685 fn update_thread_work_dirs(&self, cx: &mut Context<Self>) {
1686 let new_work_dirs = self.project.read(cx).default_path_list(cx);
1687
1688 if let Some(conversation_view) = self.active_conversation_view() {
1689 conversation_view.update(cx, |conversation_view, cx| {
1690 conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
1691 });
1692 }
1693
1694 for conversation_view in self.background_threads.values() {
1695 conversation_view.update(cx, |conversation_view, cx| {
1696 conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
1697 });
1698 }
1699 }
1700
1701 fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context<Self>) {
1702 let ActiveView::AgentThread { conversation_view } = old_view else {
1703 return;
1704 };
1705
1706 let Some(thread_view) = conversation_view.read(cx).root_thread(cx) else {
1707 return;
1708 };
1709
1710 self.background_threads
1711 .insert(thread_view.read(cx).id.clone(), conversation_view);
1712 self.cleanup_background_threads(cx);
1713 }
1714
1715 /// We keep threads that are:
1716 /// - Still running
1717 /// - Do not support reloading the full session
1718 /// - Have had the most recent events (up to 5 idle threads)
1719 fn cleanup_background_threads(&mut self, cx: &App) {
1720 let mut potential_removals = self
1721 .background_threads
1722 .iter()
1723 .filter(|(_id, view)| {
1724 let Some(thread_view) = view.read(cx).root_thread(cx) else {
1725 return true;
1726 };
1727 let thread = thread_view.read(cx).thread.read(cx);
1728 thread.connection().supports_load_session() && thread.status() == ThreadStatus::Idle
1729 })
1730 .collect::<Vec<_>>();
1731
1732 const MAX_IDLE_BACKGROUND_THREADS: usize = 5;
1733
1734 potential_removals.sort_unstable_by_key(|(_, view)| view.read(cx).updated_at(cx));
1735 let n = potential_removals
1736 .len()
1737 .saturating_sub(MAX_IDLE_BACKGROUND_THREADS);
1738 let to_remove = potential_removals
1739 .into_iter()
1740 .map(|(id, _)| id.clone())
1741 .take(n)
1742 .collect::<Vec<_>>();
1743 for id in to_remove {
1744 self.background_threads.remove(&id);
1745 }
1746 }
1747
1748 pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
1749 match &self.active_view {
1750 ActiveView::AgentThread {
1751 conversation_view, ..
1752 } => conversation_view.read(cx).as_native_thread(cx),
1753 _ => None,
1754 }
1755 }
1756
1757 fn set_active_view(
1758 &mut self,
1759 new_view: ActiveView,
1760 focus: bool,
1761 window: &mut Window,
1762 cx: &mut Context<Self>,
1763 ) {
1764 let was_in_agent_history = matches!(self.active_view, ActiveView::History { .. });
1765 let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized);
1766 let current_is_history = matches!(self.active_view, ActiveView::History { .. });
1767 let new_is_history = matches!(new_view, ActiveView::History { .. });
1768
1769 let current_is_config = matches!(self.active_view, ActiveView::Configuration);
1770 let new_is_config = matches!(new_view, ActiveView::Configuration);
1771
1772 let current_is_overlay = current_is_history || current_is_config;
1773 let new_is_overlay = new_is_history || new_is_config;
1774
1775 if current_is_uninitialized || (current_is_overlay && !new_is_overlay) {
1776 self.active_view = new_view;
1777 } else if !current_is_overlay && new_is_overlay {
1778 self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
1779 } else {
1780 let old_view = std::mem::replace(&mut self.active_view, new_view);
1781 if !new_is_overlay {
1782 if let Some(previous) = self.previous_view.take() {
1783 self.retain_running_thread(previous, cx);
1784 }
1785 }
1786 self.retain_running_thread(old_view, cx);
1787 }
1788
1789 // Subscribe to the active ThreadView's events (e.g. FirstSendRequested)
1790 // so the panel can intercept the first send for worktree creation.
1791 // Re-subscribe whenever the ConnectionView changes, since the inner
1792 // ThreadView may have been replaced (e.g. navigating between threads).
1793 self._active_view_observation = match &self.active_view {
1794 ActiveView::AgentThread { conversation_view } => {
1795 self._thread_view_subscription =
1796 Self::subscribe_to_active_thread_view(conversation_view, window, cx);
1797 let focus_handle = conversation_view.focus_handle(cx);
1798 self._active_thread_focus_subscription =
1799 Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| {
1800 cx.emit(AgentPanelEvent::ThreadFocused);
1801 cx.notify();
1802 }));
1803 Some(cx.observe_in(
1804 conversation_view,
1805 window,
1806 |this, server_view, window, cx| {
1807 this._thread_view_subscription =
1808 Self::subscribe_to_active_thread_view(&server_view, window, cx);
1809 cx.emit(AgentPanelEvent::ActiveViewChanged);
1810 this.serialize(cx);
1811 cx.notify();
1812 },
1813 ))
1814 }
1815 _ => {
1816 self._thread_view_subscription = None;
1817 self._active_thread_focus_subscription = None;
1818 None
1819 }
1820 };
1821
1822 if let ActiveView::History { view } = &self.active_view {
1823 if !was_in_agent_history {
1824 view.update(cx, |view, cx| {
1825 view.history()
1826 .update(cx, |history, cx| history.refresh_full_history(cx))
1827 });
1828 }
1829 }
1830
1831 if focus {
1832 self.focus_handle(cx).focus(window, cx);
1833 }
1834 cx.emit(AgentPanelEvent::ActiveViewChanged);
1835 }
1836
1837 fn populate_recently_updated_menu_section(
1838 mut menu: ContextMenu,
1839 panel: Entity<Self>,
1840 view: Entity<ThreadHistoryView>,
1841 cx: &mut Context<ContextMenu>,
1842 ) -> ContextMenu {
1843 let entries = view
1844 .read(cx)
1845 .history()
1846 .read(cx)
1847 .sessions()
1848 .iter()
1849 .take(RECENTLY_UPDATED_MENU_LIMIT)
1850 .cloned()
1851 .collect::<Vec<_>>();
1852
1853 if entries.is_empty() {
1854 return menu;
1855 }
1856
1857 menu = menu.header("Recently Updated");
1858
1859 for entry in entries {
1860 let title = entry
1861 .title
1862 .as_ref()
1863 .filter(|title| !title.is_empty())
1864 .cloned()
1865 .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
1866
1867 menu = menu.entry(title, None, {
1868 let panel = panel.downgrade();
1869 let entry = entry.clone();
1870 move |window, cx| {
1871 let entry = entry.clone();
1872 panel
1873 .update(cx, move |this, cx| {
1874 if let Some(agent) = this.selected_agent() {
1875 this.load_agent_thread(
1876 agent,
1877 entry.session_id.clone(),
1878 entry.work_dirs.clone(),
1879 entry.title.clone(),
1880 true,
1881 window,
1882 cx,
1883 );
1884 }
1885 })
1886 .ok();
1887 }
1888 });
1889 }
1890
1891 menu.separator()
1892 }
1893
1894 fn subscribe_to_active_thread_view(
1895 server_view: &Entity<ConversationView>,
1896 window: &mut Window,
1897 cx: &mut Context<Self>,
1898 ) -> Option<Subscription> {
1899 server_view.read(cx).active_thread().cloned().map(|tv| {
1900 cx.subscribe_in(
1901 &tv,
1902 window,
1903 |this, view, event: &AcpThreadViewEvent, window, cx| match event {
1904 AcpThreadViewEvent::FirstSendRequested { content } => {
1905 this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
1906 }
1907 AcpThreadViewEvent::MessageSentOrQueued => {
1908 let session_id = view.read(cx).thread.read(cx).session_id().clone();
1909 cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id });
1910 }
1911 },
1912 )
1913 })
1914 }
1915
1916 pub fn start_thread_in(&self) -> &StartThreadIn {
1917 &self.start_thread_in
1918 }
1919
1920 fn set_start_thread_in(
1921 &mut self,
1922 action: &StartThreadIn,
1923 window: &mut Window,
1924 cx: &mut Context<Self>,
1925 ) {
1926 if matches!(action, StartThreadIn::NewWorktree) && !cx.has_flag::<AgentV2FeatureFlag>() {
1927 return;
1928 }
1929
1930 let new_target = match *action {
1931 StartThreadIn::LocalProject => StartThreadIn::LocalProject,
1932 StartThreadIn::NewWorktree => {
1933 if !self.project_has_git_repository(cx) {
1934 log::error!(
1935 "set_start_thread_in: cannot use NewWorktree without a git repository"
1936 );
1937 return;
1938 }
1939 if self.project.read(cx).is_via_collab() {
1940 log::error!("set_start_thread_in: cannot use NewWorktree in a collab project");
1941 return;
1942 }
1943 StartThreadIn::NewWorktree
1944 }
1945 };
1946 self.start_thread_in = new_target;
1947 if let Some(thread) = self.active_thread_view(cx) {
1948 thread.update(cx, |thread, cx| thread.focus_handle(cx).focus(window, cx));
1949 }
1950 self.serialize(cx);
1951 cx.notify();
1952 }
1953
1954 fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1955 let next = match self.start_thread_in {
1956 StartThreadIn::LocalProject => StartThreadIn::NewWorktree,
1957 StartThreadIn::NewWorktree => StartThreadIn::LocalProject,
1958 };
1959 self.set_start_thread_in(&next, window, cx);
1960 }
1961
1962 fn reset_start_thread_in_to_default(&mut self, cx: &mut Context<Self>) {
1963 use settings::{NewThreadLocation, Settings};
1964 let default = AgentSettings::get_global(cx).new_thread_location;
1965 let start_thread_in = match default {
1966 NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
1967 NewThreadLocation::NewWorktree => {
1968 if self.project_has_git_repository(cx) {
1969 StartThreadIn::NewWorktree
1970 } else {
1971 StartThreadIn::LocalProject
1972 }
1973 }
1974 };
1975 if self.start_thread_in != start_thread_in {
1976 self.start_thread_in = start_thread_in;
1977 self.serialize(cx);
1978 cx.notify();
1979 }
1980 }
1981
1982 pub(crate) fn selected_agent(&self) -> Option<Agent> {
1983 Some(self.selected_agent.clone())
1984 }
1985
1986 fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
1987 if let Some(extension_store) = ExtensionStore::try_global(cx) {
1988 let (manifests, extensions_dir) = {
1989 let store = extension_store.read(cx);
1990 let installed = store.installed_extensions();
1991 let manifests: Vec<_> = installed
1992 .iter()
1993 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
1994 .collect();
1995 let extensions_dir = paths::extensions_dir().join("installed");
1996 (manifests, extensions_dir)
1997 };
1998
1999 self.project.update(cx, |project, cx| {
2000 project.agent_server_store().update(cx, |store, cx| {
2001 let manifest_refs: Vec<_> = manifests
2002 .iter()
2003 .map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
2004 .collect();
2005 store.sync_extension_agents(manifest_refs, extensions_dir, cx);
2006 });
2007 });
2008 }
2009 }
2010
2011 pub fn new_agent_thread_with_external_source_prompt(
2012 &mut self,
2013 external_source_prompt: Option<ExternalSourcePrompt>,
2014 window: &mut Window,
2015 cx: &mut Context<Self>,
2016 ) {
2017 self.external_thread(
2018 None,
2019 None,
2020 None,
2021 None,
2022 external_source_prompt.map(AgentInitialContent::from),
2023 true,
2024 window,
2025 cx,
2026 );
2027 }
2028
2029 pub fn new_agent_thread(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
2030 self.reset_start_thread_in_to_default(cx);
2031 self.new_agent_thread_inner(agent, true, window, cx);
2032 }
2033
2034 fn new_agent_thread_inner(
2035 &mut self,
2036 agent: Agent,
2037 focus: bool,
2038 window: &mut Window,
2039 cx: &mut Context<Self>,
2040 ) {
2041 self.external_thread(Some(agent), None, None, None, None, focus, window, cx);
2042 }
2043
2044 pub fn load_agent_thread(
2045 &mut self,
2046 agent: Agent,
2047 session_id: acp::SessionId,
2048 work_dirs: Option<PathList>,
2049 title: Option<SharedString>,
2050 focus: bool,
2051 window: &mut Window,
2052 cx: &mut Context<Self>,
2053 ) {
2054 if let Some(conversation_view) = self.background_threads.remove(&session_id) {
2055 self.set_active_view(
2056 ActiveView::AgentThread { conversation_view },
2057 focus,
2058 window,
2059 cx,
2060 );
2061 return;
2062 }
2063
2064 if let ActiveView::AgentThread { conversation_view } = &self.active_view {
2065 if conversation_view
2066 .read(cx)
2067 .active_thread()
2068 .map(|t| t.read(cx).id.clone())
2069 == Some(session_id.clone())
2070 {
2071 cx.emit(AgentPanelEvent::ActiveViewChanged);
2072 return;
2073 }
2074 }
2075
2076 if let Some(ActiveView::AgentThread { conversation_view }) = &self.previous_view {
2077 if conversation_view
2078 .read(cx)
2079 .active_thread()
2080 .map(|t| t.read(cx).id.clone())
2081 == Some(session_id.clone())
2082 {
2083 let view = self.previous_view.take().unwrap();
2084 self.set_active_view(view, focus, window, cx);
2085 return;
2086 }
2087 }
2088
2089 self.external_thread(
2090 Some(agent),
2091 Some(session_id),
2092 work_dirs,
2093 title,
2094 None,
2095 focus,
2096 window,
2097 cx,
2098 );
2099 }
2100
2101 pub(crate) fn create_agent_thread(
2102 &mut self,
2103 server: Rc<dyn AgentServer>,
2104 resume_session_id: Option<acp::SessionId>,
2105 work_dirs: Option<PathList>,
2106 title: Option<SharedString>,
2107 initial_content: Option<AgentInitialContent>,
2108 workspace: WeakEntity<Workspace>,
2109 project: Entity<Project>,
2110 ext_agent: Agent,
2111 focus: bool,
2112 window: &mut Window,
2113 cx: &mut Context<Self>,
2114 ) {
2115 if self.selected_agent != ext_agent {
2116 self.selected_agent = ext_agent.clone();
2117 self.serialize(cx);
2118 }
2119 let thread_store = server
2120 .clone()
2121 .downcast::<agent::NativeAgentServer>()
2122 .is_some()
2123 .then(|| self.thread_store.clone());
2124
2125 let connection_store = self.connection_store.clone();
2126
2127 let conversation_view = cx.new(|cx| {
2128 crate::ConversationView::new(
2129 server,
2130 connection_store,
2131 ext_agent,
2132 resume_session_id,
2133 work_dirs,
2134 title,
2135 initial_content,
2136 workspace.clone(),
2137 project,
2138 thread_store,
2139 self.prompt_store.clone(),
2140 window,
2141 cx,
2142 )
2143 });
2144
2145 cx.observe(&conversation_view, |this, server_view, cx| {
2146 let is_active = this
2147 .active_conversation_view()
2148 .is_some_and(|active| active.entity_id() == server_view.entity_id());
2149 if is_active {
2150 cx.emit(AgentPanelEvent::ActiveViewChanged);
2151 this.serialize(cx);
2152 } else {
2153 cx.emit(AgentPanelEvent::BackgroundThreadChanged);
2154 }
2155 cx.notify();
2156 })
2157 .detach();
2158
2159 self.set_active_view(
2160 ActiveView::AgentThread { conversation_view },
2161 focus,
2162 window,
2163 cx,
2164 );
2165 }
2166
2167 fn active_thread_has_messages(&self, cx: &App) -> bool {
2168 self.active_agent_thread(cx)
2169 .is_some_and(|thread| !thread.read(cx).entries().is_empty())
2170 }
2171
2172 pub fn active_thread_is_draft(&self, cx: &App) -> bool {
2173 self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
2174 }
2175
2176 fn handle_first_send_requested(
2177 &mut self,
2178 thread_view: Entity<ThreadView>,
2179 content: Vec<acp::ContentBlock>,
2180 window: &mut Window,
2181 cx: &mut Context<Self>,
2182 ) {
2183 if self.start_thread_in == StartThreadIn::NewWorktree {
2184 self.handle_worktree_creation_requested(content, window, cx);
2185 } else {
2186 cx.defer_in(window, move |_this, window, cx| {
2187 thread_view.update(cx, |thread_view, cx| {
2188 let editor = thread_view.message_editor.clone();
2189 thread_view.send_impl(editor, window, cx);
2190 });
2191 });
2192 }
2193 }
2194
2195 // TODO: The mapping from workspace root paths to git repositories needs a
2196 // unified approach across the codebase: this method, `sidebar::is_root_repo`,
2197 // thread persistence (which PathList is saved to the database), and thread
2198 // querying (which PathList is used to read threads back). All of these need
2199 // to agree on how repos are resolved for a given workspace, especially in
2200 // multi-root and nested-repo configurations.
2201 /// Partitions the project's visible worktrees into git-backed repositories
2202 /// and plain (non-git) paths. Git repos will have worktrees created for
2203 /// them; non-git paths are carried over to the new workspace as-is.
2204 ///
2205 /// When multiple worktrees map to the same repository, the most specific
2206 /// match wins (deepest work directory path), with a deterministic
2207 /// tie-break on entity id. Each repository appears at most once.
2208 fn classify_worktrees(
2209 &self,
2210 cx: &App,
2211 ) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
2212 let project = &self.project;
2213 let repositories = project.read(cx).repositories(cx).clone();
2214 let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
2215 let mut non_git_paths: Vec<PathBuf> = Vec::new();
2216 let mut seen_repo_ids = std::collections::HashSet::new();
2217
2218 for worktree in project.read(cx).visible_worktrees(cx) {
2219 let wt_path = worktree.read(cx).abs_path();
2220
2221 let matching_repo = repositories
2222 .iter()
2223 .filter_map(|(id, repo)| {
2224 let work_dir = repo.read(cx).work_directory_abs_path.clone();
2225 if wt_path.starts_with(work_dir.as_ref())
2226 || work_dir.starts_with(wt_path.as_ref())
2227 {
2228 Some((*id, repo.clone(), work_dir.as_ref().components().count()))
2229 } else {
2230 None
2231 }
2232 })
2233 .max_by(
2234 |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
2235 left_depth
2236 .cmp(right_depth)
2237 .then_with(|| left_id.cmp(right_id))
2238 },
2239 );
2240
2241 if let Some((id, repo, _)) = matching_repo {
2242 if seen_repo_ids.insert(id) {
2243 git_repos.push(repo);
2244 }
2245 } else {
2246 non_git_paths.push(wt_path.to_path_buf());
2247 }
2248 }
2249
2250 (git_repos, non_git_paths)
2251 }
2252
2253 /// Kicks off an async git-worktree creation for each repository. Returns:
2254 ///
2255 /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
2256 /// receiver resolves once the git worktree command finishes.
2257 /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
2258 /// later to remap open editor tabs into the new workspace.
2259 fn start_worktree_creations(
2260 git_repos: &[Entity<project::git_store::Repository>],
2261 branch_name: &str,
2262 worktree_directory_setting: &str,
2263 cx: &mut Context<Self>,
2264 ) -> Result<(
2265 Vec<(
2266 Entity<project::git_store::Repository>,
2267 PathBuf,
2268 futures::channel::oneshot::Receiver<Result<()>>,
2269 )>,
2270 Vec<(PathBuf, PathBuf)>,
2271 )> {
2272 let mut creation_infos = Vec::new();
2273 let mut path_remapping = Vec::new();
2274
2275 for repo in git_repos {
2276 let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
2277 let new_path =
2278 repo.path_for_new_linked_worktree(branch_name, worktree_directory_setting)?;
2279 let receiver =
2280 repo.create_worktree(branch_name.to_string(), new_path.clone(), None);
2281 let work_dir = repo.work_directory_abs_path.clone();
2282 anyhow::Ok((work_dir, new_path, receiver))
2283 })?;
2284 path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
2285 creation_infos.push((repo.clone(), new_path, receiver));
2286 }
2287
2288 Ok((creation_infos, path_remapping))
2289 }
2290
2291 /// Waits for every in-flight worktree creation to complete. If any
2292 /// creation fails, all successfully-created worktrees are rolled back
2293 /// (removed) so the project isn't left in a half-migrated state.
2294 async fn await_and_rollback_on_failure(
2295 creation_infos: Vec<(
2296 Entity<project::git_store::Repository>,
2297 PathBuf,
2298 futures::channel::oneshot::Receiver<Result<()>>,
2299 )>,
2300 cx: &mut AsyncWindowContext,
2301 ) -> Result<Vec<PathBuf>> {
2302 let mut created_paths: Vec<PathBuf> = Vec::new();
2303 let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
2304 Vec::new();
2305 let mut first_error: Option<anyhow::Error> = None;
2306
2307 for (repo, new_path, receiver) in creation_infos {
2308 match receiver.await {
2309 Ok(Ok(())) => {
2310 created_paths.push(new_path.clone());
2311 repos_and_paths.push((repo, new_path));
2312 }
2313 Ok(Err(err)) => {
2314 if first_error.is_none() {
2315 first_error = Some(err);
2316 }
2317 }
2318 Err(_canceled) => {
2319 if first_error.is_none() {
2320 first_error = Some(anyhow!("Worktree creation was canceled"));
2321 }
2322 }
2323 }
2324 }
2325
2326 let Some(err) = first_error else {
2327 return Ok(created_paths);
2328 };
2329
2330 // Rollback all successfully created worktrees
2331 let mut rollback_receivers = Vec::new();
2332 for (rollback_repo, rollback_path) in &repos_and_paths {
2333 if let Ok(receiver) = cx.update(|_, cx| {
2334 rollback_repo.update(cx, |repo, _cx| {
2335 repo.remove_worktree(rollback_path.clone(), true)
2336 })
2337 }) {
2338 rollback_receivers.push((rollback_path.clone(), receiver));
2339 }
2340 }
2341 let mut rollback_failures: Vec<String> = Vec::new();
2342 for (path, receiver) in rollback_receivers {
2343 match receiver.await {
2344 Ok(Ok(())) => {}
2345 Ok(Err(rollback_err)) => {
2346 log::error!(
2347 "failed to rollback worktree at {}: {rollback_err}",
2348 path.display()
2349 );
2350 rollback_failures.push(format!("{}: {rollback_err}", path.display()));
2351 }
2352 Err(rollback_err) => {
2353 log::error!(
2354 "failed to rollback worktree at {}: {rollback_err}",
2355 path.display()
2356 );
2357 rollback_failures.push(format!("{}: {rollback_err}", path.display()));
2358 }
2359 }
2360 }
2361 let mut error_message = format!("Failed to create worktree: {err}");
2362 if !rollback_failures.is_empty() {
2363 error_message.push_str("\n\nFailed to clean up: ");
2364 error_message.push_str(&rollback_failures.join(", "));
2365 }
2366 Err(anyhow!(error_message))
2367 }
2368
2369 fn set_worktree_creation_error(
2370 &mut self,
2371 message: SharedString,
2372 window: &mut Window,
2373 cx: &mut Context<Self>,
2374 ) {
2375 self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message));
2376 if matches!(self.active_view, ActiveView::Uninitialized) {
2377 let selected_agent = self.selected_agent.clone();
2378 self.new_agent_thread(selected_agent, window, cx);
2379 }
2380 cx.notify();
2381 }
2382
2383 fn handle_worktree_creation_requested(
2384 &mut self,
2385 content: Vec<acp::ContentBlock>,
2386 window: &mut Window,
2387 cx: &mut Context<Self>,
2388 ) {
2389 if matches!(
2390 self.worktree_creation_status,
2391 Some(WorktreeCreationStatus::Creating)
2392 ) {
2393 return;
2394 }
2395
2396 self.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
2397 cx.notify();
2398
2399 let (git_repos, non_git_paths) = self.classify_worktrees(cx);
2400
2401 if git_repos.is_empty() {
2402 self.set_worktree_creation_error(
2403 "No git repositories found in the project".into(),
2404 window,
2405 cx,
2406 );
2407 return;
2408 }
2409
2410 // Kick off branch listing as early as possible so it can run
2411 // concurrently with the remaining synchronous setup work.
2412 let branch_receivers: Vec<_> = git_repos
2413 .iter()
2414 .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
2415 .collect();
2416
2417 let worktree_directory_setting = ProjectSettings::get_global(cx)
2418 .git
2419 .worktree_directory
2420 .clone();
2421
2422 let active_file_path = self.workspace.upgrade().and_then(|workspace| {
2423 let workspace = workspace.read(cx);
2424 let active_item = workspace.active_item(cx)?;
2425 let project_path = active_item.project_path(cx)?;
2426 workspace
2427 .project()
2428 .read(cx)
2429 .absolute_path(&project_path, cx)
2430 });
2431
2432 let workspace = self.workspace.clone();
2433 let window_handle = window
2434 .window_handle()
2435 .downcast::<workspace::MultiWorkspace>();
2436
2437 let selected_agent = self.selected_agent();
2438
2439 let task = cx.spawn_in(window, async move |this, cx| {
2440 // Await the branch listings we kicked off earlier.
2441 let mut existing_branches = Vec::new();
2442 for result in futures::future::join_all(branch_receivers).await {
2443 match result {
2444 Ok(Ok(branches)) => {
2445 for branch in branches {
2446 existing_branches.push(branch.name().to_string());
2447 }
2448 }
2449 Ok(Err(err)) => {
2450 Err::<(), _>(err).log_err();
2451 }
2452 Err(_) => {}
2453 }
2454 }
2455
2456 let existing_branch_refs: Vec<&str> =
2457 existing_branches.iter().map(|s| s.as_str()).collect();
2458 let mut rng = rand::rng();
2459 let branch_name =
2460 match crate::branch_names::generate_branch_name(&existing_branch_refs, &mut rng) {
2461 Some(name) => name,
2462 None => {
2463 this.update_in(cx, |this, window, cx| {
2464 this.set_worktree_creation_error(
2465 "Failed to generate a unique branch name".into(),
2466 window,
2467 cx,
2468 );
2469 })?;
2470 return anyhow::Ok(());
2471 }
2472 };
2473
2474 let (creation_infos, path_remapping) = match this.update_in(cx, |_this, _window, cx| {
2475 Self::start_worktree_creations(
2476 &git_repos,
2477 &branch_name,
2478 &worktree_directory_setting,
2479 cx,
2480 )
2481 }) {
2482 Ok(Ok(result)) => result,
2483 Ok(Err(err)) | Err(err) => {
2484 this.update_in(cx, |this, window, cx| {
2485 this.set_worktree_creation_error(
2486 format!("Failed to validate worktree directory: {err}").into(),
2487 window,
2488 cx,
2489 );
2490 })
2491 .log_err();
2492 return anyhow::Ok(());
2493 }
2494 };
2495
2496 let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
2497 {
2498 Ok(paths) => paths,
2499 Err(err) => {
2500 this.update_in(cx, |this, window, cx| {
2501 this.set_worktree_creation_error(format!("{err}").into(), window, cx);
2502 })?;
2503 return anyhow::Ok(());
2504 }
2505 };
2506
2507 let mut all_paths = created_paths;
2508 let has_non_git = !non_git_paths.is_empty();
2509 all_paths.extend(non_git_paths.iter().cloned());
2510
2511 let app_state = match workspace.upgrade() {
2512 Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?,
2513 None => {
2514 this.update_in(cx, |this, window, cx| {
2515 this.set_worktree_creation_error(
2516 "Workspace no longer available".into(),
2517 window,
2518 cx,
2519 );
2520 })?;
2521 return anyhow::Ok(());
2522 }
2523 };
2524
2525 let this_for_error = this.clone();
2526 if let Err(err) = Self::setup_new_workspace(
2527 this,
2528 all_paths,
2529 app_state,
2530 window_handle,
2531 active_file_path,
2532 path_remapping,
2533 non_git_paths,
2534 has_non_git,
2535 content,
2536 selected_agent,
2537 cx,
2538 )
2539 .await
2540 {
2541 this_for_error
2542 .update_in(cx, |this, window, cx| {
2543 this.set_worktree_creation_error(
2544 format!("Failed to set up workspace: {err}").into(),
2545 window,
2546 cx,
2547 );
2548 })
2549 .log_err();
2550 }
2551 anyhow::Ok(())
2552 });
2553
2554 self._worktree_creation_task = Some(cx.foreground_executor().spawn(async move {
2555 task.await.log_err();
2556 }));
2557 }
2558
2559 async fn setup_new_workspace(
2560 this: WeakEntity<Self>,
2561 all_paths: Vec<PathBuf>,
2562 app_state: Arc<workspace::AppState>,
2563 window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
2564 active_file_path: Option<PathBuf>,
2565 path_remapping: Vec<(PathBuf, PathBuf)>,
2566 non_git_paths: Vec<PathBuf>,
2567 has_non_git: bool,
2568 content: Vec<acp::ContentBlock>,
2569 selected_agent: Option<Agent>,
2570 cx: &mut AsyncWindowContext,
2571 ) -> Result<()> {
2572 let OpenResult {
2573 window: new_window_handle,
2574 workspace: new_workspace,
2575 ..
2576 } = cx
2577 .update(|_window, cx| {
2578 Workspace::new_local(
2579 all_paths,
2580 app_state,
2581 window_handle,
2582 None,
2583 None,
2584 OpenMode::Add,
2585 cx,
2586 )
2587 })?
2588 .await?;
2589
2590 let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
2591
2592 if let Some(task) = panels_task {
2593 task.await.log_err();
2594 }
2595
2596 new_workspace
2597 .update(cx, |workspace, cx| {
2598 workspace.project().read(cx).wait_for_initial_scan(cx)
2599 })
2600 .await;
2601
2602 new_workspace
2603 .update(cx, |workspace, cx| {
2604 let repos = workspace
2605 .project()
2606 .read(cx)
2607 .repositories(cx)
2608 .values()
2609 .cloned()
2610 .collect::<Vec<_>>();
2611
2612 let tasks = repos
2613 .into_iter()
2614 .map(|repo| repo.update(cx, |repo, _| repo.barrier()));
2615 futures::future::join_all(tasks)
2616 })
2617 .await;
2618
2619 let initial_content = AgentInitialContent::ContentBlock {
2620 blocks: content,
2621 auto_submit: true,
2622 };
2623
2624 new_window_handle.update(cx, |_multi_workspace, window, cx| {
2625 new_workspace.update(cx, |workspace, cx| {
2626 if has_non_git {
2627 let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
2628 workspace.show_toast(
2629 workspace::Toast::new(
2630 toast_id,
2631 "Some project folders are not git repositories. \
2632 They were included as-is without creating a worktree.",
2633 ),
2634 cx,
2635 );
2636 }
2637
2638 // If we had an active buffer, remap its path and reopen it.
2639 let had_active_file = active_file_path.is_some();
2640 let remapped_active_path = active_file_path.and_then(|original_path| {
2641 let best_match = path_remapping
2642 .iter()
2643 .filter_map(|(old_root, new_root)| {
2644 original_path.strip_prefix(old_root).ok().map(|relative| {
2645 (old_root.components().count(), new_root.join(relative))
2646 })
2647 })
2648 .max_by_key(|(depth, _)| *depth);
2649
2650 if let Some((_, remapped_path)) = best_match {
2651 return Some(remapped_path);
2652 }
2653
2654 for non_git in &non_git_paths {
2655 if original_path.starts_with(non_git) {
2656 return Some(original_path);
2657 }
2658 }
2659 None
2660 });
2661
2662 if had_active_file && remapped_active_path.is_none() {
2663 log::warn!(
2664 "Active file could not be remapped to the new worktree; it will not be reopened"
2665 );
2666 }
2667
2668 if let Some(path) = remapped_active_path {
2669 let open_task = workspace.open_paths(
2670 vec![path],
2671 workspace::OpenOptions::default(),
2672 None,
2673 window,
2674 cx,
2675 );
2676 cx.spawn(async move |_, _| -> anyhow::Result<()> {
2677 for item in open_task.await.into_iter().flatten() {
2678 item?;
2679 }
2680 Ok(())
2681 })
2682 .detach_and_log_err(cx);
2683 }
2684
2685 workspace.focus_panel::<AgentPanel>(window, cx);
2686
2687 // If no active buffer was open, zoom the agent panel
2688 // (equivalent to cmd-esc fullscreen behavior).
2689 // This must happen after focus_panel, which activates
2690 // and opens the panel in the dock.
2691
2692 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2693 panel.update(cx, |panel, cx| {
2694 panel.external_thread(
2695 selected_agent,
2696 None,
2697 None,
2698 None,
2699 Some(initial_content),
2700 true,
2701 window,
2702 cx,
2703 );
2704 });
2705 }
2706 });
2707 })?;
2708
2709 new_window_handle.update(cx, |multi_workspace, window, cx| {
2710 multi_workspace.activate(new_workspace.clone(), window, cx);
2711 })?;
2712
2713 this.update_in(cx, |this, window, cx| {
2714 this.worktree_creation_status = None;
2715
2716 if let Some(thread_view) = this.active_thread_view(cx) {
2717 thread_view.update(cx, |thread_view, cx| {
2718 thread_view
2719 .message_editor
2720 .update(cx, |editor, cx| editor.clear(window, cx));
2721 });
2722 }
2723
2724 cx.notify();
2725 })?;
2726
2727 anyhow::Ok(())
2728 }
2729}
2730
2731impl Focusable for AgentPanel {
2732 fn focus_handle(&self, cx: &App) -> FocusHandle {
2733 match &self.active_view {
2734 ActiveView::Uninitialized => self.focus_handle.clone(),
2735 ActiveView::AgentThread {
2736 conversation_view, ..
2737 } => conversation_view.focus_handle(cx),
2738 ActiveView::History { view } => view.read(cx).focus_handle(cx),
2739 ActiveView::Configuration => {
2740 if let Some(configuration) = self.configuration.as_ref() {
2741 configuration.focus_handle(cx)
2742 } else {
2743 self.focus_handle.clone()
2744 }
2745 }
2746 }
2747 }
2748}
2749
2750fn agent_panel_dock_position(cx: &App) -> DockPosition {
2751 AgentSettings::get_global(cx).dock.into()
2752}
2753
2754pub enum AgentPanelEvent {
2755 ActiveViewChanged,
2756 ThreadFocused,
2757 BackgroundThreadChanged,
2758 MessageSentOrQueued { session_id: acp::SessionId },
2759}
2760
2761impl EventEmitter<PanelEvent> for AgentPanel {}
2762impl EventEmitter<AgentPanelEvent> for AgentPanel {}
2763
2764impl Panel for AgentPanel {
2765 fn persistent_name() -> &'static str {
2766 "AgentPanel"
2767 }
2768
2769 fn panel_key() -> &'static str {
2770 AGENT_PANEL_KEY
2771 }
2772
2773 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
2774 agent_panel_dock_position(cx)
2775 }
2776
2777 fn position_is_valid(&self, position: DockPosition) -> bool {
2778 position != DockPosition::Bottom
2779 }
2780
2781 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2782 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
2783 settings
2784 .agent
2785 .get_or_insert_default()
2786 .set_dock(position.into());
2787 });
2788 }
2789
2790 fn default_size(&self, window: &Window, cx: &App) -> Pixels {
2791 let settings = AgentSettings::get_global(cx);
2792 match self.position(window, cx) {
2793 DockPosition::Left | DockPosition::Right => settings.default_width,
2794 DockPosition::Bottom => settings.default_height,
2795 }
2796 }
2797
2798 fn supports_flexible_size(&self) -> bool {
2799 true
2800 }
2801
2802 fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
2803 AgentSettings::get_global(cx).flexible
2804 }
2805
2806 fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
2807 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
2808 settings
2809 .agent
2810 .get_or_insert_default()
2811 .set_flexible_size(flexible);
2812 });
2813 }
2814
2815 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
2816 if active
2817 && matches!(self.active_view, ActiveView::Uninitialized)
2818 && !matches!(
2819 self.worktree_creation_status,
2820 Some(WorktreeCreationStatus::Creating)
2821 )
2822 {
2823 let selected_agent = self.selected_agent.clone();
2824 self.new_agent_thread_inner(selected_agent, false, window, cx);
2825 }
2826 }
2827
2828 fn remote_id() -> Option<proto::PanelId> {
2829 Some(proto::PanelId::AssistantPanel)
2830 }
2831
2832 fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
2833 (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
2834 }
2835
2836 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2837 Some("Agent Panel")
2838 }
2839
2840 fn toggle_action(&self) -> Box<dyn Action> {
2841 Box::new(ToggleFocus)
2842 }
2843
2844 fn activation_priority(&self) -> u32 {
2845 0
2846 }
2847
2848 fn enabled(&self, cx: &App) -> bool {
2849 AgentSettings::get_global(cx).enabled(cx)
2850 }
2851
2852 fn is_agent_panel(&self) -> bool {
2853 true
2854 }
2855
2856 fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
2857 self.zoomed
2858 }
2859
2860 fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
2861 self.zoomed = zoomed;
2862 cx.notify();
2863 }
2864}
2865
2866impl AgentPanel {
2867 fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
2868 let content = match &self.active_view {
2869 ActiveView::AgentThread { conversation_view } => {
2870 let server_view_ref = conversation_view.read(cx);
2871 let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
2872 && server_view_ref.root_thread(cx).map_or(false, |tv| {
2873 tv.read(cx).thread.read(cx).has_provisional_title()
2874 });
2875
2876 if let Some(title_editor) = server_view_ref
2877 .root_thread(cx)
2878 .map(|r| r.read(cx).title_editor.clone())
2879 {
2880 if is_generating_title {
2881 Label::new(DEFAULT_THREAD_TITLE)
2882 .color(Color::Muted)
2883 .truncate()
2884 .with_animation(
2885 "generating_title",
2886 Animation::new(Duration::from_secs(2))
2887 .repeat()
2888 .with_easing(pulsating_between(0.4, 0.8)),
2889 |label, delta| label.alpha(delta),
2890 )
2891 .into_any_element()
2892 } else {
2893 div()
2894 .w_full()
2895 .on_action({
2896 let conversation_view = conversation_view.downgrade();
2897 move |_: &menu::Confirm, window, cx| {
2898 if let Some(conversation_view) = conversation_view.upgrade() {
2899 conversation_view.focus_handle(cx).focus(window, cx);
2900 }
2901 }
2902 })
2903 .on_action({
2904 let conversation_view = conversation_view.downgrade();
2905 move |_: &editor::actions::Cancel, window, cx| {
2906 if let Some(conversation_view) = conversation_view.upgrade() {
2907 conversation_view.focus_handle(cx).focus(window, cx);
2908 }
2909 }
2910 })
2911 .child(title_editor)
2912 .into_any_element()
2913 }
2914 } else {
2915 Label::new(conversation_view.read(cx).title(cx))
2916 .color(Color::Muted)
2917 .truncate()
2918 .into_any_element()
2919 }
2920 }
2921 ActiveView::History { .. } => Label::new("History").truncate().into_any_element(),
2922 ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
2923 ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
2924 };
2925
2926 h_flex()
2927 .key_context("TitleEditor")
2928 .id("TitleEditor")
2929 .flex_grow()
2930 .w_full()
2931 .max_w_full()
2932 .overflow_x_scroll()
2933 .child(content)
2934 .into_any()
2935 }
2936
2937 fn handle_regenerate_thread_title(conversation_view: Entity<ConversationView>, cx: &mut App) {
2938 conversation_view.update(cx, |conversation_view, cx| {
2939 if let Some(thread) = conversation_view.as_native_thread(cx) {
2940 thread.update(cx, |thread, cx| {
2941 thread.generate_title(cx);
2942 });
2943 }
2944 });
2945 }
2946
2947 fn render_panel_options_menu(
2948 &self,
2949 window: &mut Window,
2950 cx: &mut Context<Self>,
2951 ) -> impl IntoElement {
2952 let focus_handle = self.focus_handle(cx);
2953
2954 let full_screen_label = if self.is_zoomed(window, cx) {
2955 "Disable Full Screen"
2956 } else {
2957 "Enable Full Screen"
2958 };
2959
2960 let conversation_view = match &self.active_view {
2961 ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
2962 _ => None,
2963 };
2964 let thread_with_messages = match &self.active_view {
2965 ActiveView::AgentThread { conversation_view } => {
2966 conversation_view.read(cx).has_user_submitted_prompt(cx)
2967 }
2968 _ => false,
2969 };
2970 let has_auth_methods = match &self.active_view {
2971 ActiveView::AgentThread { conversation_view } => {
2972 conversation_view.read(cx).has_auth_methods()
2973 }
2974 _ => false,
2975 };
2976
2977 PopoverMenu::new("agent-options-menu")
2978 .trigger_with_tooltip(
2979 IconButton::new("agent-options-menu", IconName::Ellipsis)
2980 .icon_size(IconSize::Small),
2981 {
2982 let focus_handle = focus_handle.clone();
2983 move |_window, cx| {
2984 Tooltip::for_action_in(
2985 "Toggle Agent Menu",
2986 &ToggleOptionsMenu,
2987 &focus_handle,
2988 cx,
2989 )
2990 }
2991 },
2992 )
2993 .anchor(Corner::TopRight)
2994 .with_handle(self.agent_panel_menu_handle.clone())
2995 .menu({
2996 move |window, cx| {
2997 Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
2998 menu = menu.context(focus_handle.clone());
2999
3000 if thread_with_messages {
3001 menu = menu.header("Current Thread");
3002
3003 if let Some(conversation_view) = conversation_view.as_ref() {
3004 menu = menu
3005 .entry("Regenerate Thread Title", None, {
3006 let conversation_view = conversation_view.clone();
3007 move |_, cx| {
3008 Self::handle_regenerate_thread_title(
3009 conversation_view.clone(),
3010 cx,
3011 );
3012 }
3013 })
3014 .separator();
3015 }
3016 }
3017
3018 menu = menu
3019 .header("MCP Servers")
3020 .action(
3021 "View Server Extensions",
3022 Box::new(zed_actions::Extensions {
3023 category_filter: Some(
3024 zed_actions::ExtensionCategoryFilter::ContextServers,
3025 ),
3026 id: None,
3027 }),
3028 )
3029 .action("Add Custom Server…", Box::new(AddContextServer))
3030 .separator()
3031 .action("Rules", Box::new(OpenRulesLibrary::default()))
3032 .action("Profiles", Box::new(ManageProfiles::default()))
3033 .action("Settings", Box::new(OpenSettings))
3034 .separator()
3035 .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar))
3036 .action(full_screen_label, Box::new(ToggleZoom));
3037
3038 if has_auth_methods {
3039 menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
3040 }
3041
3042 menu
3043 }))
3044 }
3045 })
3046 }
3047
3048 fn render_recent_entries_menu(
3049 &self,
3050 icon: IconName,
3051 corner: Corner,
3052 cx: &mut Context<Self>,
3053 ) -> impl IntoElement {
3054 let focus_handle = self.focus_handle(cx);
3055
3056 PopoverMenu::new("agent-nav-menu")
3057 .trigger_with_tooltip(
3058 IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small),
3059 {
3060 move |_window, cx| {
3061 Tooltip::for_action_in(
3062 "Toggle Recently Updated Threads",
3063 &ToggleNavigationMenu,
3064 &focus_handle,
3065 cx,
3066 )
3067 }
3068 },
3069 )
3070 .anchor(corner)
3071 .with_handle(self.agent_navigation_menu_handle.clone())
3072 .menu({
3073 let menu = self.agent_navigation_menu.clone();
3074 move |window, cx| {
3075 telemetry::event!("View Thread History Clicked");
3076
3077 if let Some(menu) = menu.as_ref() {
3078 menu.update(cx, |_, cx| {
3079 cx.defer_in(window, |menu, window, cx| {
3080 menu.rebuild(window, cx);
3081 });
3082 })
3083 }
3084 menu.clone()
3085 }
3086 })
3087 }
3088
3089 fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3090 let focus_handle = self.focus_handle(cx);
3091
3092 IconButton::new("go-back", IconName::ArrowLeft)
3093 .icon_size(IconSize::Small)
3094 .on_click(cx.listener(|this, _, window, cx| {
3095 this.go_back(&workspace::GoBack, window, cx);
3096 }))
3097 .tooltip({
3098 move |_window, cx| {
3099 Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
3100 }
3101 })
3102 }
3103
3104 fn project_has_git_repository(&self, cx: &App) -> bool {
3105 !self.project.read(cx).repositories(cx).is_empty()
3106 }
3107
3108 fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3109 use settings::{NewThreadLocation, Settings};
3110
3111 let focus_handle = self.focus_handle(cx);
3112 let has_git_repo = self.project_has_git_repository(cx);
3113 let is_via_collab = self.project.read(cx).is_via_collab();
3114 let fs = self.fs.clone();
3115
3116 let is_creating = matches!(
3117 self.worktree_creation_status,
3118 Some(WorktreeCreationStatus::Creating)
3119 );
3120
3121 let current_target = self.start_thread_in;
3122 let trigger_label = self.start_thread_in.label();
3123
3124 let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
3125 let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
3126 let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
3127
3128 let icon = if self.start_thread_in_menu_handle.is_deployed() {
3129 IconName::ChevronUp
3130 } else {
3131 IconName::ChevronDown
3132 };
3133
3134 let trigger_button = Button::new("thread-target-trigger", trigger_label)
3135 .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
3136 .disabled(is_creating);
3137
3138 let dock_position = AgentSettings::get_global(cx).dock;
3139 let documentation_side = match dock_position {
3140 settings::DockPosition::Left => DocumentationSide::Right,
3141 settings::DockPosition::Bottom | settings::DockPosition::Right => {
3142 DocumentationSide::Left
3143 }
3144 };
3145
3146 PopoverMenu::new("thread-target-selector")
3147 .trigger_with_tooltip(trigger_button, {
3148 move |_window, cx| {
3149 Tooltip::for_action_in(
3150 "Start Thread In…",
3151 &CycleStartThreadIn,
3152 &focus_handle,
3153 cx,
3154 )
3155 }
3156 })
3157 .menu(move |window, cx| {
3158 let is_local_selected = current_target == StartThreadIn::LocalProject;
3159 let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree;
3160 let fs = fs.clone();
3161
3162 Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
3163 let new_worktree_disabled = !has_git_repo || is_via_collab;
3164
3165 menu.header("Start Thread In…")
3166 .item(
3167 ContextMenuEntry::new("Current Worktree")
3168 .toggleable(IconPosition::End, is_local_selected)
3169 .documentation_aside(documentation_side, move |_| {
3170 HoldForDefault::new(is_local_default)
3171 .more_content(false)
3172 .into_any_element()
3173 })
3174 .handler({
3175 let fs = fs.clone();
3176 move |window, cx| {
3177 if window.modifiers().secondary() {
3178 update_settings_file(fs.clone(), cx, |settings, _| {
3179 settings
3180 .agent
3181 .get_or_insert_default()
3182 .set_new_thread_location(
3183 NewThreadLocation::LocalProject,
3184 );
3185 });
3186 }
3187 window.dispatch_action(
3188 Box::new(StartThreadIn::LocalProject),
3189 cx,
3190 );
3191 }
3192 }),
3193 )
3194 .item({
3195 let entry = ContextMenuEntry::new("New Git Worktree")
3196 .toggleable(IconPosition::End, is_new_worktree_selected)
3197 .disabled(new_worktree_disabled)
3198 .handler({
3199 let fs = fs.clone();
3200 move |window, cx| {
3201 if window.modifiers().secondary() {
3202 update_settings_file(fs.clone(), cx, |settings, _| {
3203 settings
3204 .agent
3205 .get_or_insert_default()
3206 .set_new_thread_location(
3207 NewThreadLocation::NewWorktree,
3208 );
3209 });
3210 }
3211 window.dispatch_action(
3212 Box::new(StartThreadIn::NewWorktree),
3213 cx,
3214 );
3215 }
3216 });
3217
3218 if new_worktree_disabled {
3219 entry.documentation_aside(documentation_side, move |_| {
3220 let reason = if !has_git_repo {
3221 "No git repository found in this project."
3222 } else {
3223 "Not available for remote/collab projects yet."
3224 };
3225 Label::new(reason)
3226 .color(Color::Muted)
3227 .size(LabelSize::Small)
3228 .into_any_element()
3229 })
3230 } else {
3231 entry.documentation_aside(documentation_side, move |_| {
3232 HoldForDefault::new(is_new_worktree_default)
3233 .more_content(false)
3234 .into_any_element()
3235 })
3236 }
3237 })
3238 }))
3239 })
3240 .with_handle(self.start_thread_in_menu_handle.clone())
3241 .anchor(Corner::TopLeft)
3242 .offset(gpui::Point {
3243 x: px(1.0),
3244 y: px(1.0),
3245 })
3246 }
3247
3248 fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3249 let agent_server_store = self.project.read(cx).agent_server_store().clone();
3250 let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some();
3251 let focus_handle = self.focus_handle(cx);
3252
3253 let (selected_agent_custom_icon, selected_agent_label) =
3254 if let Agent::Custom { id, .. } = &self.selected_agent {
3255 let store = agent_server_store.read(cx);
3256 let icon = store.agent_icon(&id);
3257
3258 let label = store
3259 .agent_display_name(&id)
3260 .unwrap_or_else(|| self.selected_agent.label());
3261 (icon, label)
3262 } else {
3263 (None, self.selected_agent.label())
3264 };
3265
3266 let active_thread = match &self.active_view {
3267 ActiveView::AgentThread { conversation_view } => {
3268 conversation_view.read(cx).as_native_thread(cx)
3269 }
3270 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3271 None
3272 }
3273 };
3274
3275 let new_thread_menu_builder: Rc<
3276 dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
3277 > = {
3278 let selected_agent = self.selected_agent.clone();
3279 let is_agent_selected = move |agent: Agent| selected_agent == agent;
3280
3281 let workspace = self.workspace.clone();
3282 let is_via_collab = workspace
3283 .update(cx, |workspace, cx| {
3284 workspace.project().read(cx).is_via_collab()
3285 })
3286 .unwrap_or_default();
3287
3288 let focus_handle = focus_handle.clone();
3289 let agent_server_store = agent_server_store;
3290
3291 Rc::new(move |window, cx| {
3292 telemetry::event!("New Thread Clicked");
3293
3294 let active_thread = active_thread.clone();
3295 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
3296 menu.context(focus_handle.clone())
3297 .when_some(active_thread, |this, active_thread| {
3298 let thread = active_thread.read(cx);
3299
3300 if !thread.is_empty() {
3301 let session_id = thread.id().clone();
3302 this.item(
3303 ContextMenuEntry::new("New From Summary")
3304 .icon(IconName::ThreadFromSummary)
3305 .icon_color(Color::Muted)
3306 .handler(move |window, cx| {
3307 window.dispatch_action(
3308 Box::new(NewNativeAgentThreadFromSummary {
3309 from_session_id: session_id.clone(),
3310 }),
3311 cx,
3312 );
3313 }),
3314 )
3315 } else {
3316 this
3317 }
3318 })
3319 .item(
3320 ContextMenuEntry::new("Zed Agent")
3321 .when(is_agent_selected(Agent::NativeAgent), |this| {
3322 this.action(Box::new(NewExternalAgentThread { agent: None }))
3323 })
3324 .icon(IconName::ZedAgent)
3325 .icon_color(Color::Muted)
3326 .handler({
3327 let workspace = workspace.clone();
3328 move |window, cx| {
3329 if let Some(workspace) = workspace.upgrade() {
3330 workspace.update(cx, |workspace, cx| {
3331 if let Some(panel) =
3332 workspace.panel::<AgentPanel>(cx)
3333 {
3334 panel.update(cx, |panel, cx| {
3335 panel.new_agent_thread(
3336 Agent::NativeAgent,
3337 window,
3338 cx,
3339 );
3340 });
3341 }
3342 });
3343 }
3344 }
3345 }),
3346 )
3347 .map(|mut menu| {
3348 let agent_server_store = agent_server_store.read(cx);
3349 let registry_store = project::AgentRegistryStore::try_global(cx);
3350 let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
3351
3352 struct AgentMenuItem {
3353 id: AgentId,
3354 display_name: SharedString,
3355 }
3356
3357 let agent_items = agent_server_store
3358 .external_agents()
3359 .map(|agent_id| {
3360 let display_name = agent_server_store
3361 .agent_display_name(agent_id)
3362 .or_else(|| {
3363 registry_store_ref
3364 .as_ref()
3365 .and_then(|store| store.agent(agent_id))
3366 .map(|a| a.name().clone())
3367 })
3368 .unwrap_or_else(|| agent_id.0.clone());
3369 AgentMenuItem {
3370 id: agent_id.clone(),
3371 display_name,
3372 }
3373 })
3374 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
3375 .collect::<Vec<_>>();
3376
3377 if !agent_items.is_empty() {
3378 menu = menu.separator().header("External Agents");
3379 }
3380 for item in &agent_items {
3381 let mut entry = ContextMenuEntry::new(item.display_name.clone());
3382
3383 let icon_path =
3384 agent_server_store.agent_icon(&item.id).or_else(|| {
3385 registry_store_ref
3386 .as_ref()
3387 .and_then(|store| store.agent(&item.id))
3388 .and_then(|a| a.icon_path().cloned())
3389 });
3390
3391 if let Some(icon_path) = icon_path {
3392 entry = entry.custom_icon_svg(icon_path);
3393 } else {
3394 entry = entry.icon(IconName::Sparkle);
3395 }
3396
3397 entry = entry
3398 .when(
3399 is_agent_selected(Agent::Custom {
3400 id: item.id.clone(),
3401 }),
3402 |this| {
3403 this.action(Box::new(NewExternalAgentThread {
3404 agent: None,
3405 }))
3406 },
3407 )
3408 .icon_color(Color::Muted)
3409 .disabled(is_via_collab)
3410 .handler({
3411 let workspace = workspace.clone();
3412 let agent_id = item.id.clone();
3413 move |window, cx| {
3414 if let Some(workspace) = workspace.upgrade() {
3415 workspace.update(cx, |workspace, cx| {
3416 if let Some(panel) =
3417 workspace.panel::<AgentPanel>(cx)
3418 {
3419 panel.update(cx, |panel, cx| {
3420 panel.new_agent_thread(
3421 Agent::Custom {
3422 id: agent_id.clone(),
3423 },
3424 window,
3425 cx,
3426 );
3427 });
3428 }
3429 });
3430 }
3431 }
3432 });
3433
3434 menu = menu.item(entry);
3435 }
3436
3437 menu
3438 })
3439 .separator()
3440 .item(
3441 ContextMenuEntry::new("Add More Agents")
3442 .icon(IconName::Plus)
3443 .icon_color(Color::Muted)
3444 .handler({
3445 move |window, cx| {
3446 window
3447 .dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
3448 }
3449 }),
3450 )
3451 }))
3452 })
3453 };
3454
3455 let is_thread_loading = self
3456 .active_conversation_view()
3457 .map(|thread| thread.read(cx).is_loading())
3458 .unwrap_or(false);
3459
3460 let has_custom_icon = selected_agent_custom_icon.is_some();
3461 let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
3462 let selected_agent_builtin_icon = self.selected_agent.icon();
3463 let selected_agent_label_for_tooltip = selected_agent_label.clone();
3464
3465 let selected_agent = div()
3466 .id("selected_agent_icon")
3467 .when_some(selected_agent_custom_icon, |this, icon_path| {
3468 this.px_1()
3469 .child(Icon::from_external_svg(icon_path).color(Color::Muted))
3470 })
3471 .when(!has_custom_icon, |this| {
3472 this.when_some(selected_agent_builtin_icon, |this, icon| {
3473 this.px_1().child(Icon::new(icon).color(Color::Muted))
3474 })
3475 })
3476 .tooltip(move |_, cx| {
3477 Tooltip::with_meta(
3478 selected_agent_label_for_tooltip.clone(),
3479 None,
3480 "Selected Agent",
3481 cx,
3482 )
3483 });
3484
3485 let selected_agent = if is_thread_loading {
3486 selected_agent
3487 .with_animation(
3488 "pulsating-icon",
3489 Animation::new(Duration::from_secs(1))
3490 .repeat()
3491 .with_easing(pulsating_between(0.2, 0.6)),
3492 |icon, delta| icon.opacity(delta),
3493 )
3494 .into_any_element()
3495 } else {
3496 selected_agent.into_any_element()
3497 };
3498
3499 let show_history_menu = self.has_history_for_selected_agent(cx);
3500 let has_v2_flag = cx.has_flag::<AgentV2FeatureFlag>();
3501 let is_empty_state = !self.active_thread_has_messages(cx);
3502
3503 let is_in_history_or_config = matches!(
3504 &self.active_view,
3505 ActiveView::History { .. } | ActiveView::Configuration
3506 );
3507
3508 let is_full_screen = self.is_zoomed(window, cx);
3509
3510 let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config;
3511
3512 let base_container = h_flex()
3513 .id("agent-panel-toolbar")
3514 .h(Tab::container_height(cx))
3515 .max_w_full()
3516 .flex_none()
3517 .justify_between()
3518 .gap_2()
3519 .bg(cx.theme().colors().tab_bar_background)
3520 .border_b_1()
3521 .border_color(cx.theme().colors().border);
3522
3523 if use_v2_empty_toolbar {
3524 let (chevron_icon, icon_color, label_color) =
3525 if self.new_thread_menu_handle.is_deployed() {
3526 (IconName::ChevronUp, Color::Accent, Color::Accent)
3527 } else {
3528 (IconName::ChevronDown, Color::Muted, Color::Default)
3529 };
3530
3531 let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
3532 Icon::from_external_svg(icon_path)
3533 .size(IconSize::Small)
3534 .color(icon_color)
3535 } else {
3536 let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
3537 Icon::new(icon_name).size(IconSize::Small).color(icon_color)
3538 };
3539
3540 let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
3541 .start_icon(agent_icon)
3542 .color(label_color)
3543 .end_icon(
3544 Icon::new(chevron_icon)
3545 .color(icon_color)
3546 .size(IconSize::XSmall),
3547 );
3548
3549 let agent_selector_menu = PopoverMenu::new("new_thread_menu")
3550 .trigger_with_tooltip(agent_selector_button, {
3551 move |_window, cx| {
3552 Tooltip::for_action_in(
3553 "New Thread…",
3554 &ToggleNewThreadMenu,
3555 &focus_handle,
3556 cx,
3557 )
3558 }
3559 })
3560 .menu({
3561 let builder = new_thread_menu_builder.clone();
3562 move |window, cx| builder(window, cx)
3563 })
3564 .with_handle(self.new_thread_menu_handle.clone())
3565 .anchor(Corner::TopLeft)
3566 .offset(gpui::Point {
3567 x: px(1.0),
3568 y: px(1.0),
3569 });
3570
3571 base_container
3572 .child(
3573 h_flex()
3574 .size_full()
3575 .gap(DynamicSpacing::Base04.rems(cx))
3576 .pl(DynamicSpacing::Base04.rems(cx))
3577 .child(agent_selector_menu)
3578 .when(
3579 has_visible_worktrees && self.project_has_git_repository(cx),
3580 |this| this.child(self.render_start_thread_in_selector(cx)),
3581 ),
3582 )
3583 .child(
3584 h_flex()
3585 .h_full()
3586 .flex_none()
3587 .gap_1()
3588 .pl_1()
3589 .pr_1()
3590 .when(show_history_menu && !has_v2_flag, |this| {
3591 this.child(self.render_recent_entries_menu(
3592 IconName::MenuAltTemp,
3593 Corner::TopRight,
3594 cx,
3595 ))
3596 })
3597 .when(is_full_screen, |this| {
3598 this.child(
3599 IconButton::new("disable-full-screen", IconName::Minimize)
3600 .icon_size(IconSize::Small)
3601 .tooltip(move |_, cx| {
3602 Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx)
3603 })
3604 .on_click({
3605 cx.listener(move |_, _, window, cx| {
3606 window.dispatch_action(ToggleZoom.boxed_clone(), cx);
3607 })
3608 }),
3609 )
3610 })
3611 .child(self.render_panel_options_menu(window, cx)),
3612 )
3613 .into_any_element()
3614 } else {
3615 let new_thread_menu = PopoverMenu::new("new_thread_menu")
3616 .trigger_with_tooltip(
3617 IconButton::new("new_thread_menu_btn", IconName::Plus)
3618 .icon_size(IconSize::Small),
3619 {
3620 move |_window, cx| {
3621 Tooltip::for_action_in(
3622 "New Thread\u{2026}",
3623 &ToggleNewThreadMenu,
3624 &focus_handle,
3625 cx,
3626 )
3627 }
3628 },
3629 )
3630 .anchor(Corner::TopRight)
3631 .with_handle(self.new_thread_menu_handle.clone())
3632 .menu(move |window, cx| new_thread_menu_builder(window, cx));
3633
3634 base_container
3635 .child(
3636 h_flex()
3637 .size_full()
3638 .gap(DynamicSpacing::Base04.rems(cx))
3639 .pl(DynamicSpacing::Base04.rems(cx))
3640 .child(match &self.active_view {
3641 ActiveView::History { .. } | ActiveView::Configuration => {
3642 self.render_toolbar_back_button(cx).into_any_element()
3643 }
3644 _ => selected_agent.into_any_element(),
3645 })
3646 .child(self.render_title_view(window, cx)),
3647 )
3648 .child(
3649 h_flex()
3650 .h_full()
3651 .flex_none()
3652 .gap_1()
3653 .pl_1()
3654 .pr_1()
3655 .child(new_thread_menu)
3656 .when(show_history_menu && !has_v2_flag, |this| {
3657 this.child(self.render_recent_entries_menu(
3658 IconName::MenuAltTemp,
3659 Corner::TopRight,
3660 cx,
3661 ))
3662 })
3663 .when(is_full_screen, |this| {
3664 this.child(
3665 IconButton::new("disable-full-screen", IconName::Minimize)
3666 .icon_size(IconSize::Small)
3667 .tooltip(move |_, cx| {
3668 Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx)
3669 })
3670 .on_click({
3671 cx.listener(move |_, _, window, cx| {
3672 window.dispatch_action(ToggleZoom.boxed_clone(), cx);
3673 })
3674 }),
3675 )
3676 })
3677 .child(self.render_panel_options_menu(window, cx)),
3678 )
3679 .into_any_element()
3680 }
3681 }
3682
3683 fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3684 let status = self.worktree_creation_status.as_ref()?;
3685 match status {
3686 WorktreeCreationStatus::Creating => Some(
3687 h_flex()
3688 .absolute()
3689 .bottom_12()
3690 .w_full()
3691 .p_2()
3692 .gap_1()
3693 .justify_center()
3694 .bg(cx.theme().colors().editor_background)
3695 .child(
3696 Icon::new(IconName::LoadCircle)
3697 .size(IconSize::Small)
3698 .color(Color::Muted)
3699 .with_rotate_animation(3),
3700 )
3701 .child(
3702 Label::new("Creating Worktree…")
3703 .color(Color::Muted)
3704 .size(LabelSize::Small),
3705 )
3706 .into_any_element(),
3707 ),
3708 WorktreeCreationStatus::Error(message) => Some(
3709 Callout::new()
3710 .icon(IconName::Warning)
3711 .severity(Severity::Warning)
3712 .title(message.clone())
3713 .into_any_element(),
3714 ),
3715 }
3716 }
3717
3718 fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
3719 if TrialEndUpsell::dismissed(cx) {
3720 return false;
3721 }
3722
3723 match &self.active_view {
3724 ActiveView::AgentThread { .. } => {
3725 if LanguageModelRegistry::global(cx)
3726 .read(cx)
3727 .default_model()
3728 .is_some_and(|model| {
3729 model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
3730 })
3731 {
3732 return false;
3733 }
3734 }
3735 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3736 return false;
3737 }
3738 }
3739
3740 let plan = self.user_store.read(cx).plan();
3741 let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
3742
3743 plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
3744 }
3745
3746 fn should_render_onboarding(&self, cx: &mut Context<Self>) -> bool {
3747 if self.on_boarding_upsell_dismissed.load(Ordering::Acquire) {
3748 return false;
3749 }
3750
3751 let user_store = self.user_store.read(cx);
3752
3753 if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
3754 && user_store
3755 .subscription_period()
3756 .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
3757 .is_some_and(|date| date < chrono::Utc::now())
3758 {
3759 OnboardingUpsell::set_dismissed(true, cx);
3760 self.on_boarding_upsell_dismissed
3761 .store(true, Ordering::Release);
3762 return false;
3763 }
3764
3765 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
3766 .visible_providers()
3767 .iter()
3768 .any(|provider| {
3769 provider.is_authenticated(cx)
3770 && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
3771 });
3772
3773 match &self.active_view {
3774 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3775 false
3776 }
3777 ActiveView::AgentThread {
3778 conversation_view, ..
3779 } if conversation_view.read(cx).as_native_thread(cx).is_none() => false,
3780 ActiveView::AgentThread { conversation_view } => {
3781 let history_is_empty = conversation_view
3782 .read(cx)
3783 .history()
3784 .is_none_or(|h| h.read(cx).is_empty());
3785 history_is_empty || !has_configured_non_zed_providers
3786 }
3787 }
3788 }
3789
3790 fn render_onboarding(
3791 &self,
3792 _window: &mut Window,
3793 cx: &mut Context<Self>,
3794 ) -> Option<impl IntoElement> {
3795 if !self.should_render_onboarding(cx) {
3796 return None;
3797 }
3798
3799 Some(div().child(self.onboarding.clone()))
3800 }
3801
3802 fn render_trial_end_upsell(
3803 &self,
3804 _window: &mut Window,
3805 cx: &mut Context<Self>,
3806 ) -> Option<impl IntoElement> {
3807 if !self.should_render_trial_end_upsell(cx) {
3808 return None;
3809 }
3810
3811 Some(
3812 v_flex()
3813 .absolute()
3814 .inset_0()
3815 .size_full()
3816 .bg(cx.theme().colors().panel_background)
3817 .opacity(0.85)
3818 .block_mouse_except_scroll()
3819 .child(EndTrialUpsell::new(Arc::new({
3820 let this = cx.entity();
3821 move |_, cx| {
3822 this.update(cx, |_this, cx| {
3823 TrialEndUpsell::set_dismissed(true, cx);
3824 cx.notify();
3825 });
3826 }
3827 }))),
3828 )
3829 }
3830
3831 fn render_drag_target(&self, cx: &Context<Self>) -> Div {
3832 let is_local = self.project.read(cx).is_local();
3833 div()
3834 .invisible()
3835 .absolute()
3836 .top_0()
3837 .right_0()
3838 .bottom_0()
3839 .left_0()
3840 .bg(cx.theme().colors().drop_target_background)
3841 .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
3842 .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
3843 .when(is_local, |this| {
3844 this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
3845 })
3846 .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
3847 let item = tab.pane.read(cx).item_for_index(tab.ix);
3848 let project_paths = item
3849 .and_then(|item| item.project_path(cx))
3850 .into_iter()
3851 .collect::<Vec<_>>();
3852 this.handle_drop(project_paths, vec![], window, cx);
3853 }))
3854 .on_drop(
3855 cx.listener(move |this, selection: &DraggedSelection, window, cx| {
3856 let project_paths = selection
3857 .items()
3858 .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
3859 .collect::<Vec<_>>();
3860 this.handle_drop(project_paths, vec![], window, cx);
3861 }),
3862 )
3863 .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
3864 let tasks = paths
3865 .paths()
3866 .iter()
3867 .map(|path| {
3868 Workspace::project_path_for_path(this.project.clone(), path, false, cx)
3869 })
3870 .collect::<Vec<_>>();
3871 cx.spawn_in(window, async move |this, cx| {
3872 let mut paths = vec![];
3873 let mut added_worktrees = vec![];
3874 let opened_paths = futures::future::join_all(tasks).await;
3875 for entry in opened_paths {
3876 if let Some((worktree, project_path)) = entry.log_err() {
3877 added_worktrees.push(worktree);
3878 paths.push(project_path);
3879 }
3880 }
3881 this.update_in(cx, |this, window, cx| {
3882 this.handle_drop(paths, added_worktrees, window, cx);
3883 })
3884 .ok();
3885 })
3886 .detach();
3887 }))
3888 }
3889
3890 fn handle_drop(
3891 &mut self,
3892 paths: Vec<ProjectPath>,
3893 added_worktrees: Vec<Entity<Worktree>>,
3894 window: &mut Window,
3895 cx: &mut Context<Self>,
3896 ) {
3897 match &self.active_view {
3898 ActiveView::AgentThread { conversation_view } => {
3899 conversation_view.update(cx, |conversation_view, cx| {
3900 conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
3901 });
3902 }
3903 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
3904 }
3905 }
3906
3907 fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
3908 if !self.show_trust_workspace_message {
3909 return None;
3910 }
3911
3912 let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
3913
3914 Some(
3915 Callout::new()
3916 .icon(IconName::Warning)
3917 .severity(Severity::Warning)
3918 .border_position(ui::BorderPosition::Bottom)
3919 .title("You're in Restricted Mode")
3920 .description(description)
3921 .actions_slot(
3922 Button::new("open-trust-modal", "Configure Project Trust")
3923 .label_size(LabelSize::Small)
3924 .style(ButtonStyle::Outlined)
3925 .on_click({
3926 cx.listener(move |this, _, window, cx| {
3927 this.workspace
3928 .update(cx, |workspace, cx| {
3929 workspace
3930 .show_worktree_trust_security_modal(true, window, cx)
3931 })
3932 .log_err();
3933 })
3934 }),
3935 ),
3936 )
3937 }
3938
3939 fn key_context(&self) -> KeyContext {
3940 let mut key_context = KeyContext::new_with_defaults();
3941 key_context.add("AgentPanel");
3942 match &self.active_view {
3943 ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
3944 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
3945 }
3946 key_context
3947 }
3948}
3949
3950impl Render for AgentPanel {
3951 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3952 // WARNING: Changes to this element hierarchy can have
3953 // non-obvious implications to the layout of children.
3954 //
3955 // If you need to change it, please confirm:
3956 // - The message editor expands (cmd-option-esc) correctly
3957 // - When expanded, the buttons at the bottom of the panel are displayed correctly
3958 // - Font size works as expected and can be changed with cmd-+/cmd-
3959 // - Scrolling in all views works as expected
3960 // - Files can be dropped into the panel
3961 let content = v_flex()
3962 .relative()
3963 .size_full()
3964 .justify_between()
3965 .key_context(self.key_context())
3966 .on_action(cx.listener(|this, action: &NewThread, window, cx| {
3967 this.new_thread(action, window, cx);
3968 }))
3969 .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
3970 this.open_history(window, cx);
3971 }))
3972 .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
3973 this.open_configuration(window, cx);
3974 }))
3975 .on_action(cx.listener(Self::open_active_thread_as_markdown))
3976 .on_action(cx.listener(Self::deploy_rules_library))
3977 .on_action(cx.listener(Self::go_back))
3978 .on_action(cx.listener(Self::toggle_navigation_menu))
3979 .on_action(cx.listener(Self::toggle_options_menu))
3980 .on_action(cx.listener(Self::increase_font_size))
3981 .on_action(cx.listener(Self::decrease_font_size))
3982 .on_action(cx.listener(Self::reset_font_size))
3983 .on_action(cx.listener(Self::toggle_zoom))
3984 .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
3985 if let Some(conversation_view) = this.active_conversation_view() {
3986 conversation_view.update(cx, |conversation_view, cx| {
3987 conversation_view.reauthenticate(window, cx)
3988 })
3989 }
3990 }))
3991 .child(self.render_toolbar(window, cx))
3992 .children(self.render_workspace_trust_message(cx))
3993 .children(self.render_onboarding(window, cx))
3994 .map(|parent| match &self.active_view {
3995 ActiveView::Uninitialized => parent,
3996 ActiveView::AgentThread {
3997 conversation_view, ..
3998 } => parent
3999 .child(conversation_view.clone())
4000 .child(self.render_drag_target(cx)),
4001 ActiveView::History { view } => parent.child(view.clone()),
4002 ActiveView::Configuration => parent.children(self.configuration.clone()),
4003 })
4004 .children(self.render_worktree_creation_status(cx))
4005 .children(self.render_trial_end_upsell(window, cx));
4006
4007 match self.active_view.which_font_size_used() {
4008 WhichFontSize::AgentFont => {
4009 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
4010 .size_full()
4011 .child(content)
4012 .into_any()
4013 }
4014 _ => content.into_any(),
4015 }
4016 }
4017}
4018
4019struct PromptLibraryInlineAssist {
4020 workspace: WeakEntity<Workspace>,
4021}
4022
4023impl PromptLibraryInlineAssist {
4024 pub fn new(workspace: WeakEntity<Workspace>) -> Self {
4025 Self { workspace }
4026 }
4027}
4028
4029impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
4030 fn assist(
4031 &self,
4032 prompt_editor: &Entity<Editor>,
4033 initial_prompt: Option<String>,
4034 window: &mut Window,
4035 cx: &mut Context<RulesLibrary>,
4036 ) {
4037 InlineAssistant::update_global(cx, |assistant, cx| {
4038 let Some(workspace) = self.workspace.upgrade() else {
4039 return;
4040 };
4041 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4042 return;
4043 };
4044 let history = panel
4045 .read(cx)
4046 .connection_store()
4047 .read(cx)
4048 .entry(&crate::Agent::NativeAgent)
4049 .and_then(|s| s.read(cx).history())
4050 .map(|h| h.downgrade());
4051 let project = workspace.read(cx).project().downgrade();
4052 let panel = panel.read(cx);
4053 let thread_store = panel.thread_store().clone();
4054 assistant.assist(
4055 prompt_editor,
4056 self.workspace.clone(),
4057 project,
4058 thread_store,
4059 None,
4060 history,
4061 initial_prompt,
4062 window,
4063 cx,
4064 );
4065 })
4066 }
4067
4068 fn focus_agent_panel(
4069 &self,
4070 workspace: &mut Workspace,
4071 window: &mut Window,
4072 cx: &mut Context<Workspace>,
4073 ) -> bool {
4074 workspace.focus_panel::<AgentPanel>(window, cx).is_some()
4075 }
4076}
4077
4078struct OnboardingUpsell;
4079
4080impl Dismissable for OnboardingUpsell {
4081 const KEY: &'static str = "dismissed-trial-upsell";
4082}
4083
4084struct TrialEndUpsell;
4085
4086impl Dismissable for TrialEndUpsell {
4087 const KEY: &'static str = "dismissed-trial-end-upsell";
4088}
4089
4090/// Test-only helper methods
4091#[cfg(any(test, feature = "test-support"))]
4092impl AgentPanel {
4093 pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
4094 Self::new(workspace, None, window, cx)
4095 }
4096
4097 /// Opens an external thread using an arbitrary AgentServer.
4098 ///
4099 /// This is a test-only helper that allows visual tests and integration tests
4100 /// to inject a stub server without modifying production code paths.
4101 /// Not compiled into production builds.
4102 pub fn open_external_thread_with_server(
4103 &mut self,
4104 server: Rc<dyn AgentServer>,
4105 window: &mut Window,
4106 cx: &mut Context<Self>,
4107 ) {
4108 let workspace = self.workspace.clone();
4109 let project = self.project.clone();
4110
4111 let ext_agent = Agent::Custom {
4112 id: server.agent_id(),
4113 };
4114
4115 self.create_agent_thread(
4116 server, None, None, None, None, workspace, project, ext_agent, true, window, cx,
4117 );
4118 }
4119
4120 /// Returns the currently active thread view, if any.
4121 ///
4122 /// This is a test-only accessor that exposes the private `active_thread_view()`
4123 /// method for test assertions. Not compiled into production builds.
4124 pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConversationView>> {
4125 self.active_conversation_view()
4126 }
4127
4128 /// Sets the start_thread_in value directly, bypassing validation.
4129 ///
4130 /// This is a test-only helper for visual tests that need to show specific
4131 /// start_thread_in states without requiring a real git repository.
4132 pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
4133 self.start_thread_in = target;
4134 cx.notify();
4135 }
4136
4137 /// Returns the current worktree creation status.
4138 ///
4139 /// This is a test-only helper for visual tests.
4140 pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
4141 self.worktree_creation_status.as_ref()
4142 }
4143
4144 /// Sets the worktree creation status directly.
4145 ///
4146 /// This is a test-only helper for visual tests that need to show the
4147 /// "Creating worktree…" spinner or error banners.
4148 pub fn set_worktree_creation_status_for_tests(
4149 &mut self,
4150 status: Option<WorktreeCreationStatus>,
4151 cx: &mut Context<Self>,
4152 ) {
4153 self.worktree_creation_status = status;
4154 cx.notify();
4155 }
4156
4157 /// Opens the history view.
4158 ///
4159 /// This is a test-only helper that exposes the private `open_history()`
4160 /// method for visual tests.
4161 pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4162 self.open_history(window, cx);
4163 }
4164
4165 /// Opens the start_thread_in selector popover menu.
4166 ///
4167 /// This is a test-only helper for visual tests.
4168 pub fn open_start_thread_in_menu_for_tests(
4169 &mut self,
4170 window: &mut Window,
4171 cx: &mut Context<Self>,
4172 ) {
4173 self.start_thread_in_menu_handle.show(window, cx);
4174 }
4175
4176 /// Dismisses the start_thread_in dropdown menu.
4177 ///
4178 /// This is a test-only helper for visual tests.
4179 pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
4180 self.start_thread_in_menu_handle.hide(cx);
4181 }
4182}
4183
4184#[cfg(test)]
4185mod tests {
4186 use super::*;
4187 use crate::conversation_view::tests::{StubAgentServer, init_test};
4188 use crate::test_support::{
4189 active_session_id, open_thread_with_connection, open_thread_with_custom_connection,
4190 send_message,
4191 };
4192 use acp_thread::{StubAgentConnection, ThreadStatus};
4193 use agent_servers::CODEX_ID;
4194 use feature_flags::FeatureFlagAppExt;
4195 use fs::FakeFs;
4196 use gpui::{TestAppContext, VisualTestContext};
4197 use project::Project;
4198 use serde_json::json;
4199 use std::path::Path;
4200 use std::time::Instant;
4201 use workspace::MultiWorkspace;
4202
4203 #[gpui::test]
4204 async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
4205 init_test(cx);
4206 cx.update(|cx| {
4207 cx.update_flags(true, vec!["agent-v2".to_string()]);
4208 agent::ThreadStore::init_global(cx);
4209 language_model::LanguageModelRegistry::test(cx);
4210 });
4211
4212 // --- Create a MultiWorkspace window with two workspaces ---
4213 let fs = FakeFs::new(cx.executor());
4214 let project_a = Project::test(fs.clone(), [], cx).await;
4215 let project_b = Project::test(fs, [], cx).await;
4216
4217 let multi_workspace =
4218 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4219
4220 let workspace_a = multi_workspace
4221 .read_with(cx, |multi_workspace, _cx| {
4222 multi_workspace.workspace().clone()
4223 })
4224 .unwrap();
4225
4226 let workspace_b = multi_workspace
4227 .update(cx, |multi_workspace, window, cx| {
4228 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
4229 })
4230 .unwrap();
4231
4232 workspace_a.update(cx, |workspace, _cx| {
4233 workspace.set_random_database_id();
4234 });
4235 workspace_b.update(cx, |workspace, _cx| {
4236 workspace.set_random_database_id();
4237 });
4238
4239 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4240
4241 // --- Set up workspace A: with an active thread ---
4242 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
4243 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4244 });
4245
4246 panel_a.update_in(cx, |panel, window, cx| {
4247 panel.open_external_thread_with_server(
4248 Rc::new(StubAgentServer::default_response()),
4249 window,
4250 cx,
4251 );
4252 });
4253
4254 cx.run_until_parked();
4255
4256 panel_a.read_with(cx, |panel, cx| {
4257 assert!(
4258 panel.active_agent_thread(cx).is_some(),
4259 "workspace A should have an active thread after connection"
4260 );
4261 });
4262
4263 let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
4264
4265 // --- Set up workspace B: ClaudeCode, no active thread ---
4266 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
4267 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4268 });
4269
4270 panel_b.update(cx, |panel, _cx| {
4271 panel.selected_agent = Agent::Custom {
4272 id: "claude-acp".into(),
4273 };
4274 });
4275
4276 // --- Serialize both panels ---
4277 panel_a.update(cx, |panel, cx| panel.serialize(cx));
4278 panel_b.update(cx, |panel, cx| panel.serialize(cx));
4279 cx.run_until_parked();
4280
4281 // --- Load fresh panels for each workspace and verify independent state ---
4282 let async_cx = cx.update(|window, cx| window.to_async(cx));
4283 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
4284 .await
4285 .expect("panel A load should succeed");
4286 cx.run_until_parked();
4287
4288 let async_cx = cx.update(|window, cx| window.to_async(cx));
4289 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
4290 .await
4291 .expect("panel B load should succeed");
4292 cx.run_until_parked();
4293
4294 // Workspace A should restore its thread and agent type
4295 loaded_a.read_with(cx, |panel, _cx| {
4296 assert_eq!(
4297 panel.selected_agent, agent_type_a,
4298 "workspace A agent type should be restored"
4299 );
4300 assert!(
4301 panel.active_conversation_view().is_some(),
4302 "workspace A should have its active thread restored"
4303 );
4304 });
4305
4306 // Workspace B should restore its own agent type, with no thread
4307 loaded_b.read_with(cx, |panel, _cx| {
4308 assert_eq!(
4309 panel.selected_agent,
4310 Agent::Custom {
4311 id: "claude-acp".into()
4312 },
4313 "workspace B agent type should be restored"
4314 );
4315 assert!(
4316 panel.active_conversation_view().is_none(),
4317 "workspace B should have no active thread"
4318 );
4319 });
4320 }
4321
4322 /// Extracts the text from a Text content block, panicking if it's not Text.
4323 fn expect_text_block(block: &acp::ContentBlock) -> &str {
4324 match block {
4325 acp::ContentBlock::Text(t) => t.text.as_str(),
4326 other => panic!("expected Text block, got {:?}", other),
4327 }
4328 }
4329
4330 /// Extracts the (text_content, uri) from a Resource content block, panicking
4331 /// if it's not a TextResourceContents resource.
4332 fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
4333 match block {
4334 acp::ContentBlock::Resource(r) => match &r.resource {
4335 acp::EmbeddedResourceResource::TextResourceContents(t) => {
4336 (t.text.as_str(), t.uri.as_str())
4337 }
4338 other => panic!("expected TextResourceContents, got {:?}", other),
4339 },
4340 other => panic!("expected Resource block, got {:?}", other),
4341 }
4342 }
4343
4344 #[test]
4345 fn test_build_conflict_resolution_prompt_single_conflict() {
4346 let conflicts = vec![ConflictContent {
4347 file_path: "src/main.rs".to_string(),
4348 conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
4349 .to_string(),
4350 ours_branch_name: "HEAD".to_string(),
4351 theirs_branch_name: "feature".to_string(),
4352 }];
4353
4354 let blocks = build_conflict_resolution_prompt(&conflicts);
4355 // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
4356 assert_eq!(
4357 blocks.len(),
4358 4,
4359 "expected 2 text + 1 resource link + 1 resource block"
4360 );
4361
4362 let intro_text = expect_text_block(&blocks[0]);
4363 assert!(
4364 intro_text.contains("Please resolve the following merge conflict in"),
4365 "prompt should include single-conflict intro text"
4366 );
4367
4368 match &blocks[1] {
4369 acp::ContentBlock::ResourceLink(link) => {
4370 assert!(
4371 link.uri.contains("file://"),
4372 "resource link URI should use file scheme"
4373 );
4374 assert!(
4375 link.uri.contains("main.rs"),
4376 "resource link URI should reference file path"
4377 );
4378 }
4379 other => panic!("expected ResourceLink block, got {:?}", other),
4380 }
4381
4382 let body_text = expect_text_block(&blocks[2]);
4383 assert!(
4384 body_text.contains("`HEAD` (ours)"),
4385 "prompt should mention ours branch"
4386 );
4387 assert!(
4388 body_text.contains("`feature` (theirs)"),
4389 "prompt should mention theirs branch"
4390 );
4391 assert!(
4392 body_text.contains("editing the file directly"),
4393 "prompt should instruct the agent to edit the file"
4394 );
4395
4396 let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
4397 assert!(
4398 resource_text.contains("<<<<<<< HEAD"),
4399 "resource should contain the conflict text"
4400 );
4401 assert!(
4402 resource_uri.contains("merge-conflict"),
4403 "resource URI should use the merge-conflict scheme"
4404 );
4405 assert!(
4406 resource_uri.contains("main.rs"),
4407 "resource URI should reference the file path"
4408 );
4409 }
4410
4411 #[test]
4412 fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
4413 let conflicts = vec![
4414 ConflictContent {
4415 file_path: "src/lib.rs".to_string(),
4416 conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
4417 .to_string(),
4418 ours_branch_name: "main".to_string(),
4419 theirs_branch_name: "dev".to_string(),
4420 },
4421 ConflictContent {
4422 file_path: "src/lib.rs".to_string(),
4423 conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
4424 .to_string(),
4425 ours_branch_name: "main".to_string(),
4426 theirs_branch_name: "dev".to_string(),
4427 },
4428 ];
4429
4430 let blocks = build_conflict_resolution_prompt(&conflicts);
4431 // 1 Text instruction + 2 Resource blocks
4432 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
4433
4434 let text = expect_text_block(&blocks[0]);
4435 assert!(
4436 text.contains("all 2 merge conflicts"),
4437 "prompt should mention the total count"
4438 );
4439 assert!(
4440 text.contains("`main` (ours)"),
4441 "prompt should mention ours branch"
4442 );
4443 assert!(
4444 text.contains("`dev` (theirs)"),
4445 "prompt should mention theirs branch"
4446 );
4447 // Single file, so "file" not "files"
4448 assert!(
4449 text.contains("file directly"),
4450 "single file should use singular 'file'"
4451 );
4452
4453 let (resource_a, _) = expect_resource_block(&blocks[1]);
4454 let (resource_b, _) = expect_resource_block(&blocks[2]);
4455 assert!(
4456 resource_a.contains("fn a()"),
4457 "first resource should contain first conflict"
4458 );
4459 assert!(
4460 resource_b.contains("fn b()"),
4461 "second resource should contain second conflict"
4462 );
4463 }
4464
4465 #[test]
4466 fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
4467 let conflicts = vec![
4468 ConflictContent {
4469 file_path: "src/a.rs".to_string(),
4470 conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
4471 ours_branch_name: "main".to_string(),
4472 theirs_branch_name: "dev".to_string(),
4473 },
4474 ConflictContent {
4475 file_path: "src/b.rs".to_string(),
4476 conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
4477 ours_branch_name: "main".to_string(),
4478 theirs_branch_name: "dev".to_string(),
4479 },
4480 ];
4481
4482 let blocks = build_conflict_resolution_prompt(&conflicts);
4483 // 1 Text instruction + 2 Resource blocks
4484 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
4485
4486 let text = expect_text_block(&blocks[0]);
4487 assert!(
4488 text.contains("files directly"),
4489 "multiple files should use plural 'files'"
4490 );
4491
4492 let (_, uri_a) = expect_resource_block(&blocks[1]);
4493 let (_, uri_b) = expect_resource_block(&blocks[2]);
4494 assert!(
4495 uri_a.contains("a.rs"),
4496 "first resource URI should reference a.rs"
4497 );
4498 assert!(
4499 uri_b.contains("b.rs"),
4500 "second resource URI should reference b.rs"
4501 );
4502 }
4503
4504 #[test]
4505 fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
4506 let file_paths = vec![
4507 "src/main.rs".to_string(),
4508 "src/lib.rs".to_string(),
4509 "tests/integration.rs".to_string(),
4510 ];
4511
4512 let blocks = build_conflicted_files_resolution_prompt(&file_paths);
4513 // 1 instruction Text block + (ResourceLink + newline Text) per file
4514 assert_eq!(
4515 blocks.len(),
4516 1 + (file_paths.len() * 2),
4517 "expected instruction text plus resource links and separators"
4518 );
4519
4520 let text = expect_text_block(&blocks[0]);
4521 assert!(
4522 text.contains("unresolved merge conflicts"),
4523 "prompt should describe the task"
4524 );
4525 assert!(
4526 text.contains("conflict markers"),
4527 "prompt should mention conflict markers"
4528 );
4529
4530 for (index, path) in file_paths.iter().enumerate() {
4531 let link_index = 1 + (index * 2);
4532 let newline_index = link_index + 1;
4533
4534 match &blocks[link_index] {
4535 acp::ContentBlock::ResourceLink(link) => {
4536 assert!(
4537 link.uri.contains("file://"),
4538 "resource link URI should use file scheme"
4539 );
4540 assert!(
4541 link.uri.contains(path),
4542 "resource link URI should reference file path: {path}"
4543 );
4544 }
4545 other => panic!(
4546 "expected ResourceLink block at index {}, got {:?}",
4547 link_index, other
4548 ),
4549 }
4550
4551 let separator = expect_text_block(&blocks[newline_index]);
4552 assert_eq!(
4553 separator, "\n",
4554 "expected newline separator after each file"
4555 );
4556 }
4557 }
4558
4559 #[test]
4560 fn test_build_conflict_resolution_prompt_empty_conflicts() {
4561 let blocks = build_conflict_resolution_prompt(&[]);
4562 assert!(
4563 blocks.is_empty(),
4564 "empty conflicts should produce no blocks, got {} blocks",
4565 blocks.len()
4566 );
4567 }
4568
4569 #[test]
4570 fn test_build_conflicted_files_resolution_prompt_empty_paths() {
4571 let blocks = build_conflicted_files_resolution_prompt(&[]);
4572 assert!(
4573 blocks.is_empty(),
4574 "empty paths should produce no blocks, got {} blocks",
4575 blocks.len()
4576 );
4577 }
4578
4579 #[test]
4580 fn test_conflict_resource_block_structure() {
4581 let conflict = ConflictContent {
4582 file_path: "src/utils.rs".to_string(),
4583 conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
4584 ours_branch_name: "HEAD".to_string(),
4585 theirs_branch_name: "branch".to_string(),
4586 };
4587
4588 let block = conflict_resource_block(&conflict);
4589 let (text, uri) = expect_resource_block(&block);
4590
4591 assert_eq!(
4592 text, conflict.conflict_text,
4593 "resource text should be the raw conflict"
4594 );
4595 assert!(
4596 uri.starts_with("zed:///agent/merge-conflict"),
4597 "URI should use the zed merge-conflict scheme, got: {uri}"
4598 );
4599 assert!(uri.contains("utils.rs"), "URI should encode the file path");
4600 }
4601
4602 fn open_generating_thread_with_loadable_connection(
4603 panel: &Entity<AgentPanel>,
4604 connection: &StubAgentConnection,
4605 cx: &mut VisualTestContext,
4606 ) -> acp::SessionId {
4607 open_thread_with_custom_connection(panel, connection.clone(), cx);
4608 let session_id = active_session_id(panel, cx);
4609 send_message(panel, cx);
4610 cx.update(|_, cx| {
4611 connection.send_update(
4612 session_id.clone(),
4613 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
4614 cx,
4615 );
4616 });
4617 cx.run_until_parked();
4618 session_id
4619 }
4620
4621 fn open_idle_thread_with_non_loadable_connection(
4622 panel: &Entity<AgentPanel>,
4623 connection: &StubAgentConnection,
4624 cx: &mut VisualTestContext,
4625 ) -> acp::SessionId {
4626 open_thread_with_custom_connection(panel, connection.clone(), cx);
4627 let session_id = active_session_id(panel, cx);
4628
4629 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4630 acp::ContentChunk::new("done".into()),
4631 )]);
4632 send_message(panel, cx);
4633
4634 session_id
4635 }
4636
4637 async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
4638 init_test(cx);
4639 cx.update(|cx| {
4640 cx.update_flags(true, vec!["agent-v2".to_string()]);
4641 agent::ThreadStore::init_global(cx);
4642 language_model::LanguageModelRegistry::test(cx);
4643 });
4644
4645 let fs = FakeFs::new(cx.executor());
4646 let project = Project::test(fs.clone(), [], cx).await;
4647
4648 let multi_workspace =
4649 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4650
4651 let workspace = multi_workspace
4652 .read_with(cx, |mw, _cx| mw.workspace().clone())
4653 .unwrap();
4654
4655 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
4656
4657 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
4658 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4659 });
4660
4661 (panel, cx)
4662 }
4663
4664 #[gpui::test]
4665 async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
4666 let (panel, mut cx) = setup_panel(cx).await;
4667
4668 let connection_a = StubAgentConnection::new();
4669 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
4670 send_message(&panel, &mut cx);
4671
4672 let session_id_a = active_session_id(&panel, &cx);
4673
4674 // Send a chunk to keep thread A generating (don't end the turn).
4675 cx.update(|_, cx| {
4676 connection_a.send_update(
4677 session_id_a.clone(),
4678 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
4679 cx,
4680 );
4681 });
4682 cx.run_until_parked();
4683
4684 // Verify thread A is generating.
4685 panel.read_with(&cx, |panel, cx| {
4686 let thread = panel.active_agent_thread(cx).unwrap();
4687 assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
4688 assert!(panel.background_threads.is_empty());
4689 });
4690
4691 // Open a new thread B — thread A should be retained in background.
4692 let connection_b = StubAgentConnection::new();
4693 open_thread_with_connection(&panel, connection_b, &mut cx);
4694
4695 panel.read_with(&cx, |panel, _cx| {
4696 assert_eq!(
4697 panel.background_threads.len(),
4698 1,
4699 "Running thread A should be retained in background_views"
4700 );
4701 assert!(
4702 panel.background_threads.contains_key(&session_id_a),
4703 "Background view should be keyed by thread A's session ID"
4704 );
4705 });
4706 }
4707
4708 #[gpui::test]
4709 async fn test_idle_non_loadable_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
4710 let (panel, mut cx) = setup_panel(cx).await;
4711
4712 let connection_a = StubAgentConnection::new();
4713 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4714 acp::ContentChunk::new("Response".into()),
4715 )]);
4716 open_thread_with_connection(&panel, connection_a, &mut cx);
4717 send_message(&panel, &mut cx);
4718
4719 let weak_view_a = panel.read_with(&cx, |panel, _cx| {
4720 panel.active_conversation_view().unwrap().downgrade()
4721 });
4722 let session_id_a = active_session_id(&panel, &cx);
4723
4724 // Thread A should be idle (auto-completed via set_next_prompt_updates).
4725 panel.read_with(&cx, |panel, cx| {
4726 let thread = panel.active_agent_thread(cx).unwrap();
4727 assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
4728 });
4729
4730 // Open a new thread B — thread A should be retained because it is not loadable.
4731 let connection_b = StubAgentConnection::new();
4732 open_thread_with_connection(&panel, connection_b, &mut cx);
4733
4734 panel.read_with(&cx, |panel, _cx| {
4735 assert_eq!(
4736 panel.background_threads.len(),
4737 1,
4738 "Idle non-loadable thread A should be retained in background_views"
4739 );
4740 assert!(
4741 panel.background_threads.contains_key(&session_id_a),
4742 "Background view should be keyed by thread A's session ID"
4743 );
4744 });
4745
4746 assert!(
4747 weak_view_a.upgrade().is_some(),
4748 "Idle non-loadable ConnectionView should still be retained"
4749 );
4750 }
4751
4752 #[gpui::test]
4753 async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
4754 let (panel, mut cx) = setup_panel(cx).await;
4755
4756 let connection_a = StubAgentConnection::new();
4757 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
4758 send_message(&panel, &mut cx);
4759
4760 let session_id_a = active_session_id(&panel, &cx);
4761
4762 // Keep thread A generating.
4763 cx.update(|_, cx| {
4764 connection_a.send_update(
4765 session_id_a.clone(),
4766 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
4767 cx,
4768 );
4769 });
4770 cx.run_until_parked();
4771
4772 // Open thread B — thread A goes to background.
4773 let connection_b = StubAgentConnection::new();
4774 open_thread_with_connection(&panel, connection_b, &mut cx);
4775
4776 let session_id_b = active_session_id(&panel, &cx);
4777
4778 panel.read_with(&cx, |panel, _cx| {
4779 assert_eq!(panel.background_threads.len(), 1);
4780 assert!(panel.background_threads.contains_key(&session_id_a));
4781 });
4782
4783 // Load thread A back via load_agent_thread — should promote from background.
4784 panel.update_in(&mut cx, |panel, window, cx| {
4785 panel.load_agent_thread(
4786 panel.selected_agent().expect("selected agent must be set"),
4787 session_id_a.clone(),
4788 None,
4789 None,
4790 true,
4791 window,
4792 cx,
4793 );
4794 });
4795
4796 // Thread A should now be the active view, promoted from background.
4797 let active_session = active_session_id(&panel, &cx);
4798 assert_eq!(
4799 active_session, session_id_a,
4800 "Thread A should be the active thread after promotion"
4801 );
4802
4803 panel.read_with(&cx, |panel, _cx| {
4804 assert!(
4805 !panel.background_threads.contains_key(&session_id_a),
4806 "Promoted thread A should no longer be in background_views"
4807 );
4808 assert!(
4809 panel.background_threads.contains_key(&session_id_b),
4810 "Thread B (idle, non-loadable) should remain retained in background_views"
4811 );
4812 });
4813 }
4814
4815 #[gpui::test]
4816 async fn test_cleanup_background_threads_keeps_five_most_recent_idle_loadable_threads(
4817 cx: &mut TestAppContext,
4818 ) {
4819 let (panel, mut cx) = setup_panel(cx).await;
4820 let connection = StubAgentConnection::new()
4821 .with_supports_load_session(true)
4822 .with_agent_id("loadable-stub".into())
4823 .with_telemetry_id("loadable-stub".into());
4824 let mut session_ids = Vec::new();
4825
4826 for _ in 0..7 {
4827 session_ids.push(open_generating_thread_with_loadable_connection(
4828 &panel,
4829 &connection,
4830 &mut cx,
4831 ));
4832 }
4833
4834 let base_time = Instant::now();
4835
4836 for session_id in session_ids.iter().take(6) {
4837 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
4838 }
4839 cx.run_until_parked();
4840
4841 panel.update(&mut cx, |panel, cx| {
4842 for (index, session_id) in session_ids.iter().take(6).enumerate() {
4843 let conversation_view = panel
4844 .background_threads
4845 .get(session_id)
4846 .expect("background thread should exist")
4847 .clone();
4848 conversation_view.update(cx, |view, cx| {
4849 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
4850 });
4851 }
4852 panel.cleanup_background_threads(cx);
4853 });
4854
4855 panel.read_with(&cx, |panel, _cx| {
4856 assert_eq!(
4857 panel.background_threads.len(),
4858 5,
4859 "cleanup should keep at most five idle loadable background threads"
4860 );
4861 assert!(
4862 !panel.background_threads.contains_key(&session_ids[0]),
4863 "oldest idle loadable background thread should be removed"
4864 );
4865 for session_id in &session_ids[1..6] {
4866 assert!(
4867 panel.background_threads.contains_key(session_id),
4868 "more recent idle loadable background threads should be retained"
4869 );
4870 }
4871 assert!(
4872 !panel.background_threads.contains_key(&session_ids[6]),
4873 "the active thread should not also be stored as a background thread"
4874 );
4875 });
4876 }
4877
4878 #[gpui::test]
4879 async fn test_cleanup_background_threads_preserves_idle_non_loadable_threads(
4880 cx: &mut TestAppContext,
4881 ) {
4882 let (panel, mut cx) = setup_panel(cx).await;
4883
4884 let non_loadable_connection = StubAgentConnection::new();
4885 let non_loadable_session_id = open_idle_thread_with_non_loadable_connection(
4886 &panel,
4887 &non_loadable_connection,
4888 &mut cx,
4889 );
4890
4891 let loadable_connection = StubAgentConnection::new()
4892 .with_supports_load_session(true)
4893 .with_agent_id("loadable-stub".into())
4894 .with_telemetry_id("loadable-stub".into());
4895 let mut loadable_session_ids = Vec::new();
4896
4897 for _ in 0..7 {
4898 loadable_session_ids.push(open_generating_thread_with_loadable_connection(
4899 &panel,
4900 &loadable_connection,
4901 &mut cx,
4902 ));
4903 }
4904
4905 let base_time = Instant::now();
4906
4907 for session_id in loadable_session_ids.iter().take(6) {
4908 loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
4909 }
4910 cx.run_until_parked();
4911
4912 panel.update(&mut cx, |panel, cx| {
4913 for (index, session_id) in loadable_session_ids.iter().take(6).enumerate() {
4914 let conversation_view = panel
4915 .background_threads
4916 .get(session_id)
4917 .expect("background thread should exist")
4918 .clone();
4919 conversation_view.update(cx, |view, cx| {
4920 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
4921 });
4922 }
4923 panel.cleanup_background_threads(cx);
4924 });
4925
4926 panel.read_with(&cx, |panel, _cx| {
4927 assert_eq!(
4928 panel.background_threads.len(),
4929 6,
4930 "cleanup should keep the non-loadable idle thread in addition to five loadable ones"
4931 );
4932 assert!(
4933 panel
4934 .background_threads
4935 .contains_key(&non_loadable_session_id),
4936 "idle non-loadable background threads should not be cleanup candidates"
4937 );
4938 assert!(
4939 !panel
4940 .background_threads
4941 .contains_key(&loadable_session_ids[0]),
4942 "oldest idle loadable background thread should still be removed"
4943 );
4944 for session_id in &loadable_session_ids[1..6] {
4945 assert!(
4946 panel.background_threads.contains_key(session_id),
4947 "more recent idle loadable background threads should be retained"
4948 );
4949 }
4950 assert!(
4951 !panel
4952 .background_threads
4953 .contains_key(&loadable_session_ids[6]),
4954 "the active loadable thread should not also be stored as a background thread"
4955 );
4956 });
4957 }
4958
4959 #[gpui::test]
4960 async fn test_thread_target_local_project(cx: &mut TestAppContext) {
4961 init_test(cx);
4962 cx.update(|cx| {
4963 cx.update_flags(true, vec!["agent-v2".to_string()]);
4964 agent::ThreadStore::init_global(cx);
4965 language_model::LanguageModelRegistry::test(cx);
4966 });
4967
4968 let fs = FakeFs::new(cx.executor());
4969 fs.insert_tree(
4970 "/project",
4971 json!({
4972 ".git": {},
4973 "src": {
4974 "main.rs": "fn main() {}"
4975 }
4976 }),
4977 )
4978 .await;
4979 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
4980
4981 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
4982
4983 let multi_workspace =
4984 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4985
4986 let workspace = multi_workspace
4987 .read_with(cx, |multi_workspace, _cx| {
4988 multi_workspace.workspace().clone()
4989 })
4990 .unwrap();
4991
4992 workspace.update(cx, |workspace, _cx| {
4993 workspace.set_random_database_id();
4994 });
4995
4996 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4997
4998 // Wait for the project to discover the git repository.
4999 cx.run_until_parked();
5000
5001 let panel = workspace.update_in(cx, |workspace, window, cx| {
5002 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5003 workspace.add_panel(panel.clone(), window, cx);
5004 panel
5005 });
5006
5007 cx.run_until_parked();
5008
5009 // Default thread target should be LocalProject.
5010 panel.read_with(cx, |panel, _cx| {
5011 assert_eq!(
5012 *panel.start_thread_in(),
5013 StartThreadIn::LocalProject,
5014 "default thread target should be LocalProject"
5015 );
5016 });
5017
5018 // Start a new thread with the default LocalProject target.
5019 // Use StubAgentServer so the thread connects immediately in tests.
5020 panel.update_in(cx, |panel, window, cx| {
5021 panel.open_external_thread_with_server(
5022 Rc::new(StubAgentServer::default_response()),
5023 window,
5024 cx,
5025 );
5026 });
5027
5028 cx.run_until_parked();
5029
5030 // MultiWorkspace should still have exactly one workspace (no worktree created).
5031 multi_workspace
5032 .read_with(cx, |multi_workspace, _cx| {
5033 assert_eq!(
5034 multi_workspace.len(),
5035 1,
5036 "LocalProject should not create a new workspace"
5037 );
5038 })
5039 .unwrap();
5040
5041 // The thread should be active in the panel.
5042 panel.read_with(cx, |panel, cx| {
5043 assert!(
5044 panel.active_agent_thread(cx).is_some(),
5045 "a thread should be running in the current workspace"
5046 );
5047 });
5048
5049 // The thread target should still be LocalProject (unchanged).
5050 panel.read_with(cx, |panel, _cx| {
5051 assert_eq!(
5052 *panel.start_thread_in(),
5053 StartThreadIn::LocalProject,
5054 "thread target should remain LocalProject"
5055 );
5056 });
5057
5058 // No worktree creation status should be set.
5059 panel.read_with(cx, |panel, _cx| {
5060 assert!(
5061 panel.worktree_creation_status.is_none(),
5062 "no worktree creation should have occurred"
5063 );
5064 });
5065 }
5066
5067 #[gpui::test]
5068 async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
5069 init_test(cx);
5070 cx.update(|cx| {
5071 cx.update_flags(true, vec!["agent-v2".to_string()]);
5072 agent::ThreadStore::init_global(cx);
5073 language_model::LanguageModelRegistry::test(cx);
5074 });
5075
5076 let fs = FakeFs::new(cx.executor());
5077 fs.insert_tree(
5078 "/project",
5079 json!({
5080 ".git": {},
5081 "src": {
5082 "main.rs": "fn main() {}"
5083 }
5084 }),
5085 )
5086 .await;
5087 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5088
5089 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5090
5091 let multi_workspace =
5092 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5093
5094 let workspace = multi_workspace
5095 .read_with(cx, |multi_workspace, _cx| {
5096 multi_workspace.workspace().clone()
5097 })
5098 .unwrap();
5099
5100 workspace.update(cx, |workspace, _cx| {
5101 workspace.set_random_database_id();
5102 });
5103
5104 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5105
5106 // Wait for the project to discover the git repository.
5107 cx.run_until_parked();
5108
5109 let panel = workspace.update_in(cx, |workspace, window, cx| {
5110 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5111 workspace.add_panel(panel.clone(), window, cx);
5112 panel
5113 });
5114
5115 cx.run_until_parked();
5116
5117 // Default should be LocalProject.
5118 panel.read_with(cx, |panel, _cx| {
5119 assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
5120 });
5121
5122 // Change thread target to NewWorktree.
5123 panel.update_in(cx, |panel, window, cx| {
5124 panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
5125 });
5126
5127 panel.read_with(cx, |panel, _cx| {
5128 assert_eq!(
5129 *panel.start_thread_in(),
5130 StartThreadIn::NewWorktree,
5131 "thread target should be NewWorktree after set_thread_target"
5132 );
5133 });
5134
5135 // Let serialization complete.
5136 cx.run_until_parked();
5137
5138 // Load a fresh panel from the serialized data.
5139 let async_cx = cx.update(|window, cx| window.to_async(cx));
5140 let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
5141 .await
5142 .expect("panel load should succeed");
5143 cx.run_until_parked();
5144
5145 loaded_panel.read_with(cx, |panel, _cx| {
5146 assert_eq!(
5147 *panel.start_thread_in(),
5148 StartThreadIn::NewWorktree,
5149 "thread target should survive serialization round-trip"
5150 );
5151 });
5152 }
5153
5154 #[gpui::test]
5155 async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
5156 init_test(cx);
5157
5158 let fs = FakeFs::new(cx.executor());
5159 cx.update(|cx| {
5160 cx.update_flags(true, vec!["agent-v2".to_string()]);
5161 agent::ThreadStore::init_global(cx);
5162 language_model::LanguageModelRegistry::test(cx);
5163 <dyn fs::Fs>::set_global(fs.clone(), cx);
5164 });
5165
5166 fs.insert_tree(
5167 "/project",
5168 json!({
5169 ".git": {},
5170 "src": {
5171 "main.rs": "fn main() {}"
5172 }
5173 }),
5174 )
5175 .await;
5176
5177 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5178
5179 let multi_workspace =
5180 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5181
5182 let workspace = multi_workspace
5183 .read_with(cx, |multi_workspace, _cx| {
5184 multi_workspace.workspace().clone()
5185 })
5186 .unwrap();
5187
5188 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5189
5190 let panel = workspace.update_in(cx, |workspace, window, cx| {
5191 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5192 workspace.add_panel(panel.clone(), window, cx);
5193 panel
5194 });
5195
5196 cx.run_until_parked();
5197
5198 // Simulate worktree creation in progress and reset to Uninitialized
5199 panel.update_in(cx, |panel, window, cx| {
5200 panel.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
5201 panel.active_view = ActiveView::Uninitialized;
5202 Panel::set_active(panel, true, window, cx);
5203 assert!(
5204 matches!(panel.active_view, ActiveView::Uninitialized),
5205 "set_active should not create a thread while worktree is being created"
5206 );
5207 });
5208
5209 // Clear the creation status and use open_external_thread_with_server
5210 // (which bypasses new_agent_thread) to verify the panel can transition
5211 // out of Uninitialized. We can't call set_active directly because
5212 // new_agent_thread requires full agent server infrastructure.
5213 panel.update_in(cx, |panel, window, cx| {
5214 panel.worktree_creation_status = None;
5215 panel.active_view = ActiveView::Uninitialized;
5216 panel.open_external_thread_with_server(
5217 Rc::new(StubAgentServer::default_response()),
5218 window,
5219 cx,
5220 );
5221 });
5222
5223 cx.run_until_parked();
5224
5225 panel.read_with(cx, |panel, _cx| {
5226 assert!(
5227 !matches!(panel.active_view, ActiveView::Uninitialized),
5228 "panel should transition out of Uninitialized once worktree creation is cleared"
5229 );
5230 });
5231 }
5232
5233 #[test]
5234 fn test_deserialize_agent_variants() {
5235 // PascalCase (legacy AgentType format, persisted in panel state)
5236 assert_eq!(
5237 serde_json::from_str::<Agent>(r#""NativeAgent""#).unwrap(),
5238 Agent::NativeAgent,
5239 );
5240 assert_eq!(
5241 serde_json::from_str::<Agent>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
5242 Agent::Custom {
5243 id: "my-agent".into(),
5244 },
5245 );
5246
5247 // Legacy TextThread variant deserializes to NativeAgent
5248 assert_eq!(
5249 serde_json::from_str::<Agent>(r#""TextThread""#).unwrap(),
5250 Agent::NativeAgent,
5251 );
5252
5253 // snake_case (canonical format)
5254 assert_eq!(
5255 serde_json::from_str::<Agent>(r#""native_agent""#).unwrap(),
5256 Agent::NativeAgent,
5257 );
5258 assert_eq!(
5259 serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
5260 Agent::Custom {
5261 id: "my-agent".into(),
5262 },
5263 );
5264
5265 // Serialization uses snake_case
5266 assert_eq!(
5267 serde_json::to_string(&Agent::NativeAgent).unwrap(),
5268 r#""native_agent""#,
5269 );
5270 assert_eq!(
5271 serde_json::to_string(&Agent::Custom {
5272 id: "my-agent".into()
5273 })
5274 .unwrap(),
5275 r#"{"custom":{"name":"my-agent"}}"#,
5276 );
5277 }
5278
5279 #[gpui::test]
5280 async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
5281 init_test(cx);
5282
5283 let app_state = cx.update(|cx| {
5284 cx.update_flags(true, vec!["agent-v2".to_string()]);
5285 agent::ThreadStore::init_global(cx);
5286 language_model::LanguageModelRegistry::test(cx);
5287
5288 let app_state = workspace::AppState::test(cx);
5289 workspace::init(app_state.clone(), cx);
5290 app_state
5291 });
5292
5293 let fs = app_state.fs.as_fake();
5294 fs.insert_tree(
5295 "/project",
5296 json!({
5297 ".git": {},
5298 "src": {
5299 "main.rs": "fn main() {}"
5300 }
5301 }),
5302 )
5303 .await;
5304 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5305
5306 let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
5307
5308 let multi_workspace =
5309 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5310
5311 let workspace = multi_workspace
5312 .read_with(cx, |multi_workspace, _cx| {
5313 multi_workspace.workspace().clone()
5314 })
5315 .unwrap();
5316
5317 workspace.update(cx, |workspace, _cx| {
5318 workspace.set_random_database_id();
5319 });
5320
5321 // Register a callback so new workspaces also get an AgentPanel.
5322 cx.update(|cx| {
5323 cx.observe_new(
5324 |workspace: &mut Workspace,
5325 window: Option<&mut Window>,
5326 cx: &mut Context<Workspace>| {
5327 if let Some(window) = window {
5328 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5329 workspace.add_panel(panel, window, cx);
5330 }
5331 },
5332 )
5333 .detach();
5334 });
5335
5336 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5337
5338 // Wait for the project to discover the git repository.
5339 cx.run_until_parked();
5340
5341 let panel = workspace.update_in(cx, |workspace, window, cx| {
5342 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5343 workspace.add_panel(panel.clone(), window, cx);
5344 panel
5345 });
5346
5347 cx.run_until_parked();
5348
5349 // Open a thread (needed so there's an active thread view).
5350 panel.update_in(cx, |panel, window, cx| {
5351 panel.open_external_thread_with_server(
5352 Rc::new(StubAgentServer::default_response()),
5353 window,
5354 cx,
5355 );
5356 });
5357
5358 cx.run_until_parked();
5359
5360 // Set the selected agent to Codex (a custom agent) and start_thread_in
5361 // to NewWorktree. We do this AFTER opening the thread because
5362 // open_external_thread_with_server overrides selected_agent.
5363 panel.update_in(cx, |panel, window, cx| {
5364 panel.selected_agent = Agent::Custom {
5365 id: CODEX_ID.into(),
5366 };
5367 panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
5368 });
5369
5370 // Verify the panel has the Codex agent selected.
5371 panel.read_with(cx, |panel, _cx| {
5372 assert_eq!(
5373 panel.selected_agent,
5374 Agent::Custom {
5375 id: CODEX_ID.into()
5376 },
5377 );
5378 });
5379
5380 // Directly call handle_worktree_creation_requested, which is what
5381 // handle_first_send_requested does when start_thread_in == NewWorktree.
5382 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
5383 "Hello from test",
5384 ))];
5385 panel.update_in(cx, |panel, window, cx| {
5386 panel.handle_worktree_creation_requested(content, window, cx);
5387 });
5388
5389 // Let the async worktree creation + workspace setup complete.
5390 cx.run_until_parked();
5391
5392 // Find the new workspace's AgentPanel and verify it used the Codex agent.
5393 let found_codex = multi_workspace
5394 .read_with(cx, |multi_workspace, cx| {
5395 // There should be more than one workspace now (the original + the new worktree).
5396 assert!(
5397 multi_workspace.len() > 1,
5398 "expected a new workspace to have been created, found {}",
5399 multi_workspace.len(),
5400 );
5401
5402 // Check the newest workspace's panel for the correct agent.
5403 let new_workspace = multi_workspace
5404 .workspaces()
5405 .find(|ws| *ws != workspace)
5406 .expect("should find the new workspace");
5407 let new_panel = new_workspace
5408 .read(cx)
5409 .panel::<AgentPanel>(cx)
5410 .expect("new workspace should have an AgentPanel");
5411
5412 new_panel.read(cx).selected_agent.clone()
5413 })
5414 .unwrap();
5415
5416 assert_eq!(
5417 found_codex,
5418 Agent::Custom {
5419 id: CODEX_ID.into()
5420 },
5421 "the new worktree workspace should use the same agent (Codex) that was selected in the original panel",
5422 );
5423 }
5424
5425 #[gpui::test]
5426 async fn test_work_dirs_update_when_worktrees_change(cx: &mut TestAppContext) {
5427 use crate::thread_metadata_store::ThreadMetadataStore;
5428
5429 init_test(cx);
5430 cx.update(|cx| {
5431 cx.update_flags(true, vec!["agent-v2".to_string()]);
5432 agent::ThreadStore::init_global(cx);
5433 language_model::LanguageModelRegistry::test(cx);
5434 });
5435
5436 // Set up a project with one worktree.
5437 let fs = FakeFs::new(cx.executor());
5438 fs.insert_tree("/project_a", json!({ "file.txt": "" }))
5439 .await;
5440 let project = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
5441
5442 let multi_workspace =
5443 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5444 let workspace = multi_workspace
5445 .read_with(cx, |mw, _cx| mw.workspace().clone())
5446 .unwrap();
5447 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
5448
5449 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
5450 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5451 });
5452
5453 // Open thread A and send a message. With empty next_prompt_updates it
5454 // stays generating, so opening B will move A to background_threads.
5455 let connection_a = StubAgentConnection::new().with_agent_id("agent-a".into());
5456 open_thread_with_custom_connection(&panel, connection_a.clone(), &mut cx);
5457 send_message(&panel, &mut cx);
5458 let session_id_a = active_session_id(&panel, &cx);
5459
5460 // Open thread C — thread A (generating) moves to background.
5461 // Thread C completes immediately (idle), then opening B moves C to background too.
5462 let connection_c = StubAgentConnection::new().with_agent_id("agent-c".into());
5463 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5464 acp::ContentChunk::new("done".into()),
5465 )]);
5466 open_thread_with_custom_connection(&panel, connection_c.clone(), &mut cx);
5467 send_message(&panel, &mut cx);
5468 let session_id_c = active_session_id(&panel, &cx);
5469
5470 // Open thread B — thread C (idle, non-loadable) is retained in background.
5471 let connection_b = StubAgentConnection::new().with_agent_id("agent-b".into());
5472 open_thread_with_custom_connection(&panel, connection_b.clone(), &mut cx);
5473 send_message(&panel, &mut cx);
5474 let session_id_b = active_session_id(&panel, &cx);
5475
5476 let metadata_store = cx.update(|_, cx| ThreadMetadataStore::global(cx));
5477
5478 panel.read_with(&cx, |panel, _cx| {
5479 assert!(
5480 panel.background_threads.contains_key(&session_id_a),
5481 "Thread A should be in background_threads"
5482 );
5483 assert!(
5484 panel.background_threads.contains_key(&session_id_c),
5485 "Thread C should be in background_threads"
5486 );
5487 });
5488
5489 // Verify initial work_dirs for thread B contain only /project_a.
5490 let initial_b_paths = panel.read_with(&cx, |panel, cx| {
5491 let thread = panel.active_agent_thread(cx).unwrap();
5492 thread.read(cx).work_dirs().cloned().unwrap()
5493 });
5494 assert_eq!(
5495 initial_b_paths.ordered_paths().collect::<Vec<_>>(),
5496 vec![&PathBuf::from("/project_a")],
5497 "Thread B should initially have only /project_a"
5498 );
5499
5500 // Now add a second worktree to the project.
5501 fs.insert_tree("/project_b", json!({ "other.txt": "" }))
5502 .await;
5503 let (new_tree, _) = project
5504 .update(&mut cx, |project, cx| {
5505 project.find_or_create_worktree("/project_b", true, cx)
5506 })
5507 .await
5508 .unwrap();
5509 cx.read(|cx| new_tree.read(cx).as_local().unwrap().scan_complete())
5510 .await;
5511 cx.run_until_parked();
5512
5513 // Verify thread B's (active) work_dirs now include both worktrees.
5514 let updated_b_paths = panel.read_with(&cx, |panel, cx| {
5515 let thread = panel.active_agent_thread(cx).unwrap();
5516 thread.read(cx).work_dirs().cloned().unwrap()
5517 });
5518 let mut b_paths_sorted = updated_b_paths.ordered_paths().cloned().collect::<Vec<_>>();
5519 b_paths_sorted.sort();
5520 assert_eq!(
5521 b_paths_sorted,
5522 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
5523 "Thread B work_dirs should include both worktrees after adding /project_b"
5524 );
5525
5526 // Verify thread A's (background) work_dirs are also updated.
5527 let updated_a_paths = panel.read_with(&cx, |panel, cx| {
5528 let bg_view = panel.background_threads.get(&session_id_a).unwrap();
5529 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
5530 root_thread
5531 .read(cx)
5532 .thread
5533 .read(cx)
5534 .work_dirs()
5535 .cloned()
5536 .unwrap()
5537 });
5538 let mut a_paths_sorted = updated_a_paths.ordered_paths().cloned().collect::<Vec<_>>();
5539 a_paths_sorted.sort();
5540 assert_eq!(
5541 a_paths_sorted,
5542 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
5543 "Thread A work_dirs should include both worktrees after adding /project_b"
5544 );
5545
5546 // Verify thread idle C was also updated.
5547 let updated_c_paths = panel.read_with(&cx, |panel, cx| {
5548 let bg_view = panel.background_threads.get(&session_id_c).unwrap();
5549 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
5550 root_thread
5551 .read(cx)
5552 .thread
5553 .read(cx)
5554 .work_dirs()
5555 .cloned()
5556 .unwrap()
5557 });
5558 let mut c_paths_sorted = updated_c_paths.ordered_paths().cloned().collect::<Vec<_>>();
5559 c_paths_sorted.sort();
5560 assert_eq!(
5561 c_paths_sorted,
5562 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
5563 "Thread C (idle background) work_dirs should include both worktrees after adding /project_b"
5564 );
5565
5566 // Verify the metadata store reflects the new paths for running threads only.
5567 cx.run_until_parked();
5568 for (label, session_id) in [("thread B", &session_id_b), ("thread A", &session_id_a)] {
5569 let metadata_paths = metadata_store.read_with(&cx, |store, _cx| {
5570 let metadata = store
5571 .entry(session_id)
5572 .unwrap_or_else(|| panic!("{label} thread metadata should exist"));
5573 metadata.folder_paths.clone()
5574 });
5575 let mut sorted = metadata_paths.ordered_paths().cloned().collect::<Vec<_>>();
5576 sorted.sort();
5577 assert_eq!(
5578 sorted,
5579 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
5580 "{label} thread metadata folder_paths should include both worktrees"
5581 );
5582 }
5583
5584 // Now remove a worktree and verify work_dirs shrink.
5585 let worktree_b_id = new_tree.read_with(&cx, |tree, _| tree.id());
5586 project.update(&mut cx, |project, cx| {
5587 project.remove_worktree(worktree_b_id, cx);
5588 });
5589 cx.run_until_parked();
5590
5591 let after_remove_b = panel.read_with(&cx, |panel, cx| {
5592 let thread = panel.active_agent_thread(cx).unwrap();
5593 thread.read(cx).work_dirs().cloned().unwrap()
5594 });
5595 assert_eq!(
5596 after_remove_b.ordered_paths().collect::<Vec<_>>(),
5597 vec![&PathBuf::from("/project_a")],
5598 "Thread B work_dirs should revert to only /project_a after removing /project_b"
5599 );
5600
5601 let after_remove_a = panel.read_with(&cx, |panel, cx| {
5602 let bg_view = panel.background_threads.get(&session_id_a).unwrap();
5603 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
5604 root_thread
5605 .read(cx)
5606 .thread
5607 .read(cx)
5608 .work_dirs()
5609 .cloned()
5610 .unwrap()
5611 });
5612 assert_eq!(
5613 after_remove_a.ordered_paths().collect::<Vec<_>>(),
5614 vec![&PathBuf::from("/project_a")],
5615 "Thread A work_dirs should revert to only /project_a after removing /project_b"
5616 );
5617 }
5618}