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