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