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