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