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 })
1865 });
1866 }
1867
1868 self.new_thread(&NewThread, window, cx);
1869 if let Some((thread, model)) = self
1870 .active_native_agent_thread(cx)
1871 .zip(provider.default_model(cx))
1872 {
1873 thread.update(cx, |thread, cx| {
1874 thread.set_model(model, cx);
1875 });
1876 }
1877 }
1878 }
1879 }
1880
1881 pub fn workspace_id(&self) -> Option<WorkspaceId> {
1882 self.workspace_id
1883 }
1884
1885 pub fn background_threads(&self) -> &HashMap<acp::SessionId, Entity<ConversationView>> {
1886 &self.background_threads
1887 }
1888
1889 pub fn active_conversation_view(&self) -> Option<&Entity<ConversationView>> {
1890 match &self.active_view {
1891 ActiveView::AgentThread { conversation_view } => Some(conversation_view),
1892 _ => None,
1893 }
1894 }
1895
1896 pub fn active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
1897 let server_view = self.active_conversation_view()?;
1898 server_view.read(cx).active_thread().cloned()
1899 }
1900
1901 pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
1902 match &self.active_view {
1903 ActiveView::AgentThread {
1904 conversation_view, ..
1905 } => conversation_view
1906 .read(cx)
1907 .active_thread()
1908 .map(|r| r.read(cx).thread.clone()),
1909 _ => None,
1910 }
1911 }
1912
1913 /// Returns the primary thread views for all retained connections: the
1914 pub fn is_background_thread(&self, session_id: &acp::SessionId) -> bool {
1915 self.background_threads.contains_key(session_id)
1916 }
1917
1918 pub fn cancel_thread(&self, session_id: &acp::SessionId, cx: &mut Context<Self>) -> bool {
1919 let conversation_views = self
1920 .active_conversation_view()
1921 .into_iter()
1922 .chain(self.background_threads.values());
1923
1924 for conversation_view in conversation_views {
1925 if let Some(thread_view) = conversation_view.read(cx).thread_view(session_id) {
1926 thread_view.update(cx, |view, cx| view.cancel_generation(cx));
1927 return true;
1928 }
1929 }
1930 false
1931 }
1932
1933 /// active thread plus any background threads that are still running or
1934 /// completed but unseen.
1935 pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
1936 let mut views = Vec::new();
1937
1938 if let Some(server_view) = self.active_conversation_view() {
1939 if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
1940 views.push(thread_view);
1941 }
1942 }
1943
1944 for server_view in self.background_threads.values() {
1945 if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
1946 views.push(thread_view);
1947 }
1948 }
1949
1950 views
1951 }
1952
1953 fn update_thread_work_dirs(&self, cx: &mut Context<Self>) {
1954 let new_work_dirs = self.project.read(cx).default_path_list(cx);
1955
1956 if let Some(conversation_view) = self.active_conversation_view() {
1957 conversation_view.update(cx, |conversation_view, cx| {
1958 conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
1959 });
1960 }
1961
1962 for conversation_view in self.background_threads.values() {
1963 conversation_view.update(cx, |conversation_view, cx| {
1964 conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
1965 });
1966 }
1967 }
1968
1969 fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context<Self>) {
1970 let ActiveView::AgentThread { conversation_view } = old_view else {
1971 return;
1972 };
1973
1974 let Some(thread_view) = conversation_view.read(cx).root_thread(cx) else {
1975 return;
1976 };
1977
1978 if thread_view.read(cx).thread.read(cx).entries().is_empty() {
1979 return;
1980 }
1981
1982 self.background_threads
1983 .insert(thread_view.read(cx).id.clone(), conversation_view);
1984 self.cleanup_background_threads(cx);
1985 }
1986
1987 /// We keep threads that are:
1988 /// - Still running
1989 /// - Do not support reloading the full session
1990 /// - Have had the most recent events (up to 5 idle threads)
1991 fn cleanup_background_threads(&mut self, cx: &App) {
1992 let mut potential_removals = self
1993 .background_threads
1994 .iter()
1995 .filter(|(_id, view)| {
1996 let Some(thread_view) = view.read(cx).root_thread(cx) else {
1997 return true;
1998 };
1999 let thread = thread_view.read(cx).thread.read(cx);
2000 thread.connection().supports_load_session() && thread.status() == ThreadStatus::Idle
2001 })
2002 .collect::<Vec<_>>();
2003
2004 const MAX_IDLE_BACKGROUND_THREADS: usize = 5;
2005
2006 potential_removals.sort_unstable_by_key(|(_, view)| view.read(cx).updated_at(cx));
2007 let n = potential_removals
2008 .len()
2009 .saturating_sub(MAX_IDLE_BACKGROUND_THREADS);
2010 let to_remove = potential_removals
2011 .into_iter()
2012 .map(|(id, _)| id.clone())
2013 .take(n)
2014 .collect::<Vec<_>>();
2015 for id in to_remove {
2016 self.background_threads.remove(&id);
2017 }
2018 }
2019
2020 pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
2021 match &self.active_view {
2022 ActiveView::AgentThread {
2023 conversation_view, ..
2024 } => conversation_view.read(cx).as_native_thread(cx),
2025 _ => None,
2026 }
2027 }
2028
2029 fn set_active_view(
2030 &mut self,
2031 new_view: ActiveView,
2032 focus: bool,
2033 window: &mut Window,
2034 cx: &mut Context<Self>,
2035 ) {
2036 let was_in_agent_history = matches!(self.active_view, ActiveView::History { .. });
2037 let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized);
2038 let current_is_history = matches!(self.active_view, ActiveView::History { .. });
2039 let new_is_history = matches!(new_view, ActiveView::History { .. });
2040
2041 let current_is_config = matches!(self.active_view, ActiveView::Configuration);
2042 let new_is_config = matches!(new_view, ActiveView::Configuration);
2043
2044 let current_is_overlay = current_is_history || current_is_config;
2045 let new_is_overlay = new_is_history || new_is_config;
2046
2047 if current_is_uninitialized || (current_is_overlay && !new_is_overlay) {
2048 self.active_view = new_view;
2049 } else if !current_is_overlay && new_is_overlay {
2050 self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
2051 } else {
2052 let old_view = std::mem::replace(&mut self.active_view, new_view);
2053 if !new_is_overlay {
2054 if let Some(previous) = self.previous_view.take() {
2055 self.retain_running_thread(previous, cx);
2056 }
2057 }
2058 self.retain_running_thread(old_view, cx);
2059 }
2060
2061 // Subscribe to the active ThreadView's events (e.g. FirstSendRequested)
2062 // so the panel can intercept the first send for worktree creation.
2063 // Re-subscribe whenever the ConnectionView changes, since the inner
2064 // ThreadView may have been replaced (e.g. navigating between threads).
2065 self._active_view_observation = match &self.active_view {
2066 ActiveView::AgentThread { conversation_view } => {
2067 self._thread_view_subscription =
2068 Self::subscribe_to_active_thread_view(conversation_view, window, cx);
2069 let focus_handle = conversation_view.focus_handle(cx);
2070 self._active_thread_focus_subscription =
2071 Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| {
2072 cx.emit(AgentPanelEvent::ThreadFocused);
2073 cx.notify();
2074 }));
2075 Some(cx.observe_in(
2076 conversation_view,
2077 window,
2078 |this, server_view, window, cx| {
2079 this._thread_view_subscription =
2080 Self::subscribe_to_active_thread_view(&server_view, window, cx);
2081 cx.emit(AgentPanelEvent::ActiveViewChanged);
2082 this.serialize(cx);
2083 cx.notify();
2084 },
2085 ))
2086 }
2087 _ => {
2088 self._thread_view_subscription = None;
2089 self._active_thread_focus_subscription = None;
2090 None
2091 }
2092 };
2093
2094 if let ActiveView::History { view } = &self.active_view {
2095 if !was_in_agent_history {
2096 view.update(cx, |view, cx| {
2097 view.history()
2098 .update(cx, |history, cx| history.refresh_full_history(cx))
2099 });
2100 }
2101 }
2102
2103 if focus {
2104 self.focus_handle(cx).focus(window, cx);
2105 }
2106 cx.emit(AgentPanelEvent::ActiveViewChanged);
2107 }
2108
2109 fn populate_recently_updated_menu_section(
2110 mut menu: ContextMenu,
2111 panel: Entity<Self>,
2112 view: Entity<ThreadHistoryView>,
2113 cx: &mut Context<ContextMenu>,
2114 ) -> ContextMenu {
2115 let entries = view
2116 .read(cx)
2117 .history()
2118 .read(cx)
2119 .sessions()
2120 .iter()
2121 .take(RECENTLY_UPDATED_MENU_LIMIT)
2122 .cloned()
2123 .collect::<Vec<_>>();
2124
2125 if entries.is_empty() {
2126 return menu;
2127 }
2128
2129 menu = menu.header("Recently Updated");
2130
2131 for entry in entries {
2132 let title = entry
2133 .title
2134 .as_ref()
2135 .filter(|title| !title.is_empty())
2136 .cloned()
2137 .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
2138
2139 menu = menu.entry(title, None, {
2140 let panel = panel.downgrade();
2141 let entry = entry.clone();
2142 move |window, cx| {
2143 let entry = entry.clone();
2144 panel
2145 .update(cx, move |this, cx| {
2146 if let Some(agent) = this.selected_agent() {
2147 this.load_agent_thread(
2148 agent,
2149 entry.session_id.clone(),
2150 entry.work_dirs.clone(),
2151 entry.title.clone(),
2152 true,
2153 window,
2154 cx,
2155 );
2156 }
2157 })
2158 .ok();
2159 }
2160 });
2161 }
2162
2163 menu.separator()
2164 }
2165
2166 fn subscribe_to_active_thread_view(
2167 server_view: &Entity<ConversationView>,
2168 window: &mut Window,
2169 cx: &mut Context<Self>,
2170 ) -> Option<Subscription> {
2171 server_view.read(cx).active_thread().cloned().map(|tv| {
2172 cx.subscribe_in(
2173 &tv,
2174 window,
2175 |this, view, event: &AcpThreadViewEvent, window, cx| match event {
2176 AcpThreadViewEvent::FirstSendRequested { content } => {
2177 this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
2178 }
2179 AcpThreadViewEvent::MessageSentOrQueued => {
2180 let session_id = view.read(cx).thread.read(cx).session_id().clone();
2181 cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id });
2182 }
2183 },
2184 )
2185 })
2186 }
2187
2188 pub fn start_thread_in(&self) -> &StartThreadIn {
2189 &self.start_thread_in
2190 }
2191
2192 fn set_start_thread_in(
2193 &mut self,
2194 action: &StartThreadIn,
2195 window: &mut Window,
2196 cx: &mut Context<Self>,
2197 ) {
2198 let new_target = match action {
2199 StartThreadIn::LocalProject => StartThreadIn::LocalProject,
2200 StartThreadIn::NewWorktree { .. } => {
2201 if !self.project_has_git_repository(cx) {
2202 log::error!(
2203 "set_start_thread_in: cannot use worktree mode without a git repository"
2204 );
2205 return;
2206 }
2207 if self.project.read(cx).is_via_collab() {
2208 log::error!(
2209 "set_start_thread_in: cannot use worktree mode in a collab project"
2210 );
2211 return;
2212 }
2213 action.clone()
2214 }
2215 StartThreadIn::LinkedWorktree { .. } => {
2216 if !self.project_has_git_repository(cx) {
2217 log::error!(
2218 "set_start_thread_in: cannot use LinkedWorktree without a git repository"
2219 );
2220 return;
2221 }
2222 if self.project.read(cx).is_via_collab() {
2223 log::error!(
2224 "set_start_thread_in: cannot use LinkedWorktree in a collab project"
2225 );
2226 return;
2227 }
2228 action.clone()
2229 }
2230 };
2231 self.start_thread_in = new_target;
2232 if let Some(thread) = self.active_thread_view(cx) {
2233 thread.update(cx, |thread, cx| thread.focus_handle(cx).focus(window, cx));
2234 }
2235 self.serialize(cx);
2236 cx.notify();
2237 }
2238
2239 fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2240 let next = match &self.start_thread_in {
2241 StartThreadIn::LocalProject => StartThreadIn::NewWorktree {
2242 worktree_name: None,
2243 branch_target: NewWorktreeBranchTarget::default(),
2244 },
2245 StartThreadIn::NewWorktree { .. } | StartThreadIn::LinkedWorktree { .. } => {
2246 StartThreadIn::LocalProject
2247 }
2248 };
2249 self.set_start_thread_in(&next, window, cx);
2250 }
2251
2252 fn reset_start_thread_in_to_default(&mut self, cx: &mut Context<Self>) {
2253 use settings::{NewThreadLocation, Settings};
2254 let default = AgentSettings::get_global(cx).new_thread_location;
2255 let start_thread_in = match default {
2256 NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
2257 NewThreadLocation::NewWorktree => {
2258 if self.project_has_git_repository(cx) {
2259 StartThreadIn::NewWorktree {
2260 worktree_name: None,
2261 branch_target: NewWorktreeBranchTarget::default(),
2262 }
2263 } else {
2264 StartThreadIn::LocalProject
2265 }
2266 }
2267 };
2268 if self.start_thread_in != start_thread_in {
2269 self.start_thread_in = start_thread_in;
2270 self.serialize(cx);
2271 cx.notify();
2272 }
2273 }
2274
2275 fn sync_start_thread_in_with_git_state(&mut self, cx: &mut Context<Self>) {
2276 if matches!(self.start_thread_in, StartThreadIn::LocalProject) {
2277 return;
2278 }
2279
2280 let visible_worktree_paths: Vec<_> = self
2281 .project
2282 .read(cx)
2283 .visible_worktrees(cx)
2284 .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
2285 .collect();
2286 let repositories = self.project.read(cx).repositories(cx);
2287 let linked_worktrees = if repositories.len() > 1 {
2288 Vec::new()
2289 } else {
2290 repositories
2291 .values()
2292 .flat_map(|repo| repo.read(cx).linked_worktrees().iter().cloned())
2293 .filter(|worktree| !visible_worktree_paths.contains(&worktree.path))
2294 .collect::<Vec<_>>()
2295 };
2296
2297 let updated_start_thread_in = match &self.start_thread_in {
2298 StartThreadIn::NewWorktree {
2299 worktree_name: Some(worktree_name),
2300 branch_target,
2301 } => {
2302 let normalized_worktree_name = worktree_name.replace(' ', "-");
2303 linked_worktrees
2304 .iter()
2305 .find(|worktree| {
2306 worktree.display_name() == normalized_worktree_name
2307 && self.linked_worktree_matches_branch_target(
2308 worktree,
2309 branch_target,
2310 cx,
2311 )
2312 })
2313 .map(|worktree| StartThreadIn::LinkedWorktree {
2314 path: worktree.path.clone(),
2315 display_name: worktree.display_name().to_string(),
2316 })
2317 }
2318 StartThreadIn::LinkedWorktree { path, .. } => linked_worktrees
2319 .iter()
2320 .find(|worktree| worktree.path == *path)
2321 .map(|worktree| StartThreadIn::LinkedWorktree {
2322 path: worktree.path.clone(),
2323 display_name: worktree.display_name().to_string(),
2324 })
2325 .or(Some(StartThreadIn::LocalProject)),
2326 _ => None,
2327 };
2328
2329 if let Some(updated_start_thread_in) = updated_start_thread_in {
2330 if self.start_thread_in != updated_start_thread_in {
2331 self.start_thread_in = updated_start_thread_in;
2332 self.serialize(cx);
2333 }
2334 cx.notify();
2335 }
2336 }
2337
2338 fn linked_worktree_matches_branch_target(
2339 &self,
2340 worktree: &git::repository::Worktree,
2341 branch_target: &NewWorktreeBranchTarget,
2342 cx: &App,
2343 ) -> bool {
2344 let active_repository = self.project.read(cx).active_repository(cx);
2345 let current_branch_name = active_repository.as_ref().and_then(|repo| {
2346 repo.read(cx)
2347 .branch
2348 .as_ref()
2349 .map(|branch| branch.name().to_string())
2350 });
2351 let existing_branch_names = active_repository
2352 .as_ref()
2353 .map(|repo| {
2354 repo.read(cx)
2355 .branch_list
2356 .iter()
2357 .map(|branch| branch.name().to_string())
2358 .collect::<HashSet<_>>()
2359 })
2360 .unwrap_or_default();
2361
2362 match branch_target {
2363 NewWorktreeBranchTarget::CurrentBranch => {
2364 current_branch_name.as_deref() == worktree.branch_name()
2365 }
2366 NewWorktreeBranchTarget::ExistingBranch { name } => {
2367 existing_branch_names.contains(name)
2368 && worktree.branch_name() == Some(name.as_str())
2369 }
2370 NewWorktreeBranchTarget::CreateBranch { name, .. } => {
2371 !existing_branch_names.contains(name)
2372 && worktree.branch_name() == Some(name.as_str())
2373 }
2374 }
2375 }
2376
2377 pub(crate) fn selected_agent(&self) -> Option<Agent> {
2378 Some(self.selected_agent.clone())
2379 }
2380
2381 fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
2382 if let Some(extension_store) = ExtensionStore::try_global(cx) {
2383 let (manifests, extensions_dir) = {
2384 let store = extension_store.read(cx);
2385 let installed = store.installed_extensions();
2386 let manifests: Vec<_> = installed
2387 .iter()
2388 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
2389 .collect();
2390 let extensions_dir = paths::extensions_dir().join("installed");
2391 (manifests, extensions_dir)
2392 };
2393
2394 self.project.update(cx, |project, cx| {
2395 project.agent_server_store().update(cx, |store, cx| {
2396 let manifest_refs: Vec<_> = manifests
2397 .iter()
2398 .map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
2399 .collect();
2400 store.sync_extension_agents(manifest_refs, extensions_dir, cx);
2401 });
2402 });
2403 }
2404 }
2405
2406 pub fn new_agent_thread_with_external_source_prompt(
2407 &mut self,
2408 external_source_prompt: Option<ExternalSourcePrompt>,
2409 window: &mut Window,
2410 cx: &mut Context<Self>,
2411 ) {
2412 self.external_thread(
2413 None,
2414 None,
2415 None,
2416 None,
2417 external_source_prompt.map(AgentInitialContent::from),
2418 true,
2419 window,
2420 cx,
2421 );
2422 }
2423
2424 pub fn new_agent_thread(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
2425 self.reset_start_thread_in_to_default(cx);
2426 self.new_agent_thread_inner(agent, true, window, cx);
2427 }
2428
2429 fn new_agent_thread_inner(
2430 &mut self,
2431 agent: Agent,
2432 focus: bool,
2433 window: &mut Window,
2434 cx: &mut Context<Self>,
2435 ) {
2436 let initial_content = self.take_active_draft_initial_content(cx);
2437 self.external_thread(
2438 Some(agent),
2439 None,
2440 None,
2441 None,
2442 initial_content,
2443 focus,
2444 window,
2445 cx,
2446 );
2447 }
2448
2449 pub fn load_agent_thread(
2450 &mut self,
2451 agent: Agent,
2452 session_id: acp::SessionId,
2453 work_dirs: Option<PathList>,
2454 title: Option<SharedString>,
2455 focus: bool,
2456 window: &mut Window,
2457 cx: &mut Context<Self>,
2458 ) {
2459 if let Some(store) = ThreadMetadataStore::try_global(cx) {
2460 store.update(cx, |store, cx| store.unarchive(&session_id, cx));
2461 }
2462
2463 if let Some(conversation_view) = self.background_threads.remove(&session_id) {
2464 self.set_active_view(
2465 ActiveView::AgentThread { conversation_view },
2466 focus,
2467 window,
2468 cx,
2469 );
2470 return;
2471 }
2472
2473 if let ActiveView::AgentThread { conversation_view } = &self.active_view {
2474 if conversation_view
2475 .read(cx)
2476 .active_thread()
2477 .map(|t| t.read(cx).id.clone())
2478 == Some(session_id.clone())
2479 {
2480 cx.emit(AgentPanelEvent::ActiveViewChanged);
2481 return;
2482 }
2483 }
2484
2485 if let Some(ActiveView::AgentThread { conversation_view }) = &self.previous_view {
2486 if conversation_view
2487 .read(cx)
2488 .active_thread()
2489 .map(|t| t.read(cx).id.clone())
2490 == Some(session_id.clone())
2491 {
2492 let view = self.previous_view.take().unwrap();
2493 self.set_active_view(view, focus, window, cx);
2494 return;
2495 }
2496 }
2497
2498 self.external_thread(
2499 Some(agent),
2500 Some(session_id),
2501 work_dirs,
2502 title,
2503 None,
2504 focus,
2505 window,
2506 cx,
2507 );
2508 }
2509
2510 pub(crate) fn create_agent_thread(
2511 &mut self,
2512 server: Rc<dyn AgentServer>,
2513 resume_session_id: Option<acp::SessionId>,
2514 work_dirs: Option<PathList>,
2515 title: Option<SharedString>,
2516 initial_content: Option<AgentInitialContent>,
2517 workspace: WeakEntity<Workspace>,
2518 project: Entity<Project>,
2519 agent: Agent,
2520 focus: bool,
2521 window: &mut Window,
2522 cx: &mut Context<Self>,
2523 ) {
2524 if self.selected_agent != agent {
2525 self.selected_agent = agent.clone();
2526 self.serialize(cx);
2527 }
2528
2529 cx.background_spawn({
2530 let kvp = KeyValueStore::global(cx);
2531 let agent = agent.clone();
2532 async move {
2533 write_global_last_used_agent(kvp, agent).await;
2534 }
2535 })
2536 .detach();
2537
2538 let thread_store = server
2539 .clone()
2540 .downcast::<agent::NativeAgentServer>()
2541 .is_some()
2542 .then(|| self.thread_store.clone());
2543
2544 let connection_store = self.connection_store.clone();
2545
2546 let conversation_view = cx.new(|cx| {
2547 crate::ConversationView::new(
2548 server,
2549 connection_store,
2550 agent,
2551 resume_session_id,
2552 work_dirs,
2553 title,
2554 initial_content,
2555 workspace.clone(),
2556 project,
2557 thread_store,
2558 self.prompt_store.clone(),
2559 window,
2560 cx,
2561 )
2562 });
2563
2564 cx.observe(&conversation_view, |this, server_view, cx| {
2565 let is_active = this
2566 .active_conversation_view()
2567 .is_some_and(|active| active.entity_id() == server_view.entity_id());
2568 if is_active {
2569 cx.emit(AgentPanelEvent::ActiveViewChanged);
2570 this.serialize(cx);
2571 } else {
2572 cx.emit(AgentPanelEvent::BackgroundThreadChanged);
2573 }
2574 cx.notify();
2575 })
2576 .detach();
2577
2578 self.set_active_view(
2579 ActiveView::AgentThread { conversation_view },
2580 focus,
2581 window,
2582 cx,
2583 );
2584 }
2585
2586 fn active_thread_has_messages(&self, cx: &App) -> bool {
2587 self.active_agent_thread(cx)
2588 .is_some_and(|thread| !thread.read(cx).entries().is_empty())
2589 }
2590
2591 pub fn active_thread_is_draft(&self, cx: &App) -> bool {
2592 self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
2593 }
2594
2595 fn handle_first_send_requested(
2596 &mut self,
2597 thread_view: Entity<ThreadView>,
2598 content: Vec<acp::ContentBlock>,
2599 window: &mut Window,
2600 cx: &mut Context<Self>,
2601 ) {
2602 match &self.start_thread_in {
2603 StartThreadIn::NewWorktree {
2604 worktree_name,
2605 branch_target,
2606 } => {
2607 self.handle_worktree_requested(
2608 content,
2609 WorktreeCreationArgs::New {
2610 worktree_name: worktree_name.clone(),
2611 branch_target: branch_target.clone(),
2612 },
2613 window,
2614 cx,
2615 );
2616 }
2617 StartThreadIn::LinkedWorktree { path, .. } => {
2618 self.handle_worktree_requested(
2619 content,
2620 WorktreeCreationArgs::Linked {
2621 worktree_path: path.clone(),
2622 },
2623 window,
2624 cx,
2625 );
2626 }
2627 StartThreadIn::LocalProject => {
2628 cx.defer_in(window, move |_this, window, cx| {
2629 thread_view.update(cx, |thread_view, cx| {
2630 let editor = thread_view.message_editor.clone();
2631 thread_view.send_impl(editor, window, cx);
2632 });
2633 });
2634 }
2635 }
2636 }
2637
2638 // TODO: The mapping from workspace root paths to git repositories needs a
2639 // unified approach across the codebase: this method, `sidebar::is_root_repo`,
2640 // thread persistence (which PathList is saved to the database), and thread
2641 // querying (which PathList is used to read threads back). All of these need
2642 // to agree on how repos are resolved for a given workspace, especially in
2643 // multi-root and nested-repo configurations.
2644 /// Partitions the project's visible worktrees into git-backed repositories
2645 /// and plain (non-git) paths. Git repos will have worktrees created for
2646 /// them; non-git paths are carried over to the new workspace as-is.
2647 ///
2648 /// When multiple worktrees map to the same repository, the most specific
2649 /// match wins (deepest work directory path), with a deterministic
2650 /// tie-break on entity id. Each repository appears at most once.
2651 fn classify_worktrees(
2652 &self,
2653 cx: &App,
2654 ) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
2655 let project = &self.project;
2656 let repositories = project.read(cx).repositories(cx).clone();
2657 let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
2658 let mut non_git_paths: Vec<PathBuf> = Vec::new();
2659 let mut seen_repo_ids = std::collections::HashSet::new();
2660
2661 for worktree in project.read(cx).visible_worktrees(cx) {
2662 let wt_path = worktree.read(cx).abs_path();
2663
2664 let matching_repo = repositories
2665 .iter()
2666 .filter_map(|(id, repo)| {
2667 let work_dir = repo.read(cx).work_directory_abs_path.clone();
2668 if wt_path.starts_with(work_dir.as_ref())
2669 || work_dir.starts_with(wt_path.as_ref())
2670 {
2671 Some((*id, repo.clone(), work_dir.as_ref().components().count()))
2672 } else {
2673 None
2674 }
2675 })
2676 .max_by(
2677 |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
2678 left_depth
2679 .cmp(right_depth)
2680 .then_with(|| left_id.cmp(right_id))
2681 },
2682 );
2683
2684 if let Some((id, repo, _)) = matching_repo {
2685 if seen_repo_ids.insert(id) {
2686 git_repos.push(repo);
2687 }
2688 } else {
2689 non_git_paths.push(wt_path.to_path_buf());
2690 }
2691 }
2692
2693 (git_repos, non_git_paths)
2694 }
2695
2696 fn resolve_worktree_branch_target(
2697 branch_target: &NewWorktreeBranchTarget,
2698 existing_branches: &HashSet<String>,
2699 occupied_branches: &HashSet<String>,
2700 ) -> Result<(String, bool, Option<String>)> {
2701 let generate_branch_name = || -> Result<String> {
2702 let refs: Vec<&str> = existing_branches.iter().map(|s| s.as_str()).collect();
2703 let mut rng = rand::rng();
2704 crate::branch_names::generate_branch_name(&refs, &mut rng)
2705 .ok_or_else(|| anyhow!("Failed to generate a unique branch name"))
2706 };
2707
2708 match branch_target {
2709 NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
2710 Ok((name.clone(), false, from_ref.clone()))
2711 }
2712 NewWorktreeBranchTarget::ExistingBranch { name } => {
2713 if occupied_branches.contains(name) {
2714 Ok((generate_branch_name()?, false, Some(name.clone())))
2715 } else {
2716 Ok((name.clone(), true, None))
2717 }
2718 }
2719 NewWorktreeBranchTarget::CurrentBranch => Ok((generate_branch_name()?, false, None)),
2720 }
2721 }
2722
2723 /// Kicks off an async git-worktree creation for each repository. Returns:
2724 ///
2725 /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
2726 /// receiver resolves once the git worktree command finishes.
2727 /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
2728 /// later to remap open editor tabs into the new workspace.
2729 fn start_worktree_creations(
2730 git_repos: &[Entity<project::git_store::Repository>],
2731 worktree_name: Option<String>,
2732 branch_name: &str,
2733 use_existing_branch: bool,
2734 start_point: Option<String>,
2735 worktree_directory_setting: &str,
2736 cx: &mut Context<Self>,
2737 ) -> Result<(
2738 Vec<(
2739 Entity<project::git_store::Repository>,
2740 PathBuf,
2741 futures::channel::oneshot::Receiver<Result<()>>,
2742 )>,
2743 Vec<(PathBuf, PathBuf)>,
2744 )> {
2745 let mut creation_infos = Vec::new();
2746 let mut path_remapping = Vec::new();
2747
2748 let worktree_name = worktree_name.unwrap_or_else(|| branch_name.to_string());
2749
2750 for repo in git_repos {
2751 let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
2752 let new_path =
2753 repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
2754 let target = if use_existing_branch {
2755 debug_assert!(
2756 git_repos.len() == 1,
2757 "use_existing_branch should only be true for a single repo"
2758 );
2759 git::repository::CreateWorktreeTarget::ExistingBranch {
2760 branch_name: branch_name.to_string(),
2761 }
2762 } else {
2763 git::repository::CreateWorktreeTarget::NewBranch {
2764 branch_name: branch_name.to_string(),
2765 base_sha: start_point.clone(),
2766 }
2767 };
2768 let receiver = repo.create_worktree(target, new_path.clone());
2769 let work_dir = repo.work_directory_abs_path.clone();
2770 anyhow::Ok((work_dir, new_path, receiver))
2771 })?;
2772 path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
2773 creation_infos.push((repo.clone(), new_path, receiver));
2774 }
2775
2776 Ok((creation_infos, path_remapping))
2777 }
2778
2779 /// Waits for every in-flight worktree creation to complete. If any
2780 /// creation fails, all successfully-created worktrees are rolled back
2781 /// (removed) so the project isn't left in a half-migrated state.
2782 async fn await_and_rollback_on_failure(
2783 creation_infos: Vec<(
2784 Entity<project::git_store::Repository>,
2785 PathBuf,
2786 futures::channel::oneshot::Receiver<Result<()>>,
2787 )>,
2788 cx: &mut AsyncWindowContext,
2789 ) -> Result<Vec<PathBuf>> {
2790 let mut created_paths: Vec<PathBuf> = Vec::new();
2791 let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
2792 Vec::new();
2793 let mut first_error: Option<anyhow::Error> = None;
2794
2795 for (repo, new_path, receiver) in creation_infos {
2796 match receiver.await {
2797 Ok(Ok(())) => {
2798 created_paths.push(new_path.clone());
2799 repos_and_paths.push((repo, new_path));
2800 }
2801 Ok(Err(err)) => {
2802 if first_error.is_none() {
2803 first_error = Some(err);
2804 }
2805 }
2806 Err(_canceled) => {
2807 if first_error.is_none() {
2808 first_error = Some(anyhow!("Worktree creation was canceled"));
2809 }
2810 }
2811 }
2812 }
2813
2814 let Some(err) = first_error else {
2815 return Ok(created_paths);
2816 };
2817
2818 // Rollback all successfully created worktrees
2819 let mut rollback_receivers = Vec::new();
2820 for (rollback_repo, rollback_path) in &repos_and_paths {
2821 if let Ok(receiver) = cx.update(|_, cx| {
2822 rollback_repo.update(cx, |repo, _cx| {
2823 repo.remove_worktree(rollback_path.clone(), true)
2824 })
2825 }) {
2826 rollback_receivers.push((rollback_path.clone(), receiver));
2827 }
2828 }
2829 let mut rollback_failures: Vec<String> = Vec::new();
2830 for (path, receiver) in rollback_receivers {
2831 match receiver.await {
2832 Ok(Ok(())) => {}
2833 Ok(Err(rollback_err)) => {
2834 log::error!(
2835 "failed to rollback worktree at {}: {rollback_err}",
2836 path.display()
2837 );
2838 rollback_failures.push(format!("{}: {rollback_err}", path.display()));
2839 }
2840 Err(rollback_err) => {
2841 log::error!(
2842 "failed to rollback worktree at {}: {rollback_err}",
2843 path.display()
2844 );
2845 rollback_failures.push(format!("{}: {rollback_err}", path.display()));
2846 }
2847 }
2848 }
2849 let mut error_message = format!("Failed to create worktree: {err}");
2850 if !rollback_failures.is_empty() {
2851 error_message.push_str("\n\nFailed to clean up: ");
2852 error_message.push_str(&rollback_failures.join(", "));
2853 }
2854 Err(anyhow!(error_message))
2855 }
2856
2857 fn set_worktree_creation_error(
2858 &mut self,
2859 message: SharedString,
2860 window: &mut Window,
2861 cx: &mut Context<Self>,
2862 ) {
2863 self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message));
2864 if matches!(self.active_view, ActiveView::Uninitialized) {
2865 let selected_agent = self.selected_agent.clone();
2866 self.new_agent_thread(selected_agent, window, cx);
2867 }
2868 cx.notify();
2869 }
2870
2871 fn handle_worktree_requested(
2872 &mut self,
2873 content: Vec<acp::ContentBlock>,
2874 args: WorktreeCreationArgs,
2875 window: &mut Window,
2876 cx: &mut Context<Self>,
2877 ) {
2878 if matches!(
2879 self.worktree_creation_status,
2880 Some(WorktreeCreationStatus::Creating)
2881 ) {
2882 return;
2883 }
2884
2885 self.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
2886 cx.notify();
2887
2888 let (git_repos, non_git_paths) = self.classify_worktrees(cx);
2889
2890 if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() {
2891 self.set_worktree_creation_error(
2892 "No git repositories found in the project".into(),
2893 window,
2894 cx,
2895 );
2896 return;
2897 }
2898
2899 let (branch_receivers, worktree_receivers, worktree_directory_setting) =
2900 if matches!(args, WorktreeCreationArgs::New { .. }) {
2901 (
2902 Some(
2903 git_repos
2904 .iter()
2905 .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
2906 .collect::<Vec<_>>(),
2907 ),
2908 Some(
2909 git_repos
2910 .iter()
2911 .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
2912 .collect::<Vec<_>>(),
2913 ),
2914 Some(
2915 ProjectSettings::get_global(cx)
2916 .git
2917 .worktree_directory
2918 .clone(),
2919 ),
2920 )
2921 } else {
2922 (None, None, None)
2923 };
2924
2925 let active_file_path = self.workspace.upgrade().and_then(|workspace| {
2926 let workspace = workspace.read(cx);
2927 let active_item = workspace.active_item(cx)?;
2928 let project_path = active_item.project_path(cx)?;
2929 workspace
2930 .project()
2931 .read(cx)
2932 .absolute_path(&project_path, cx)
2933 });
2934
2935 let workspace = self.workspace.clone();
2936 let window_handle = window
2937 .window_handle()
2938 .downcast::<workspace::MultiWorkspace>();
2939
2940 let selected_agent = self.selected_agent();
2941
2942 let task = cx.spawn_in(window, async move |this, cx| {
2943 let (all_paths, path_remapping, has_non_git) = match args {
2944 WorktreeCreationArgs::New {
2945 worktree_name,
2946 branch_target,
2947 } => {
2948 let branch_receivers = branch_receivers
2949 .expect("branch receivers must be prepared for new worktree creation");
2950 let worktree_receivers = worktree_receivers
2951 .expect("worktree receivers must be prepared for new worktree creation");
2952 let worktree_directory_setting = worktree_directory_setting
2953 .expect("worktree directory must be prepared for new worktree creation");
2954
2955 let mut existing_branches = HashSet::default();
2956 for result in futures::future::join_all(branch_receivers).await {
2957 match result {
2958 Ok(Ok(branches)) => {
2959 for branch in branches {
2960 existing_branches.insert(branch.name().to_string());
2961 }
2962 }
2963 Ok(Err(err)) => {
2964 Err::<(), _>(err).log_err();
2965 }
2966 Err(_) => {}
2967 }
2968 }
2969
2970 let mut occupied_branches = HashSet::default();
2971 for result in futures::future::join_all(worktree_receivers).await {
2972 match result {
2973 Ok(Ok(worktrees)) => {
2974 for worktree in worktrees {
2975 if let Some(branch_name) = worktree.branch_name() {
2976 occupied_branches.insert(branch_name.to_string());
2977 }
2978 }
2979 }
2980 Ok(Err(err)) => {
2981 Err::<(), _>(err).log_err();
2982 }
2983 Err(_) => {}
2984 }
2985 }
2986
2987 let (branch_name, use_existing_branch, start_point) =
2988 match Self::resolve_worktree_branch_target(
2989 &branch_target,
2990 &existing_branches,
2991 &occupied_branches,
2992 ) {
2993 Ok(target) => target,
2994 Err(err) => {
2995 this.update_in(cx, |this, window, cx| {
2996 this.set_worktree_creation_error(
2997 err.to_string().into(),
2998 window,
2999 cx,
3000 );
3001 })?;
3002 return anyhow::Ok(());
3003 }
3004 };
3005
3006 let (creation_infos, path_remapping) =
3007 match this.update_in(cx, |_this, _window, cx| {
3008 Self::start_worktree_creations(
3009 &git_repos,
3010 worktree_name,
3011 &branch_name,
3012 use_existing_branch,
3013 start_point,
3014 &worktree_directory_setting,
3015 cx,
3016 )
3017 }) {
3018 Ok(Ok(result)) => result,
3019 Ok(Err(err)) | Err(err) => {
3020 this.update_in(cx, |this, window, cx| {
3021 this.set_worktree_creation_error(
3022 format!("Failed to validate worktree directory: {err}")
3023 .into(),
3024 window,
3025 cx,
3026 );
3027 })
3028 .log_err();
3029 return anyhow::Ok(());
3030 }
3031 };
3032
3033 let created_paths =
3034 match Self::await_and_rollback_on_failure(creation_infos, cx).await {
3035 Ok(paths) => paths,
3036 Err(err) => {
3037 this.update_in(cx, |this, window, cx| {
3038 this.set_worktree_creation_error(
3039 format!("{err}").into(),
3040 window,
3041 cx,
3042 );
3043 })?;
3044 return anyhow::Ok(());
3045 }
3046 };
3047
3048 let mut all_paths = created_paths;
3049 let has_non_git = !non_git_paths.is_empty();
3050 all_paths.extend(non_git_paths.iter().cloned());
3051 (all_paths, path_remapping, has_non_git)
3052 }
3053 WorktreeCreationArgs::Linked { worktree_path } => {
3054 let mut all_paths = vec![worktree_path];
3055 let has_non_git = !non_git_paths.is_empty();
3056 all_paths.extend(non_git_paths.iter().cloned());
3057 (all_paths, Vec::new(), has_non_git)
3058 }
3059 };
3060
3061 let app_state = match workspace.upgrade() {
3062 Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?,
3063 None => {
3064 this.update_in(cx, |this, window, cx| {
3065 this.set_worktree_creation_error(
3066 "Workspace no longer available".into(),
3067 window,
3068 cx,
3069 );
3070 })?;
3071 return anyhow::Ok(());
3072 }
3073 };
3074
3075 let this_for_error = this.clone();
3076 if let Err(err) = Self::open_worktree_workspace_and_start_thread(
3077 this,
3078 all_paths,
3079 app_state,
3080 window_handle,
3081 active_file_path,
3082 path_remapping,
3083 non_git_paths,
3084 has_non_git,
3085 content,
3086 selected_agent,
3087 cx,
3088 )
3089 .await
3090 {
3091 this_for_error
3092 .update_in(cx, |this, window, cx| {
3093 this.set_worktree_creation_error(
3094 format!("Failed to set up workspace: {err}").into(),
3095 window,
3096 cx,
3097 );
3098 })
3099 .log_err();
3100 }
3101 anyhow::Ok(())
3102 });
3103
3104 self._worktree_creation_task = Some(cx.background_spawn(async move {
3105 task.await.log_err();
3106 }));
3107 }
3108
3109 async fn open_worktree_workspace_and_start_thread(
3110 this: WeakEntity<Self>,
3111 all_paths: Vec<PathBuf>,
3112 app_state: Arc<workspace::AppState>,
3113 window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
3114 active_file_path: Option<PathBuf>,
3115 path_remapping: Vec<(PathBuf, PathBuf)>,
3116 non_git_paths: Vec<PathBuf>,
3117 has_non_git: bool,
3118 content: Vec<acp::ContentBlock>,
3119 selected_agent: Option<Agent>,
3120 cx: &mut AsyncWindowContext,
3121 ) -> Result<()> {
3122 let OpenResult {
3123 window: new_window_handle,
3124 workspace: new_workspace,
3125 ..
3126 } = cx
3127 .update(|_window, cx| {
3128 Workspace::new_local(
3129 all_paths,
3130 app_state,
3131 window_handle,
3132 None,
3133 None,
3134 OpenMode::Add,
3135 cx,
3136 )
3137 })?
3138 .await?;
3139
3140 let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
3141
3142 if let Some(task) = panels_task {
3143 task.await.log_err();
3144 }
3145
3146 new_workspace
3147 .update(cx, |workspace, cx| {
3148 workspace.project().read(cx).wait_for_initial_scan(cx)
3149 })
3150 .await;
3151
3152 new_workspace
3153 .update(cx, |workspace, cx| {
3154 let repos = workspace
3155 .project()
3156 .read(cx)
3157 .repositories(cx)
3158 .values()
3159 .cloned()
3160 .collect::<Vec<_>>();
3161
3162 let tasks = repos
3163 .into_iter()
3164 .map(|repo| repo.update(cx, |repo, _| repo.barrier()));
3165 futures::future::join_all(tasks)
3166 })
3167 .await;
3168
3169 let initial_content = AgentInitialContent::ContentBlock {
3170 blocks: content,
3171 auto_submit: true,
3172 };
3173
3174 new_window_handle.update(cx, |_multi_workspace, window, cx| {
3175 new_workspace.update(cx, |workspace, cx| {
3176 if has_non_git {
3177 let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
3178 workspace.show_toast(
3179 workspace::Toast::new(
3180 toast_id,
3181 "Some project folders are not git repositories. \
3182 They were included as-is without creating a worktree.",
3183 ),
3184 cx,
3185 );
3186 }
3187
3188 // If we had an active buffer, remap its path and reopen it.
3189 let had_active_file = active_file_path.is_some();
3190 let remapped_active_path = active_file_path.and_then(|original_path| {
3191 let best_match = path_remapping
3192 .iter()
3193 .filter_map(|(old_root, new_root)| {
3194 original_path.strip_prefix(old_root).ok().map(|relative| {
3195 (old_root.components().count(), new_root.join(relative))
3196 })
3197 })
3198 .max_by_key(|(depth, _)| *depth);
3199
3200 if let Some((_, remapped_path)) = best_match {
3201 return Some(remapped_path);
3202 }
3203
3204 for non_git in &non_git_paths {
3205 if original_path.starts_with(non_git) {
3206 return Some(original_path);
3207 }
3208 }
3209 None
3210 });
3211
3212 if had_active_file && remapped_active_path.is_none() {
3213 log::warn!(
3214 "Active file could not be remapped to the new worktree; it will not be reopened"
3215 );
3216 }
3217
3218 if let Some(path) = remapped_active_path {
3219 let open_task = workspace.open_paths(
3220 vec![path],
3221 workspace::OpenOptions::default(),
3222 None,
3223 window,
3224 cx,
3225 );
3226 cx.spawn(async move |_, _| -> anyhow::Result<()> {
3227 for item in open_task.await.into_iter().flatten() {
3228 item?;
3229 }
3230 Ok(())
3231 })
3232 .detach_and_log_err(cx);
3233 }
3234
3235 workspace.focus_panel::<AgentPanel>(window, cx);
3236
3237 // If no active buffer was open, zoom the agent panel
3238 // (equivalent to cmd-esc fullscreen behavior).
3239 // This must happen after focus_panel, which activates
3240 // and opens the panel in the dock.
3241
3242 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3243 panel.update(cx, |panel, cx| {
3244 panel.external_thread(
3245 selected_agent,
3246 None,
3247 None,
3248 None,
3249 Some(initial_content),
3250 true,
3251 window,
3252 cx,
3253 );
3254 });
3255 }
3256 });
3257 })?;
3258
3259 new_window_handle.update(cx, |multi_workspace, window, cx| {
3260 multi_workspace.activate(new_workspace.clone(), window, cx);
3261
3262 new_workspace.update(cx, |workspace, cx| {
3263 workspace.run_create_worktree_tasks(window, cx);
3264 })
3265 })?;
3266
3267 this.update_in(cx, |this, window, cx| {
3268 this.worktree_creation_status = None;
3269
3270 if let Some(thread_view) = this.active_thread_view(cx) {
3271 thread_view.update(cx, |thread_view, cx| {
3272 thread_view
3273 .message_editor
3274 .update(cx, |editor, cx| editor.clear(window, cx));
3275 });
3276 }
3277
3278 this.start_thread_in = StartThreadIn::LocalProject;
3279 this.serialize(cx);
3280 cx.notify();
3281 })?;
3282
3283 anyhow::Ok(())
3284 }
3285}
3286
3287impl Focusable for AgentPanel {
3288 fn focus_handle(&self, cx: &App) -> FocusHandle {
3289 match &self.active_view {
3290 ActiveView::Uninitialized => self.focus_handle.clone(),
3291 ActiveView::AgentThread {
3292 conversation_view, ..
3293 } => conversation_view.focus_handle(cx),
3294 ActiveView::History { view } => view.read(cx).focus_handle(cx),
3295 ActiveView::Configuration => {
3296 if let Some(configuration) = self.configuration.as_ref() {
3297 configuration.focus_handle(cx)
3298 } else {
3299 self.focus_handle.clone()
3300 }
3301 }
3302 }
3303 }
3304}
3305
3306fn agent_panel_dock_position(cx: &App) -> DockPosition {
3307 AgentSettings::get_global(cx).dock.into()
3308}
3309
3310pub enum AgentPanelEvent {
3311 ActiveViewChanged,
3312 ThreadFocused,
3313 BackgroundThreadChanged,
3314 MessageSentOrQueued { session_id: acp::SessionId },
3315}
3316
3317impl EventEmitter<PanelEvent> for AgentPanel {}
3318impl EventEmitter<AgentPanelEvent> for AgentPanel {}
3319
3320impl Panel for AgentPanel {
3321 fn persistent_name() -> &'static str {
3322 "AgentPanel"
3323 }
3324
3325 fn panel_key() -> &'static str {
3326 AGENT_PANEL_KEY
3327 }
3328
3329 fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3330 agent_panel_dock_position(cx)
3331 }
3332
3333 fn position_is_valid(&self, position: DockPosition) -> bool {
3334 position != DockPosition::Bottom
3335 }
3336
3337 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
3338 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3339 settings
3340 .agent
3341 .get_or_insert_default()
3342 .set_dock(position.into());
3343 });
3344 }
3345
3346 fn default_size(&self, window: &Window, cx: &App) -> Pixels {
3347 let settings = AgentSettings::get_global(cx);
3348 match self.position(window, cx) {
3349 DockPosition::Left | DockPosition::Right => settings.default_width,
3350 DockPosition::Bottom => settings.default_height,
3351 }
3352 }
3353
3354 fn supports_flexible_size(&self) -> bool {
3355 true
3356 }
3357
3358 fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
3359 AgentSettings::get_global(cx).flexible
3360 }
3361
3362 fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
3363 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3364 settings
3365 .agent
3366 .get_or_insert_default()
3367 .set_flexible_size(flexible);
3368 });
3369 }
3370
3371 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
3372 if active
3373 && matches!(self.active_view, ActiveView::Uninitialized)
3374 && !matches!(
3375 self.worktree_creation_status,
3376 Some(WorktreeCreationStatus::Creating)
3377 )
3378 {
3379 let selected_agent = self.selected_agent.clone();
3380 self.new_agent_thread_inner(selected_agent, false, window, cx);
3381 }
3382 }
3383
3384 fn remote_id() -> Option<proto::PanelId> {
3385 Some(proto::PanelId::AssistantPanel)
3386 }
3387
3388 fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
3389 (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
3390 }
3391
3392 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3393 Some("Agent Panel")
3394 }
3395
3396 fn toggle_action(&self) -> Box<dyn Action> {
3397 Box::new(ToggleFocus)
3398 }
3399
3400 fn activation_priority(&self) -> u32 {
3401 0
3402 }
3403
3404 fn enabled(&self, cx: &App) -> bool {
3405 AgentSettings::get_global(cx).enabled(cx)
3406 }
3407
3408 fn is_agent_panel(&self) -> bool {
3409 true
3410 }
3411
3412 fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
3413 self.zoomed
3414 }
3415
3416 fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
3417 self.zoomed = zoomed;
3418 cx.notify();
3419 }
3420}
3421
3422impl AgentPanel {
3423 fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
3424 let content = match &self.active_view {
3425 ActiveView::AgentThread { conversation_view } => {
3426 let server_view_ref = conversation_view.read(cx);
3427 let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
3428 && server_view_ref.root_thread(cx).map_or(false, |tv| {
3429 tv.read(cx).thread.read(cx).has_provisional_title()
3430 });
3431
3432 if let Some(title_editor) = server_view_ref
3433 .root_thread(cx)
3434 .map(|r| r.read(cx).title_editor.clone())
3435 {
3436 if is_generating_title {
3437 Label::new(DEFAULT_THREAD_TITLE)
3438 .color(Color::Muted)
3439 .truncate()
3440 .with_animation(
3441 "generating_title",
3442 Animation::new(Duration::from_secs(2))
3443 .repeat()
3444 .with_easing(pulsating_between(0.4, 0.8)),
3445 |label, delta| label.alpha(delta),
3446 )
3447 .into_any_element()
3448 } else {
3449 div()
3450 .w_full()
3451 .on_action({
3452 let conversation_view = conversation_view.downgrade();
3453 move |_: &menu::Confirm, window, cx| {
3454 if let Some(conversation_view) = conversation_view.upgrade() {
3455 conversation_view.focus_handle(cx).focus(window, cx);
3456 }
3457 }
3458 })
3459 .on_action({
3460 let conversation_view = conversation_view.downgrade();
3461 move |_: &editor::actions::Cancel, window, cx| {
3462 if let Some(conversation_view) = conversation_view.upgrade() {
3463 conversation_view.focus_handle(cx).focus(window, cx);
3464 }
3465 }
3466 })
3467 .child(title_editor)
3468 .into_any_element()
3469 }
3470 } else {
3471 Label::new(conversation_view.read(cx).title(cx))
3472 .color(Color::Muted)
3473 .truncate()
3474 .into_any_element()
3475 }
3476 }
3477 ActiveView::History { .. } => Label::new("History").truncate().into_any_element(),
3478 ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
3479 ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
3480 };
3481
3482 h_flex()
3483 .key_context("TitleEditor")
3484 .id("TitleEditor")
3485 .flex_grow()
3486 .w_full()
3487 .max_w_full()
3488 .overflow_x_scroll()
3489 .child(content)
3490 .into_any()
3491 }
3492
3493 fn handle_regenerate_thread_title(conversation_view: Entity<ConversationView>, cx: &mut App) {
3494 conversation_view.update(cx, |conversation_view, cx| {
3495 if let Some(thread) = conversation_view.as_native_thread(cx) {
3496 thread.update(cx, |thread, cx| {
3497 thread.generate_title(cx);
3498 });
3499 }
3500 });
3501 }
3502
3503 fn render_panel_options_menu(
3504 &self,
3505 _window: &mut Window,
3506 cx: &mut Context<Self>,
3507 ) -> impl IntoElement {
3508 let focus_handle = self.focus_handle(cx);
3509
3510 let conversation_view = match &self.active_view {
3511 ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
3512 _ => None,
3513 };
3514 let thread_with_messages = match &self.active_view {
3515 ActiveView::AgentThread { conversation_view } => {
3516 conversation_view.read(cx).has_user_submitted_prompt(cx)
3517 }
3518 _ => false,
3519 };
3520 let has_auth_methods = match &self.active_view {
3521 ActiveView::AgentThread { conversation_view } => {
3522 conversation_view.read(cx).has_auth_methods()
3523 }
3524 _ => false,
3525 };
3526
3527 PopoverMenu::new("agent-options-menu")
3528 .trigger_with_tooltip(
3529 IconButton::new("agent-options-menu", IconName::Ellipsis)
3530 .icon_size(IconSize::Small),
3531 {
3532 let focus_handle = focus_handle.clone();
3533 move |_window, cx| {
3534 Tooltip::for_action_in(
3535 "Toggle Agent Menu",
3536 &ToggleOptionsMenu,
3537 &focus_handle,
3538 cx,
3539 )
3540 }
3541 },
3542 )
3543 .anchor(Corner::TopRight)
3544 .with_handle(self.agent_panel_menu_handle.clone())
3545 .menu({
3546 move |window, cx| {
3547 Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
3548 menu = menu.context(focus_handle.clone());
3549
3550 if thread_with_messages {
3551 menu = menu.header("Current Thread");
3552
3553 if let Some(conversation_view) = conversation_view.as_ref() {
3554 menu = menu
3555 .entry("Regenerate Thread Title", None, {
3556 let conversation_view = conversation_view.clone();
3557 move |_, cx| {
3558 Self::handle_regenerate_thread_title(
3559 conversation_view.clone(),
3560 cx,
3561 );
3562 }
3563 })
3564 .separator();
3565 }
3566 }
3567
3568 menu = menu
3569 .header("MCP Servers")
3570 .action(
3571 "View Server Extensions",
3572 Box::new(zed_actions::Extensions {
3573 category_filter: Some(
3574 zed_actions::ExtensionCategoryFilter::ContextServers,
3575 ),
3576 id: None,
3577 }),
3578 )
3579 .action("Add Custom Server…", Box::new(AddContextServer))
3580 .separator()
3581 .action("Rules", Box::new(OpenRulesLibrary::default()))
3582 .action("Profiles", Box::new(ManageProfiles::default()))
3583 .action("Settings", Box::new(OpenSettings))
3584 .separator()
3585 .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar));
3586
3587 if has_auth_methods {
3588 menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
3589 }
3590
3591 menu
3592 }))
3593 }
3594 })
3595 }
3596
3597 fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3598 let focus_handle = self.focus_handle(cx);
3599
3600 IconButton::new("go-back", IconName::ArrowLeft)
3601 .icon_size(IconSize::Small)
3602 .on_click(cx.listener(|this, _, window, cx| {
3603 this.go_back(&workspace::GoBack, window, cx);
3604 }))
3605 .tooltip({
3606 move |_window, cx| {
3607 Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
3608 }
3609 })
3610 }
3611
3612 fn project_has_git_repository(&self, cx: &App) -> bool {
3613 !self.project.read(cx).repositories(cx).is_empty()
3614 }
3615
3616 fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3617 let focus_handle = self.focus_handle(cx);
3618
3619 let is_creating = matches!(
3620 self.worktree_creation_status,
3621 Some(WorktreeCreationStatus::Creating)
3622 );
3623
3624 let trigger_parts = self
3625 .start_thread_in
3626 .trigger_label(self.project.read(cx), cx);
3627
3628 let icon = if self.start_thread_in_menu_handle.is_deployed() {
3629 IconName::ChevronUp
3630 } else {
3631 IconName::ChevronDown
3632 };
3633
3634 let trigger_button = ButtonLike::new("thread-target-trigger")
3635 .disabled(is_creating)
3636 .when_some(trigger_parts.prefix, |this, prefix| {
3637 this.child(Label::new(prefix).color(Color::Muted))
3638 })
3639 .child(Label::new(trigger_parts.label))
3640 .when_some(trigger_parts.suffix, |this, suffix| {
3641 this.child(Label::new(suffix).color(Color::Muted))
3642 })
3643 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
3644
3645 let project = self.project.clone();
3646 let current_target = self.start_thread_in.clone();
3647 let fs = self.fs.clone();
3648
3649 PopoverMenu::new("thread-target-selector")
3650 .trigger_with_tooltip(trigger_button, {
3651 move |_window, cx| {
3652 Tooltip::for_action_in(
3653 "Start Thread In…",
3654 &CycleStartThreadIn,
3655 &focus_handle,
3656 cx,
3657 )
3658 }
3659 })
3660 .menu(move |window, cx| {
3661 let fs = fs.clone();
3662 Some(cx.new(|cx| {
3663 ThreadWorktreePicker::new(project.clone(), ¤t_target, fs, window, cx)
3664 }))
3665 })
3666 .with_handle(self.start_thread_in_menu_handle.clone())
3667 .anchor(Corner::TopLeft)
3668 .offset(gpui::Point {
3669 x: px(1.0),
3670 y: px(1.0),
3671 })
3672 }
3673
3674 fn render_new_worktree_branch_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3675 let is_creating = matches!(
3676 self.worktree_creation_status,
3677 Some(WorktreeCreationStatus::Creating)
3678 );
3679
3680 let project_ref = self.project.read(cx);
3681 let trigger_parts = self
3682 .start_thread_in
3683 .branch_trigger_label(project_ref, cx)
3684 .unwrap_or_else(|| StartThreadInLabel {
3685 prefix: Some("From:".into()),
3686 label: "HEAD".into(),
3687 suffix: None,
3688 });
3689
3690 let icon = if self.thread_branch_menu_handle.is_deployed() {
3691 IconName::ChevronUp
3692 } else {
3693 IconName::ChevronDown
3694 };
3695
3696 let trigger_button = ButtonLike::new("thread-branch-trigger")
3697 .disabled(is_creating)
3698 .when_some(trigger_parts.prefix, |this, prefix| {
3699 this.child(Label::new(prefix).color(Color::Muted))
3700 })
3701 .child(Label::new(trigger_parts.label))
3702 .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
3703
3704 let project = self.project.clone();
3705 let current_target = self.start_thread_in.clone();
3706
3707 PopoverMenu::new("thread-branch-selector")
3708 .trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…"))
3709 .menu(move |window, cx| {
3710 Some(cx.new(|cx| {
3711 ThreadBranchPicker::new(project.clone(), ¤t_target, window, cx)
3712 }))
3713 })
3714 .with_handle(self.thread_branch_menu_handle.clone())
3715 .anchor(Corner::TopLeft)
3716 .offset(gpui::Point {
3717 x: px(1.0),
3718 y: px(1.0),
3719 })
3720 }
3721
3722 fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3723 let agent_server_store = self.project.read(cx).agent_server_store().clone();
3724 let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some();
3725 let focus_handle = self.focus_handle(cx);
3726
3727 let (selected_agent_custom_icon, selected_agent_label) =
3728 if let Agent::Custom { id, .. } = &self.selected_agent {
3729 let store = agent_server_store.read(cx);
3730 let icon = store.agent_icon(&id);
3731
3732 let label = store
3733 .agent_display_name(&id)
3734 .unwrap_or_else(|| self.selected_agent.label());
3735 (icon, label)
3736 } else {
3737 (None, self.selected_agent.label())
3738 };
3739
3740 let active_thread = match &self.active_view {
3741 ActiveView::AgentThread { conversation_view } => {
3742 conversation_view.read(cx).as_native_thread(cx)
3743 }
3744 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3745 None
3746 }
3747 };
3748
3749 let new_thread_menu_builder: Rc<
3750 dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
3751 > = {
3752 let selected_agent = self.selected_agent.clone();
3753 let is_agent_selected = move |agent: Agent| selected_agent == agent;
3754
3755 let workspace = self.workspace.clone();
3756 let is_via_collab = workspace
3757 .update(cx, |workspace, cx| {
3758 workspace.project().read(cx).is_via_collab()
3759 })
3760 .unwrap_or_default();
3761
3762 let focus_handle = focus_handle.clone();
3763 let agent_server_store = agent_server_store;
3764
3765 Rc::new(move |window, cx| {
3766 telemetry::event!("New Thread Clicked");
3767
3768 let active_thread = active_thread.clone();
3769 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
3770 menu.context(focus_handle.clone())
3771 .when_some(active_thread, |this, active_thread| {
3772 let thread = active_thread.read(cx);
3773
3774 if !thread.is_empty() {
3775 let session_id = thread.id().clone();
3776 this.item(
3777 ContextMenuEntry::new("New From Summary")
3778 .icon(IconName::ThreadFromSummary)
3779 .icon_color(Color::Muted)
3780 .handler(move |window, cx| {
3781 window.dispatch_action(
3782 Box::new(NewNativeAgentThreadFromSummary {
3783 from_session_id: session_id.clone(),
3784 }),
3785 cx,
3786 );
3787 }),
3788 )
3789 } else {
3790 this
3791 }
3792 })
3793 .item(
3794 ContextMenuEntry::new("Zed Agent")
3795 .when(is_agent_selected(Agent::NativeAgent), |this| {
3796 this.action(Box::new(NewExternalAgentThread { agent: None }))
3797 })
3798 .icon(IconName::ZedAgent)
3799 .icon_color(Color::Muted)
3800 .handler({
3801 let workspace = workspace.clone();
3802 move |window, cx| {
3803 if let Some(workspace) = workspace.upgrade() {
3804 workspace.update(cx, |workspace, cx| {
3805 if let Some(panel) =
3806 workspace.panel::<AgentPanel>(cx)
3807 {
3808 panel.update(cx, |panel, cx| {
3809 panel.new_agent_thread(
3810 Agent::NativeAgent,
3811 window,
3812 cx,
3813 );
3814 });
3815 }
3816 });
3817 }
3818 }
3819 }),
3820 )
3821 .map(|mut menu| {
3822 let agent_server_store = agent_server_store.read(cx);
3823 let registry_store = project::AgentRegistryStore::try_global(cx);
3824 let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
3825
3826 struct AgentMenuItem {
3827 id: AgentId,
3828 display_name: SharedString,
3829 }
3830
3831 let agent_items = agent_server_store
3832 .external_agents()
3833 .map(|agent_id| {
3834 let display_name = agent_server_store
3835 .agent_display_name(agent_id)
3836 .or_else(|| {
3837 registry_store_ref
3838 .as_ref()
3839 .and_then(|store| store.agent(agent_id))
3840 .map(|a| a.name().clone())
3841 })
3842 .unwrap_or_else(|| agent_id.0.clone());
3843 AgentMenuItem {
3844 id: agent_id.clone(),
3845 display_name,
3846 }
3847 })
3848 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
3849 .collect::<Vec<_>>();
3850
3851 if !agent_items.is_empty() {
3852 menu = menu.separator().header("External Agents");
3853 }
3854 for item in &agent_items {
3855 let mut entry = ContextMenuEntry::new(item.display_name.clone());
3856
3857 let icon_path =
3858 agent_server_store.agent_icon(&item.id).or_else(|| {
3859 registry_store_ref
3860 .as_ref()
3861 .and_then(|store| store.agent(&item.id))
3862 .and_then(|a| a.icon_path().cloned())
3863 });
3864
3865 if let Some(icon_path) = icon_path {
3866 entry = entry.custom_icon_svg(icon_path);
3867 } else {
3868 entry = entry.icon(IconName::Sparkle);
3869 }
3870
3871 entry = entry
3872 .when(
3873 is_agent_selected(Agent::Custom {
3874 id: item.id.clone(),
3875 }),
3876 |this| {
3877 this.action(Box::new(NewExternalAgentThread {
3878 agent: None,
3879 }))
3880 },
3881 )
3882 .icon_color(Color::Muted)
3883 .disabled(is_via_collab)
3884 .handler({
3885 let workspace = workspace.clone();
3886 let agent_id = item.id.clone();
3887 move |window, cx| {
3888 if let Some(workspace) = workspace.upgrade() {
3889 workspace.update(cx, |workspace, cx| {
3890 if let Some(panel) =
3891 workspace.panel::<AgentPanel>(cx)
3892 {
3893 panel.update(cx, |panel, cx| {
3894 panel.new_agent_thread(
3895 Agent::Custom {
3896 id: agent_id.clone(),
3897 },
3898 window,
3899 cx,
3900 );
3901 });
3902 }
3903 });
3904 }
3905 }
3906 });
3907
3908 menu = menu.item(entry);
3909 }
3910
3911 menu
3912 })
3913 .separator()
3914 .item(
3915 ContextMenuEntry::new("Add More Agents")
3916 .icon(IconName::Plus)
3917 .icon_color(Color::Muted)
3918 .handler({
3919 move |window, cx| {
3920 window
3921 .dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
3922 }
3923 }),
3924 )
3925 }))
3926 })
3927 };
3928
3929 let is_thread_loading = self
3930 .active_conversation_view()
3931 .map(|thread| thread.read(cx).is_loading())
3932 .unwrap_or(false);
3933
3934 let has_custom_icon = selected_agent_custom_icon.is_some();
3935 let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
3936 let selected_agent_builtin_icon = self.selected_agent.icon();
3937 let selected_agent_label_for_tooltip = selected_agent_label.clone();
3938
3939 let selected_agent = div()
3940 .id("selected_agent_icon")
3941 .when_some(selected_agent_custom_icon, |this, icon_path| {
3942 this.px_1()
3943 .child(Icon::from_external_svg(icon_path).color(Color::Muted))
3944 })
3945 .when(!has_custom_icon, |this| {
3946 this.when_some(selected_agent_builtin_icon, |this, icon| {
3947 this.px_1().child(Icon::new(icon).color(Color::Muted))
3948 })
3949 })
3950 .tooltip(move |_, cx| {
3951 Tooltip::with_meta(
3952 selected_agent_label_for_tooltip.clone(),
3953 None,
3954 "Selected Agent",
3955 cx,
3956 )
3957 });
3958
3959 let selected_agent = if is_thread_loading {
3960 selected_agent
3961 .with_animation(
3962 "pulsating-icon",
3963 Animation::new(Duration::from_secs(1))
3964 .repeat()
3965 .with_easing(pulsating_between(0.2, 0.6)),
3966 |icon, delta| icon.opacity(delta),
3967 )
3968 .into_any_element()
3969 } else {
3970 selected_agent.into_any_element()
3971 };
3972
3973 let is_empty_state = !self.active_thread_has_messages(cx);
3974
3975 let is_in_history_or_config = matches!(
3976 &self.active_view,
3977 ActiveView::History { .. } | ActiveView::Configuration
3978 );
3979
3980 let is_full_screen = self.is_zoomed(window, cx);
3981 let full_screen_button = if is_full_screen {
3982 IconButton::new("disable-full-screen", IconName::Minimize)
3983 .icon_size(IconSize::Small)
3984 .tooltip(move |_, cx| Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx))
3985 .on_click(cx.listener(move |this, _, window, cx| {
3986 this.toggle_zoom(&ToggleZoom, window, cx);
3987 }))
3988 } else {
3989 IconButton::new("enable-full-screen", IconName::Maximize)
3990 .icon_size(IconSize::Small)
3991 .tooltip(move |_, cx| Tooltip::for_action("Enable Full Screen", &ToggleZoom, cx))
3992 .on_click(cx.listener(move |this, _, window, cx| {
3993 this.toggle_zoom(&ToggleZoom, window, cx);
3994 }))
3995 };
3996
3997 let use_v2_empty_toolbar = is_empty_state && !is_in_history_or_config;
3998
3999 let max_content_width = AgentSettings::get_global(cx).max_content_width;
4000
4001 let base_container = h_flex()
4002 .size_full()
4003 // TODO: This is only until we remove Agent settings from the panel.
4004 .when(!is_in_history_or_config, |this| {
4005 this.max_w(max_content_width).mx_auto()
4006 })
4007 .flex_none()
4008 .justify_between()
4009 .gap_2();
4010
4011 let toolbar_content = if use_v2_empty_toolbar {
4012 let (chevron_icon, icon_color, label_color) =
4013 if self.new_thread_menu_handle.is_deployed() {
4014 (IconName::ChevronUp, Color::Accent, Color::Accent)
4015 } else {
4016 (IconName::ChevronDown, Color::Muted, Color::Default)
4017 };
4018
4019 let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
4020 Icon::from_external_svg(icon_path)
4021 .size(IconSize::Small)
4022 .color(icon_color)
4023 } else {
4024 let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
4025 Icon::new(icon_name).size(IconSize::Small).color(icon_color)
4026 };
4027
4028 let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
4029 .start_icon(agent_icon)
4030 .color(label_color)
4031 .end_icon(
4032 Icon::new(chevron_icon)
4033 .color(icon_color)
4034 .size(IconSize::XSmall),
4035 );
4036
4037 let agent_selector_menu = PopoverMenu::new("new_thread_menu")
4038 .trigger_with_tooltip(agent_selector_button, {
4039 move |_window, cx| {
4040 Tooltip::for_action_in(
4041 "New Thread…",
4042 &ToggleNewThreadMenu,
4043 &focus_handle,
4044 cx,
4045 )
4046 }
4047 })
4048 .menu({
4049 let builder = new_thread_menu_builder.clone();
4050 move |window, cx| builder(window, cx)
4051 })
4052 .with_handle(self.new_thread_menu_handle.clone())
4053 .anchor(Corner::TopLeft)
4054 .offset(gpui::Point {
4055 x: px(1.0),
4056 y: px(1.0),
4057 });
4058
4059 base_container
4060 .child(
4061 h_flex()
4062 .size_full()
4063 .gap(DynamicSpacing::Base04.rems(cx))
4064 .pl(DynamicSpacing::Base04.rems(cx))
4065 .child(agent_selector_menu)
4066 .when(
4067 has_visible_worktrees && self.project_has_git_repository(cx),
4068 |this| this.child(self.render_start_thread_in_selector(cx)),
4069 )
4070 .when(
4071 matches!(self.start_thread_in, StartThreadIn::NewWorktree { .. }),
4072 |this| this.child(self.render_new_worktree_branch_selector(cx)),
4073 ),
4074 )
4075 .child(
4076 h_flex()
4077 .h_full()
4078 .flex_none()
4079 .gap_1()
4080 .pl_1()
4081 .pr_1()
4082 .child(full_screen_button)
4083 .child(self.render_panel_options_menu(window, cx)),
4084 )
4085 .into_any_element()
4086 } else {
4087 let new_thread_menu = PopoverMenu::new("new_thread_menu")
4088 .trigger_with_tooltip(
4089 IconButton::new("new_thread_menu_btn", IconName::Plus)
4090 .icon_size(IconSize::Small),
4091 {
4092 move |_window, cx| {
4093 Tooltip::for_action_in(
4094 "New Thread\u{2026}",
4095 &ToggleNewThreadMenu,
4096 &focus_handle,
4097 cx,
4098 )
4099 }
4100 },
4101 )
4102 .anchor(Corner::TopRight)
4103 .with_handle(self.new_thread_menu_handle.clone())
4104 .menu(move |window, cx| new_thread_menu_builder(window, cx));
4105
4106 base_container
4107 .child(
4108 h_flex()
4109 .size_full()
4110 .gap(DynamicSpacing::Base04.rems(cx))
4111 .pl(DynamicSpacing::Base04.rems(cx))
4112 .child(match &self.active_view {
4113 ActiveView::History { .. } | ActiveView::Configuration => {
4114 self.render_toolbar_back_button(cx).into_any_element()
4115 }
4116 _ => selected_agent.into_any_element(),
4117 })
4118 .child(self.render_title_view(window, cx)),
4119 )
4120 .child(
4121 h_flex()
4122 .h_full()
4123 .flex_none()
4124 .gap_1()
4125 .pl_1()
4126 .pr_1()
4127 .child(new_thread_menu)
4128 .child(full_screen_button)
4129 .child(self.render_panel_options_menu(window, cx)),
4130 )
4131 .into_any_element()
4132 };
4133
4134 h_flex()
4135 .id("agent-panel-toolbar")
4136 .h(Tab::container_height(cx))
4137 .flex_shrink_0()
4138 .max_w_full()
4139 .bg(cx.theme().colors().tab_bar_background)
4140 .border_b_1()
4141 .border_color(cx.theme().colors().border)
4142 .child(toolbar_content)
4143 }
4144
4145 fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4146 let status = self.worktree_creation_status.as_ref()?;
4147 match status {
4148 WorktreeCreationStatus::Creating => Some(
4149 h_flex()
4150 .absolute()
4151 .bottom_12()
4152 .w_full()
4153 .p_2()
4154 .gap_1()
4155 .justify_center()
4156 .bg(cx.theme().colors().editor_background)
4157 .child(
4158 Icon::new(IconName::LoadCircle)
4159 .size(IconSize::Small)
4160 .color(Color::Muted)
4161 .with_rotate_animation(3),
4162 )
4163 .child(
4164 Label::new("Creating Worktree…")
4165 .color(Color::Muted)
4166 .size(LabelSize::Small),
4167 )
4168 .into_any_element(),
4169 ),
4170 WorktreeCreationStatus::Error(message) => Some(
4171 Callout::new()
4172 .icon(IconName::XCircleFilled)
4173 .severity(Severity::Error)
4174 .title("Worktree Creation Error")
4175 .description(message.clone())
4176 .border_position(ui::BorderPosition::Bottom)
4177 .dismiss_action(
4178 IconButton::new("dismiss-worktree-error", IconName::Close)
4179 .icon_size(IconSize::Small)
4180 .tooltip(Tooltip::text("Dismiss"))
4181 .on_click(cx.listener(|this, _, _, cx| {
4182 this.worktree_creation_status = None;
4183 cx.notify();
4184 })),
4185 )
4186 .into_any_element(),
4187 ),
4188 }
4189 }
4190
4191 fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
4192 if TrialEndUpsell::dismissed(cx) {
4193 return false;
4194 }
4195
4196 match &self.active_view {
4197 ActiveView::AgentThread { .. } => {
4198 if LanguageModelRegistry::global(cx)
4199 .read(cx)
4200 .default_model()
4201 .is_some_and(|model| {
4202 model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4203 })
4204 {
4205 return false;
4206 }
4207 }
4208 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4209 return false;
4210 }
4211 }
4212
4213 let plan = self.user_store.read(cx).plan();
4214 let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
4215
4216 plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
4217 }
4218
4219 fn should_render_agent_layout_onboarding(&self, cx: &mut Context<Self>) -> bool {
4220 // We only want to show this for existing users: those who
4221 // have used the agent panel before the sidebar was introduced.
4222 // We can infer that state by users having seen the onboarding
4223 // at one point, but not the agent layout onboarding.
4224
4225 let has_messages = self.active_thread_has_messages(cx);
4226 let is_dismissed = self
4227 .agent_layout_onboarding_dismissed
4228 .load(Ordering::Acquire);
4229
4230 if is_dismissed || has_messages {
4231 return false;
4232 }
4233
4234 match &self.active_view {
4235 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4236 false
4237 }
4238 ActiveView::AgentThread { .. } => {
4239 let existing_user = self
4240 .new_user_onboarding_upsell_dismissed
4241 .load(Ordering::Acquire);
4242 existing_user
4243 }
4244 }
4245 }
4246
4247 fn render_agent_layout_onboarding(
4248 &self,
4249 _window: &mut Window,
4250 cx: &mut Context<Self>,
4251 ) -> Option<impl IntoElement> {
4252 if !self.should_render_agent_layout_onboarding(cx) {
4253 return None;
4254 }
4255
4256 Some(div().child(self.agent_layout_onboarding.clone()))
4257 }
4258
4259 fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context<Self>) {
4260 self.agent_layout_onboarding_dismissed
4261 .store(true, Ordering::Release);
4262 AgentLayoutOnboarding::set_dismissed(true, cx);
4263 cx.notify();
4264 }
4265
4266 fn dismiss_ai_onboarding(&mut self, cx: &mut Context<Self>) {
4267 self.new_user_onboarding_upsell_dismissed
4268 .store(true, Ordering::Release);
4269 OnboardingUpsell::set_dismissed(true, cx);
4270 self.dismiss_agent_layout_onboarding(cx);
4271 cx.notify();
4272 }
4273
4274 fn should_render_new_user_onboarding(&mut self, cx: &mut Context<Self>) -> bool {
4275 if self
4276 .new_user_onboarding_upsell_dismissed
4277 .load(Ordering::Acquire)
4278 {
4279 return false;
4280 }
4281
4282 let user_store = self.user_store.read(cx);
4283
4284 if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
4285 && user_store
4286 .subscription_period()
4287 .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
4288 .is_some_and(|date| date < chrono::Utc::now())
4289 {
4290 if !self
4291 .new_user_onboarding_upsell_dismissed
4292 .load(Ordering::Acquire)
4293 {
4294 self.dismiss_ai_onboarding(cx);
4295 }
4296 return false;
4297 }
4298
4299 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
4300 .visible_providers()
4301 .iter()
4302 .any(|provider| {
4303 provider.is_authenticated(cx)
4304 && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4305 });
4306
4307 match &self.active_view {
4308 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4309 false
4310 }
4311 ActiveView::AgentThread {
4312 conversation_view, ..
4313 } if conversation_view.read(cx).as_native_thread(cx).is_none() => false,
4314 ActiveView::AgentThread { conversation_view } => {
4315 let history_is_empty = conversation_view
4316 .read(cx)
4317 .history()
4318 .is_none_or(|h| h.read(cx).is_empty());
4319 history_is_empty || !has_configured_non_zed_providers
4320 }
4321 }
4322 }
4323
4324 fn render_new_user_onboarding(
4325 &mut self,
4326 _window: &mut Window,
4327 cx: &mut Context<Self>,
4328 ) -> Option<impl IntoElement> {
4329 if !self.should_render_new_user_onboarding(cx) {
4330 return None;
4331 }
4332
4333 Some(
4334 div()
4335 .bg(cx.theme().colors().editor_background)
4336 .child(self.new_user_onboarding.clone()),
4337 )
4338 }
4339
4340 fn render_trial_end_upsell(
4341 &self,
4342 _window: &mut Window,
4343 cx: &mut Context<Self>,
4344 ) -> Option<impl IntoElement> {
4345 if !self.should_render_trial_end_upsell(cx) {
4346 return None;
4347 }
4348
4349 Some(
4350 v_flex()
4351 .absolute()
4352 .inset_0()
4353 .size_full()
4354 .bg(cx.theme().colors().panel_background)
4355 .opacity(0.85)
4356 .block_mouse_except_scroll()
4357 .child(EndTrialUpsell::new(Arc::new({
4358 let this = cx.entity();
4359 move |_, cx| {
4360 this.update(cx, |_this, cx| {
4361 TrialEndUpsell::set_dismissed(true, cx);
4362 cx.notify();
4363 });
4364 }
4365 }))),
4366 )
4367 }
4368
4369 fn render_drag_target(&self, cx: &Context<Self>) -> Div {
4370 let is_local = self.project.read(cx).is_local();
4371 div()
4372 .invisible()
4373 .absolute()
4374 .top_0()
4375 .right_0()
4376 .bottom_0()
4377 .left_0()
4378 .bg(cx.theme().colors().drop_target_background)
4379 .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
4380 .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
4381 .when(is_local, |this| {
4382 this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
4383 })
4384 .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
4385 let item = tab.pane.read(cx).item_for_index(tab.ix);
4386 let project_paths = item
4387 .and_then(|item| item.project_path(cx))
4388 .into_iter()
4389 .collect::<Vec<_>>();
4390 this.handle_drop(project_paths, vec![], window, cx);
4391 }))
4392 .on_drop(
4393 cx.listener(move |this, selection: &DraggedSelection, window, cx| {
4394 let project_paths = selection
4395 .items()
4396 .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
4397 .collect::<Vec<_>>();
4398 this.handle_drop(project_paths, vec![], window, cx);
4399 }),
4400 )
4401 .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
4402 let tasks = paths
4403 .paths()
4404 .iter()
4405 .map(|path| {
4406 Workspace::project_path_for_path(this.project.clone(), path, false, cx)
4407 })
4408 .collect::<Vec<_>>();
4409 cx.spawn_in(window, async move |this, cx| {
4410 let mut paths = vec![];
4411 let mut added_worktrees = vec![];
4412 let opened_paths = futures::future::join_all(tasks).await;
4413 for entry in opened_paths {
4414 if let Some((worktree, project_path)) = entry.log_err() {
4415 added_worktrees.push(worktree);
4416 paths.push(project_path);
4417 }
4418 }
4419 this.update_in(cx, |this, window, cx| {
4420 this.handle_drop(paths, added_worktrees, window, cx);
4421 })
4422 .ok();
4423 })
4424 .detach();
4425 }))
4426 }
4427
4428 fn handle_drop(
4429 &mut self,
4430 paths: Vec<ProjectPath>,
4431 added_worktrees: Vec<Entity<Worktree>>,
4432 window: &mut Window,
4433 cx: &mut Context<Self>,
4434 ) {
4435 match &self.active_view {
4436 ActiveView::AgentThread { conversation_view } => {
4437 conversation_view.update(cx, |conversation_view, cx| {
4438 conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
4439 });
4440 }
4441 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4442 }
4443 }
4444
4445 fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
4446 if !self.show_trust_workspace_message {
4447 return None;
4448 }
4449
4450 let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
4451
4452 Some(
4453 Callout::new()
4454 .icon(IconName::Warning)
4455 .severity(Severity::Warning)
4456 .border_position(ui::BorderPosition::Bottom)
4457 .title("You're in Restricted Mode")
4458 .description(description)
4459 .actions_slot(
4460 Button::new("open-trust-modal", "Configure Project Trust")
4461 .label_size(LabelSize::Small)
4462 .style(ButtonStyle::Outlined)
4463 .on_click({
4464 cx.listener(move |this, _, window, cx| {
4465 this.workspace
4466 .update(cx, |workspace, cx| {
4467 workspace
4468 .show_worktree_trust_security_modal(true, window, cx)
4469 })
4470 .log_err();
4471 })
4472 }),
4473 ),
4474 )
4475 }
4476
4477 fn key_context(&self) -> KeyContext {
4478 let mut key_context = KeyContext::new_with_defaults();
4479 key_context.add("AgentPanel");
4480 match &self.active_view {
4481 ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
4482 ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4483 }
4484 key_context
4485 }
4486}
4487
4488impl Render for AgentPanel {
4489 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4490 // WARNING: Changes to this element hierarchy can have
4491 // non-obvious implications to the layout of children.
4492 //
4493 // If you need to change it, please confirm:
4494 // - The message editor expands (cmd-option-esc) correctly
4495 // - When expanded, the buttons at the bottom of the panel are displayed correctly
4496 // - Font size works as expected and can be changed with cmd-+/cmd-
4497 // - Scrolling in all views works as expected
4498 // - Files can be dropped into the panel
4499 let content = v_flex()
4500 .relative()
4501 .size_full()
4502 .justify_between()
4503 .key_context(self.key_context())
4504 .on_action(cx.listener(|this, action: &NewThread, window, cx| {
4505 this.new_thread(action, window, cx);
4506 }))
4507 .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
4508 this.open_history(window, cx);
4509 }))
4510 .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
4511 this.open_configuration(window, cx);
4512 }))
4513 .on_action(cx.listener(Self::open_active_thread_as_markdown))
4514 .on_action(cx.listener(Self::deploy_rules_library))
4515 .on_action(cx.listener(Self::go_back))
4516 .on_action(cx.listener(Self::toggle_navigation_menu))
4517 .on_action(cx.listener(Self::toggle_options_menu))
4518 .on_action(cx.listener(Self::increase_font_size))
4519 .on_action(cx.listener(Self::decrease_font_size))
4520 .on_action(cx.listener(Self::reset_font_size))
4521 .on_action(cx.listener(Self::toggle_zoom))
4522 .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
4523 if let Some(conversation_view) = this.active_conversation_view() {
4524 conversation_view.update(cx, |conversation_view, cx| {
4525 conversation_view.reauthenticate(window, cx)
4526 })
4527 }
4528 }))
4529 .child(self.render_toolbar(window, cx))
4530 .children(self.render_workspace_trust_message(cx))
4531 .children(self.render_new_user_onboarding(window, cx))
4532 .children(self.render_agent_layout_onboarding(window, cx))
4533 .map(|parent| match &self.active_view {
4534 ActiveView::Uninitialized => parent,
4535 ActiveView::AgentThread {
4536 conversation_view, ..
4537 } => parent
4538 .child(conversation_view.clone())
4539 .child(self.render_drag_target(cx)),
4540 ActiveView::History { view } => parent.child(view.clone()),
4541 ActiveView::Configuration => parent.children(self.configuration.clone()),
4542 })
4543 .children(self.render_worktree_creation_status(cx))
4544 .children(self.render_trial_end_upsell(window, cx));
4545
4546 match self.active_view.which_font_size_used() {
4547 WhichFontSize::AgentFont => {
4548 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
4549 .size_full()
4550 .child(content)
4551 .into_any()
4552 }
4553 _ => content.into_any(),
4554 }
4555 }
4556}
4557
4558struct PromptLibraryInlineAssist {
4559 workspace: WeakEntity<Workspace>,
4560}
4561
4562impl PromptLibraryInlineAssist {
4563 pub fn new(workspace: WeakEntity<Workspace>) -> Self {
4564 Self { workspace }
4565 }
4566}
4567
4568impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
4569 fn assist(
4570 &self,
4571 prompt_editor: &Entity<Editor>,
4572 initial_prompt: Option<String>,
4573 window: &mut Window,
4574 cx: &mut Context<RulesLibrary>,
4575 ) {
4576 InlineAssistant::update_global(cx, |assistant, cx| {
4577 let Some(workspace) = self.workspace.upgrade() else {
4578 return;
4579 };
4580 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4581 return;
4582 };
4583 let history = panel
4584 .read(cx)
4585 .connection_store()
4586 .read(cx)
4587 .entry(&crate::Agent::NativeAgent)
4588 .and_then(|s| s.read(cx).history())
4589 .map(|h| h.downgrade());
4590 let project = workspace.read(cx).project().downgrade();
4591 let panel = panel.read(cx);
4592 let thread_store = panel.thread_store().clone();
4593 assistant.assist(
4594 prompt_editor,
4595 self.workspace.clone(),
4596 project,
4597 thread_store,
4598 None,
4599 history,
4600 initial_prompt,
4601 window,
4602 cx,
4603 );
4604 })
4605 }
4606
4607 fn focus_agent_panel(
4608 &self,
4609 workspace: &mut Workspace,
4610 window: &mut Window,
4611 cx: &mut Context<Workspace>,
4612 ) -> bool {
4613 workspace.focus_panel::<AgentPanel>(window, cx).is_some()
4614 }
4615}
4616
4617struct OnboardingUpsell;
4618
4619impl Dismissable for OnboardingUpsell {
4620 const KEY: &'static str = "dismissed-trial-upsell";
4621}
4622
4623struct AgentLayoutOnboarding;
4624
4625impl Dismissable for AgentLayoutOnboarding {
4626 const KEY: &'static str = "dismissed-agent-layout-onboarding";
4627}
4628
4629struct TrialEndUpsell;
4630
4631impl Dismissable for TrialEndUpsell {
4632 const KEY: &'static str = "dismissed-trial-end-upsell";
4633}
4634
4635/// Test-only helper methods
4636#[cfg(any(test, feature = "test-support"))]
4637impl AgentPanel {
4638 pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
4639 Self::new(workspace, None, window, cx)
4640 }
4641
4642 /// Opens an external thread using an arbitrary AgentServer.
4643 ///
4644 /// This is a test-only helper that allows visual tests and integration tests
4645 /// to inject a stub server without modifying production code paths.
4646 /// Not compiled into production builds.
4647 pub fn open_external_thread_with_server(
4648 &mut self,
4649 server: Rc<dyn AgentServer>,
4650 window: &mut Window,
4651 cx: &mut Context<Self>,
4652 ) {
4653 let workspace = self.workspace.clone();
4654 let project = self.project.clone();
4655
4656 let ext_agent = Agent::Custom {
4657 id: server.agent_id(),
4658 };
4659
4660 self.create_agent_thread(
4661 server, None, None, None, None, workspace, project, ext_agent, true, window, cx,
4662 );
4663 }
4664
4665 /// Returns the currently active thread view, if any.
4666 ///
4667 /// This is a test-only accessor that exposes the private `active_thread_view()`
4668 /// method for test assertions. Not compiled into production builds.
4669 pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConversationView>> {
4670 self.active_conversation_view()
4671 }
4672
4673 /// Sets the start_thread_in value directly, bypassing validation.
4674 ///
4675 /// This is a test-only helper for visual tests that need to show specific
4676 /// start_thread_in states without requiring a real git repository.
4677 pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
4678 self.start_thread_in = target;
4679 cx.notify();
4680 }
4681
4682 /// Returns the current worktree creation status.
4683 ///
4684 /// This is a test-only helper for visual tests.
4685 pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
4686 self.worktree_creation_status.as_ref()
4687 }
4688
4689 /// Sets the worktree creation status directly.
4690 ///
4691 /// This is a test-only helper for visual tests that need to show the
4692 /// "Creating worktree…" spinner or error banners.
4693 pub fn set_worktree_creation_status_for_tests(
4694 &mut self,
4695 status: Option<WorktreeCreationStatus>,
4696 cx: &mut Context<Self>,
4697 ) {
4698 self.worktree_creation_status = status;
4699 cx.notify();
4700 }
4701
4702 /// Opens the history view.
4703 ///
4704 /// This is a test-only helper that exposes the private `open_history()`
4705 /// method for visual tests.
4706 pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4707 self.open_history(window, cx);
4708 }
4709
4710 /// Opens the start_thread_in selector popover menu.
4711 ///
4712 /// This is a test-only helper for visual tests.
4713 pub fn open_start_thread_in_menu_for_tests(
4714 &mut self,
4715 window: &mut Window,
4716 cx: &mut Context<Self>,
4717 ) {
4718 self.start_thread_in_menu_handle.show(window, cx);
4719 }
4720
4721 /// Dismisses the start_thread_in dropdown menu.
4722 ///
4723 /// This is a test-only helper for visual tests.
4724 pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
4725 self.start_thread_in_menu_handle.hide(cx);
4726 }
4727}
4728
4729#[cfg(test)]
4730mod tests {
4731 use super::*;
4732 use crate::conversation_view::tests::{StubAgentServer, init_test};
4733 use crate::test_support::{
4734 active_session_id, open_thread_with_connection, open_thread_with_custom_connection,
4735 send_message,
4736 };
4737 use acp_thread::{StubAgentConnection, ThreadStatus};
4738 use agent_servers::CODEX_ID;
4739 use fs::FakeFs;
4740 use gpui::{TestAppContext, VisualTestContext};
4741 use project::Project;
4742 use serde_json::json;
4743 use std::path::Path;
4744 use std::time::Instant;
4745 use workspace::MultiWorkspace;
4746
4747 #[gpui::test]
4748 async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
4749 init_test(cx);
4750 cx.update(|cx| {
4751 agent::ThreadStore::init_global(cx);
4752 language_model::LanguageModelRegistry::test(cx);
4753 });
4754
4755 // --- Create a MultiWorkspace window with two workspaces ---
4756 let fs = FakeFs::new(cx.executor());
4757 let project_a = Project::test(fs.clone(), [], cx).await;
4758 let project_b = Project::test(fs, [], cx).await;
4759
4760 let multi_workspace =
4761 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4762
4763 let workspace_a = multi_workspace
4764 .read_with(cx, |multi_workspace, _cx| {
4765 multi_workspace.workspace().clone()
4766 })
4767 .unwrap();
4768
4769 let workspace_b = multi_workspace
4770 .update(cx, |multi_workspace, window, cx| {
4771 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
4772 })
4773 .unwrap();
4774
4775 workspace_a.update(cx, |workspace, _cx| {
4776 workspace.set_random_database_id();
4777 });
4778 workspace_b.update(cx, |workspace, _cx| {
4779 workspace.set_random_database_id();
4780 });
4781
4782 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4783
4784 // --- Set up workspace A: with an active thread ---
4785 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
4786 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4787 });
4788
4789 panel_a.update_in(cx, |panel, window, cx| {
4790 panel.open_external_thread_with_server(
4791 Rc::new(StubAgentServer::default_response()),
4792 window,
4793 cx,
4794 );
4795 });
4796
4797 cx.run_until_parked();
4798
4799 panel_a.read_with(cx, |panel, cx| {
4800 assert!(
4801 panel.active_agent_thread(cx).is_some(),
4802 "workspace A should have an active thread after connection"
4803 );
4804 });
4805
4806 send_message(&panel_a, cx);
4807
4808 let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
4809
4810 // --- Set up workspace B: ClaudeCode, no active thread ---
4811 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
4812 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4813 });
4814
4815 panel_b.update(cx, |panel, _cx| {
4816 panel.selected_agent = Agent::Custom {
4817 id: "claude-acp".into(),
4818 };
4819 });
4820
4821 // --- Serialize both panels ---
4822 panel_a.update(cx, |panel, cx| panel.serialize(cx));
4823 panel_b.update(cx, |panel, cx| panel.serialize(cx));
4824 cx.run_until_parked();
4825
4826 // --- Load fresh panels for each workspace and verify independent state ---
4827 let async_cx = cx.update(|window, cx| window.to_async(cx));
4828 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
4829 .await
4830 .expect("panel A load should succeed");
4831 cx.run_until_parked();
4832
4833 let async_cx = cx.update(|window, cx| window.to_async(cx));
4834 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
4835 .await
4836 .expect("panel B load should succeed");
4837 cx.run_until_parked();
4838
4839 // Workspace A should restore its thread and agent type
4840 loaded_a.read_with(cx, |panel, _cx| {
4841 assert_eq!(
4842 panel.selected_agent, agent_type_a,
4843 "workspace A agent type should be restored"
4844 );
4845 assert!(
4846 panel.active_conversation_view().is_some(),
4847 "workspace A should have its active thread restored"
4848 );
4849 });
4850
4851 // Workspace B should restore its own agent type, with no thread
4852 loaded_b.read_with(cx, |panel, _cx| {
4853 assert_eq!(
4854 panel.selected_agent,
4855 Agent::Custom {
4856 id: "claude-acp".into()
4857 },
4858 "workspace B agent type should be restored"
4859 );
4860 assert!(
4861 panel.active_conversation_view().is_none(),
4862 "workspace B should have no active thread"
4863 );
4864 });
4865 }
4866
4867 #[gpui::test]
4868 async fn test_non_native_thread_without_metadata_is_not_restored(cx: &mut TestAppContext) {
4869 init_test(cx);
4870 cx.update(|cx| {
4871 agent::ThreadStore::init_global(cx);
4872 language_model::LanguageModelRegistry::test(cx);
4873 });
4874
4875 let fs = FakeFs::new(cx.executor());
4876 let project = Project::test(fs, [], cx).await;
4877
4878 let multi_workspace =
4879 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4880
4881 let workspace = multi_workspace
4882 .read_with(cx, |multi_workspace, _cx| {
4883 multi_workspace.workspace().clone()
4884 })
4885 .unwrap();
4886
4887 workspace.update(cx, |workspace, _cx| {
4888 workspace.set_random_database_id();
4889 });
4890
4891 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4892
4893 let panel = workspace.update_in(cx, |workspace, window, cx| {
4894 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4895 });
4896
4897 panel.update_in(cx, |panel, window, cx| {
4898 panel.open_external_thread_with_server(
4899 Rc::new(StubAgentServer::default_response()),
4900 window,
4901 cx,
4902 );
4903 });
4904
4905 cx.run_until_parked();
4906
4907 panel.read_with(cx, |panel, cx| {
4908 assert!(
4909 panel.active_agent_thread(cx).is_some(),
4910 "should have an active thread after connection"
4911 );
4912 });
4913
4914 // Serialize without ever sending a message, so no thread metadata exists.
4915 panel.update(cx, |panel, cx| panel.serialize(cx));
4916 cx.run_until_parked();
4917
4918 let async_cx = cx.update(|window, cx| window.to_async(cx));
4919 let loaded = AgentPanel::load(workspace.downgrade(), async_cx)
4920 .await
4921 .expect("panel load should succeed");
4922 cx.run_until_parked();
4923
4924 loaded.read_with(cx, |panel, _cx| {
4925 assert!(
4926 panel.active_conversation_view().is_none(),
4927 "thread without metadata should not be restored"
4928 );
4929 });
4930 }
4931
4932 /// Extracts the text from a Text content block, panicking if it's not Text.
4933 fn expect_text_block(block: &acp::ContentBlock) -> &str {
4934 match block {
4935 acp::ContentBlock::Text(t) => t.text.as_str(),
4936 other => panic!("expected Text block, got {:?}", other),
4937 }
4938 }
4939
4940 /// Extracts the (text_content, uri) from a Resource content block, panicking
4941 /// if it's not a TextResourceContents resource.
4942 fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
4943 match block {
4944 acp::ContentBlock::Resource(r) => match &r.resource {
4945 acp::EmbeddedResourceResource::TextResourceContents(t) => {
4946 (t.text.as_str(), t.uri.as_str())
4947 }
4948 other => panic!("expected TextResourceContents, got {:?}", other),
4949 },
4950 other => panic!("expected Resource block, got {:?}", other),
4951 }
4952 }
4953
4954 #[test]
4955 fn test_build_conflict_resolution_prompt_single_conflict() {
4956 let conflicts = vec![ConflictContent {
4957 file_path: "src/main.rs".to_string(),
4958 conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
4959 .to_string(),
4960 ours_branch_name: "HEAD".to_string(),
4961 theirs_branch_name: "feature".to_string(),
4962 }];
4963
4964 let blocks = build_conflict_resolution_prompt(&conflicts);
4965 // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
4966 assert_eq!(
4967 blocks.len(),
4968 4,
4969 "expected 2 text + 1 resource link + 1 resource block"
4970 );
4971
4972 let intro_text = expect_text_block(&blocks[0]);
4973 assert!(
4974 intro_text.contains("Please resolve the following merge conflict in"),
4975 "prompt should include single-conflict intro text"
4976 );
4977
4978 match &blocks[1] {
4979 acp::ContentBlock::ResourceLink(link) => {
4980 assert!(
4981 link.uri.contains("file://"),
4982 "resource link URI should use file scheme"
4983 );
4984 assert!(
4985 link.uri.contains("main.rs"),
4986 "resource link URI should reference file path"
4987 );
4988 }
4989 other => panic!("expected ResourceLink block, got {:?}", other),
4990 }
4991
4992 let body_text = expect_text_block(&blocks[2]);
4993 assert!(
4994 body_text.contains("`HEAD` (ours)"),
4995 "prompt should mention ours branch"
4996 );
4997 assert!(
4998 body_text.contains("`feature` (theirs)"),
4999 "prompt should mention theirs branch"
5000 );
5001 assert!(
5002 body_text.contains("editing the file directly"),
5003 "prompt should instruct the agent to edit the file"
5004 );
5005
5006 let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
5007 assert!(
5008 resource_text.contains("<<<<<<< HEAD"),
5009 "resource should contain the conflict text"
5010 );
5011 assert!(
5012 resource_uri.contains("merge-conflict"),
5013 "resource URI should use the merge-conflict scheme"
5014 );
5015 assert!(
5016 resource_uri.contains("main.rs"),
5017 "resource URI should reference the file path"
5018 );
5019 }
5020
5021 #[test]
5022 fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
5023 let conflicts = vec![
5024 ConflictContent {
5025 file_path: "src/lib.rs".to_string(),
5026 conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
5027 .to_string(),
5028 ours_branch_name: "main".to_string(),
5029 theirs_branch_name: "dev".to_string(),
5030 },
5031 ConflictContent {
5032 file_path: "src/lib.rs".to_string(),
5033 conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
5034 .to_string(),
5035 ours_branch_name: "main".to_string(),
5036 theirs_branch_name: "dev".to_string(),
5037 },
5038 ];
5039
5040 let blocks = build_conflict_resolution_prompt(&conflicts);
5041 // 1 Text instruction + 2 Resource blocks
5042 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5043
5044 let text = expect_text_block(&blocks[0]);
5045 assert!(
5046 text.contains("all 2 merge conflicts"),
5047 "prompt should mention the total count"
5048 );
5049 assert!(
5050 text.contains("`main` (ours)"),
5051 "prompt should mention ours branch"
5052 );
5053 assert!(
5054 text.contains("`dev` (theirs)"),
5055 "prompt should mention theirs branch"
5056 );
5057 // Single file, so "file" not "files"
5058 assert!(
5059 text.contains("file directly"),
5060 "single file should use singular 'file'"
5061 );
5062
5063 let (resource_a, _) = expect_resource_block(&blocks[1]);
5064 let (resource_b, _) = expect_resource_block(&blocks[2]);
5065 assert!(
5066 resource_a.contains("fn a()"),
5067 "first resource should contain first conflict"
5068 );
5069 assert!(
5070 resource_b.contains("fn b()"),
5071 "second resource should contain second conflict"
5072 );
5073 }
5074
5075 #[test]
5076 fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
5077 let conflicts = vec![
5078 ConflictContent {
5079 file_path: "src/a.rs".to_string(),
5080 conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
5081 ours_branch_name: "main".to_string(),
5082 theirs_branch_name: "dev".to_string(),
5083 },
5084 ConflictContent {
5085 file_path: "src/b.rs".to_string(),
5086 conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
5087 ours_branch_name: "main".to_string(),
5088 theirs_branch_name: "dev".to_string(),
5089 },
5090 ];
5091
5092 let blocks = build_conflict_resolution_prompt(&conflicts);
5093 // 1 Text instruction + 2 Resource blocks
5094 assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5095
5096 let text = expect_text_block(&blocks[0]);
5097 assert!(
5098 text.contains("files directly"),
5099 "multiple files should use plural 'files'"
5100 );
5101
5102 let (_, uri_a) = expect_resource_block(&blocks[1]);
5103 let (_, uri_b) = expect_resource_block(&blocks[2]);
5104 assert!(
5105 uri_a.contains("a.rs"),
5106 "first resource URI should reference a.rs"
5107 );
5108 assert!(
5109 uri_b.contains("b.rs"),
5110 "second resource URI should reference b.rs"
5111 );
5112 }
5113
5114 #[test]
5115 fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
5116 let file_paths = vec![
5117 "src/main.rs".to_string(),
5118 "src/lib.rs".to_string(),
5119 "tests/integration.rs".to_string(),
5120 ];
5121
5122 let blocks = build_conflicted_files_resolution_prompt(&file_paths);
5123 // 1 instruction Text block + (ResourceLink + newline Text) per file
5124 assert_eq!(
5125 blocks.len(),
5126 1 + (file_paths.len() * 2),
5127 "expected instruction text plus resource links and separators"
5128 );
5129
5130 let text = expect_text_block(&blocks[0]);
5131 assert!(
5132 text.contains("unresolved merge conflicts"),
5133 "prompt should describe the task"
5134 );
5135 assert!(
5136 text.contains("conflict markers"),
5137 "prompt should mention conflict markers"
5138 );
5139
5140 for (index, path) in file_paths.iter().enumerate() {
5141 let link_index = 1 + (index * 2);
5142 let newline_index = link_index + 1;
5143
5144 match &blocks[link_index] {
5145 acp::ContentBlock::ResourceLink(link) => {
5146 assert!(
5147 link.uri.contains("file://"),
5148 "resource link URI should use file scheme"
5149 );
5150 assert!(
5151 link.uri.contains(path),
5152 "resource link URI should reference file path: {path}"
5153 );
5154 }
5155 other => panic!(
5156 "expected ResourceLink block at index {}, got {:?}",
5157 link_index, other
5158 ),
5159 }
5160
5161 let separator = expect_text_block(&blocks[newline_index]);
5162 assert_eq!(
5163 separator, "\n",
5164 "expected newline separator after each file"
5165 );
5166 }
5167 }
5168
5169 #[test]
5170 fn test_build_conflict_resolution_prompt_empty_conflicts() {
5171 let blocks = build_conflict_resolution_prompt(&[]);
5172 assert!(
5173 blocks.is_empty(),
5174 "empty conflicts should produce no blocks, got {} blocks",
5175 blocks.len()
5176 );
5177 }
5178
5179 #[test]
5180 fn test_build_conflicted_files_resolution_prompt_empty_paths() {
5181 let blocks = build_conflicted_files_resolution_prompt(&[]);
5182 assert!(
5183 blocks.is_empty(),
5184 "empty paths should produce no blocks, got {} blocks",
5185 blocks.len()
5186 );
5187 }
5188
5189 #[test]
5190 fn test_conflict_resource_block_structure() {
5191 let conflict = ConflictContent {
5192 file_path: "src/utils.rs".to_string(),
5193 conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
5194 ours_branch_name: "HEAD".to_string(),
5195 theirs_branch_name: "branch".to_string(),
5196 };
5197
5198 let block = conflict_resource_block(&conflict);
5199 let (text, uri) = expect_resource_block(&block);
5200
5201 assert_eq!(
5202 text, conflict.conflict_text,
5203 "resource text should be the raw conflict"
5204 );
5205 assert!(
5206 uri.starts_with("zed:///agent/merge-conflict"),
5207 "URI should use the zed merge-conflict scheme, got: {uri}"
5208 );
5209 assert!(uri.contains("utils.rs"), "URI should encode the file path");
5210 }
5211
5212 fn open_generating_thread_with_loadable_connection(
5213 panel: &Entity<AgentPanel>,
5214 connection: &StubAgentConnection,
5215 cx: &mut VisualTestContext,
5216 ) -> acp::SessionId {
5217 open_thread_with_custom_connection(panel, connection.clone(), cx);
5218 let session_id = active_session_id(panel, cx);
5219 send_message(panel, cx);
5220 cx.update(|_, cx| {
5221 connection.send_update(
5222 session_id.clone(),
5223 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
5224 cx,
5225 );
5226 });
5227 cx.run_until_parked();
5228 session_id
5229 }
5230
5231 fn open_idle_thread_with_non_loadable_connection(
5232 panel: &Entity<AgentPanel>,
5233 connection: &StubAgentConnection,
5234 cx: &mut VisualTestContext,
5235 ) -> acp::SessionId {
5236 open_thread_with_custom_connection(panel, connection.clone(), cx);
5237 let session_id = active_session_id(panel, cx);
5238
5239 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5240 acp::ContentChunk::new("done".into()),
5241 )]);
5242 send_message(panel, cx);
5243
5244 session_id
5245 }
5246
5247 async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
5248 init_test(cx);
5249 cx.update(|cx| {
5250 agent::ThreadStore::init_global(cx);
5251 language_model::LanguageModelRegistry::test(cx);
5252 });
5253
5254 let fs = FakeFs::new(cx.executor());
5255 let project = Project::test(fs.clone(), [], cx).await;
5256
5257 let multi_workspace =
5258 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5259
5260 let workspace = multi_workspace
5261 .read_with(cx, |mw, _cx| mw.workspace().clone())
5262 .unwrap();
5263
5264 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
5265
5266 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
5267 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5268 });
5269
5270 (panel, cx)
5271 }
5272
5273 #[gpui::test]
5274 async fn test_empty_draft_thread_not_retained_when_navigating_away(cx: &mut TestAppContext) {
5275 let (panel, mut cx) = setup_panel(cx).await;
5276
5277 let connection_a = StubAgentConnection::new();
5278 open_thread_with_connection(&panel, connection_a, &mut cx);
5279 let session_id_a = active_session_id(&panel, &cx);
5280
5281 panel.read_with(&cx, |panel, cx| {
5282 let thread = panel.active_agent_thread(cx).unwrap();
5283 assert!(
5284 thread.read(cx).entries().is_empty(),
5285 "newly opened draft thread should have no entries"
5286 );
5287 assert!(panel.background_threads.is_empty());
5288 });
5289
5290 let connection_b = StubAgentConnection::new();
5291 open_thread_with_connection(&panel, connection_b, &mut cx);
5292
5293 panel.read_with(&cx, |panel, _cx| {
5294 assert!(
5295 panel.background_threads.is_empty(),
5296 "empty draft thread should not be retained in background_threads"
5297 );
5298 assert!(
5299 !panel.background_threads.contains_key(&session_id_a),
5300 "empty draft thread should not be keyed in background_threads"
5301 );
5302 });
5303 }
5304
5305 #[gpui::test]
5306 async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5307 let (panel, mut cx) = setup_panel(cx).await;
5308
5309 let connection_a = StubAgentConnection::new();
5310 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5311 send_message(&panel, &mut cx);
5312
5313 let session_id_a = active_session_id(&panel, &cx);
5314
5315 // Send a chunk to keep thread A generating (don't end the turn).
5316 cx.update(|_, cx| {
5317 connection_a.send_update(
5318 session_id_a.clone(),
5319 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5320 cx,
5321 );
5322 });
5323 cx.run_until_parked();
5324
5325 // Verify thread A is generating.
5326 panel.read_with(&cx, |panel, cx| {
5327 let thread = panel.active_agent_thread(cx).unwrap();
5328 assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
5329 assert!(panel.background_threads.is_empty());
5330 });
5331
5332 // Open a new thread B — thread A should be retained in background.
5333 let connection_b = StubAgentConnection::new();
5334 open_thread_with_connection(&panel, connection_b, &mut cx);
5335
5336 panel.read_with(&cx, |panel, _cx| {
5337 assert_eq!(
5338 panel.background_threads.len(),
5339 1,
5340 "Running thread A should be retained in background_views"
5341 );
5342 assert!(
5343 panel.background_threads.contains_key(&session_id_a),
5344 "Background view should be keyed by thread A's session ID"
5345 );
5346 });
5347 }
5348
5349 #[gpui::test]
5350 async fn test_idle_non_loadable_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5351 let (panel, mut cx) = setup_panel(cx).await;
5352
5353 let connection_a = StubAgentConnection::new();
5354 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5355 acp::ContentChunk::new("Response".into()),
5356 )]);
5357 open_thread_with_connection(&panel, connection_a, &mut cx);
5358 send_message(&panel, &mut cx);
5359
5360 let weak_view_a = panel.read_with(&cx, |panel, _cx| {
5361 panel.active_conversation_view().unwrap().downgrade()
5362 });
5363 let session_id_a = active_session_id(&panel, &cx);
5364
5365 // Thread A should be idle (auto-completed via set_next_prompt_updates).
5366 panel.read_with(&cx, |panel, cx| {
5367 let thread = panel.active_agent_thread(cx).unwrap();
5368 assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
5369 });
5370
5371 // Open a new thread B — thread A should be retained because it is not loadable.
5372 let connection_b = StubAgentConnection::new();
5373 open_thread_with_connection(&panel, connection_b, &mut cx);
5374
5375 panel.read_with(&cx, |panel, _cx| {
5376 assert_eq!(
5377 panel.background_threads.len(),
5378 1,
5379 "Idle non-loadable thread A should be retained in background_views"
5380 );
5381 assert!(
5382 panel.background_threads.contains_key(&session_id_a),
5383 "Background view should be keyed by thread A's session ID"
5384 );
5385 });
5386
5387 assert!(
5388 weak_view_a.upgrade().is_some(),
5389 "Idle non-loadable ConnectionView should still be retained"
5390 );
5391 }
5392
5393 #[gpui::test]
5394 async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
5395 let (panel, mut cx) = setup_panel(cx).await;
5396
5397 let connection_a = StubAgentConnection::new();
5398 open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5399 send_message(&panel, &mut cx);
5400
5401 let session_id_a = active_session_id(&panel, &cx);
5402
5403 // Keep thread A generating.
5404 cx.update(|_, cx| {
5405 connection_a.send_update(
5406 session_id_a.clone(),
5407 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5408 cx,
5409 );
5410 });
5411 cx.run_until_parked();
5412
5413 // Open thread B — thread A goes to background.
5414 let connection_b = StubAgentConnection::new();
5415 open_thread_with_connection(&panel, connection_b, &mut cx);
5416 send_message(&panel, &mut cx);
5417
5418 let session_id_b = active_session_id(&panel, &cx);
5419
5420 panel.read_with(&cx, |panel, _cx| {
5421 assert_eq!(panel.background_threads.len(), 1);
5422 assert!(panel.background_threads.contains_key(&session_id_a));
5423 });
5424
5425 // Load thread A back via load_agent_thread — should promote from background.
5426 panel.update_in(&mut cx, |panel, window, cx| {
5427 panel.load_agent_thread(
5428 panel.selected_agent().expect("selected agent must be set"),
5429 session_id_a.clone(),
5430 None,
5431 None,
5432 true,
5433 window,
5434 cx,
5435 );
5436 });
5437
5438 // Thread A should now be the active view, promoted from background.
5439 let active_session = active_session_id(&panel, &cx);
5440 assert_eq!(
5441 active_session, session_id_a,
5442 "Thread A should be the active thread after promotion"
5443 );
5444
5445 panel.read_with(&cx, |panel, _cx| {
5446 assert!(
5447 !panel.background_threads.contains_key(&session_id_a),
5448 "Promoted thread A should no longer be in background_views"
5449 );
5450 assert!(
5451 panel.background_threads.contains_key(&session_id_b),
5452 "Thread B (idle, non-loadable) should remain retained in background_views"
5453 );
5454 });
5455 }
5456
5457 #[gpui::test]
5458 async fn test_cleanup_background_threads_keeps_five_most_recent_idle_loadable_threads(
5459 cx: &mut TestAppContext,
5460 ) {
5461 let (panel, mut cx) = setup_panel(cx).await;
5462 let connection = StubAgentConnection::new()
5463 .with_supports_load_session(true)
5464 .with_agent_id("loadable-stub".into())
5465 .with_telemetry_id("loadable-stub".into());
5466 let mut session_ids = Vec::new();
5467
5468 for _ in 0..7 {
5469 session_ids.push(open_generating_thread_with_loadable_connection(
5470 &panel,
5471 &connection,
5472 &mut cx,
5473 ));
5474 }
5475
5476 let base_time = Instant::now();
5477
5478 for session_id in session_ids.iter().take(6) {
5479 connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5480 }
5481 cx.run_until_parked();
5482
5483 panel.update(&mut cx, |panel, cx| {
5484 for (index, session_id) in session_ids.iter().take(6).enumerate() {
5485 let conversation_view = panel
5486 .background_threads
5487 .get(session_id)
5488 .expect("background thread should exist")
5489 .clone();
5490 conversation_view.update(cx, |view, cx| {
5491 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5492 });
5493 }
5494 panel.cleanup_background_threads(cx);
5495 });
5496
5497 panel.read_with(&cx, |panel, _cx| {
5498 assert_eq!(
5499 panel.background_threads.len(),
5500 5,
5501 "cleanup should keep at most five idle loadable background threads"
5502 );
5503 assert!(
5504 !panel.background_threads.contains_key(&session_ids[0]),
5505 "oldest idle loadable background thread should be removed"
5506 );
5507 for session_id in &session_ids[1..6] {
5508 assert!(
5509 panel.background_threads.contains_key(session_id),
5510 "more recent idle loadable background threads should be retained"
5511 );
5512 }
5513 assert!(
5514 !panel.background_threads.contains_key(&session_ids[6]),
5515 "the active thread should not also be stored as a background thread"
5516 );
5517 });
5518 }
5519
5520 #[gpui::test]
5521 async fn test_cleanup_background_threads_preserves_idle_non_loadable_threads(
5522 cx: &mut TestAppContext,
5523 ) {
5524 let (panel, mut cx) = setup_panel(cx).await;
5525
5526 let non_loadable_connection = StubAgentConnection::new();
5527 let non_loadable_session_id = open_idle_thread_with_non_loadable_connection(
5528 &panel,
5529 &non_loadable_connection,
5530 &mut cx,
5531 );
5532
5533 let loadable_connection = StubAgentConnection::new()
5534 .with_supports_load_session(true)
5535 .with_agent_id("loadable-stub".into())
5536 .with_telemetry_id("loadable-stub".into());
5537 let mut loadable_session_ids = Vec::new();
5538
5539 for _ in 0..7 {
5540 loadable_session_ids.push(open_generating_thread_with_loadable_connection(
5541 &panel,
5542 &loadable_connection,
5543 &mut cx,
5544 ));
5545 }
5546
5547 let base_time = Instant::now();
5548
5549 for session_id in loadable_session_ids.iter().take(6) {
5550 loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5551 }
5552 cx.run_until_parked();
5553
5554 panel.update(&mut cx, |panel, cx| {
5555 for (index, session_id) in loadable_session_ids.iter().take(6).enumerate() {
5556 let conversation_view = panel
5557 .background_threads
5558 .get(session_id)
5559 .expect("background thread should exist")
5560 .clone();
5561 conversation_view.update(cx, |view, cx| {
5562 view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5563 });
5564 }
5565 panel.cleanup_background_threads(cx);
5566 });
5567
5568 panel.read_with(&cx, |panel, _cx| {
5569 assert_eq!(
5570 panel.background_threads.len(),
5571 6,
5572 "cleanup should keep the non-loadable idle thread in addition to five loadable ones"
5573 );
5574 assert!(
5575 panel
5576 .background_threads
5577 .contains_key(&non_loadable_session_id),
5578 "idle non-loadable background threads should not be cleanup candidates"
5579 );
5580 assert!(
5581 !panel
5582 .background_threads
5583 .contains_key(&loadable_session_ids[0]),
5584 "oldest idle loadable background thread should still be removed"
5585 );
5586 for session_id in &loadable_session_ids[1..6] {
5587 assert!(
5588 panel.background_threads.contains_key(session_id),
5589 "more recent idle loadable background threads should be retained"
5590 );
5591 }
5592 assert!(
5593 !panel
5594 .background_threads
5595 .contains_key(&loadable_session_ids[6]),
5596 "the active loadable thread should not also be stored as a background thread"
5597 );
5598 });
5599 }
5600
5601 #[gpui::test]
5602 async fn test_thread_target_local_project(cx: &mut TestAppContext) {
5603 init_test(cx);
5604 cx.update(|cx| {
5605 agent::ThreadStore::init_global(cx);
5606 language_model::LanguageModelRegistry::test(cx);
5607 });
5608
5609 let fs = FakeFs::new(cx.executor());
5610 fs.insert_tree(
5611 "/project",
5612 json!({
5613 ".git": {},
5614 "src": {
5615 "main.rs": "fn main() {}"
5616 }
5617 }),
5618 )
5619 .await;
5620 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5621
5622 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5623
5624 let multi_workspace =
5625 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5626
5627 let workspace = multi_workspace
5628 .read_with(cx, |multi_workspace, _cx| {
5629 multi_workspace.workspace().clone()
5630 })
5631 .unwrap();
5632
5633 workspace.update(cx, |workspace, _cx| {
5634 workspace.set_random_database_id();
5635 });
5636
5637 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5638
5639 // Wait for the project to discover the git repository.
5640 cx.run_until_parked();
5641
5642 let panel = workspace.update_in(cx, |workspace, window, cx| {
5643 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5644 workspace.add_panel(panel.clone(), window, cx);
5645 panel
5646 });
5647
5648 cx.run_until_parked();
5649
5650 // Default thread target should be LocalProject.
5651 panel.read_with(cx, |panel, _cx| {
5652 assert_eq!(
5653 *panel.start_thread_in(),
5654 StartThreadIn::LocalProject,
5655 "default thread target should be LocalProject"
5656 );
5657 });
5658
5659 // Start a new thread with the default LocalProject target.
5660 // Use StubAgentServer so the thread connects immediately in tests.
5661 panel.update_in(cx, |panel, window, cx| {
5662 panel.open_external_thread_with_server(
5663 Rc::new(StubAgentServer::default_response()),
5664 window,
5665 cx,
5666 );
5667 });
5668
5669 cx.run_until_parked();
5670
5671 // MultiWorkspace should still have exactly one workspace (no worktree created).
5672 multi_workspace
5673 .read_with(cx, |multi_workspace, _cx| {
5674 assert_eq!(
5675 multi_workspace.workspaces().count(),
5676 1,
5677 "LocalProject should not create a new workspace"
5678 );
5679 })
5680 .unwrap();
5681
5682 // The thread should be active in the panel.
5683 panel.read_with(cx, |panel, cx| {
5684 assert!(
5685 panel.active_agent_thread(cx).is_some(),
5686 "a thread should be running in the current workspace"
5687 );
5688 });
5689
5690 // The thread target should still be LocalProject (unchanged).
5691 panel.read_with(cx, |panel, _cx| {
5692 assert_eq!(
5693 *panel.start_thread_in(),
5694 StartThreadIn::LocalProject,
5695 "thread target should remain LocalProject"
5696 );
5697 });
5698
5699 // No worktree creation status should be set.
5700 panel.read_with(cx, |panel, _cx| {
5701 assert!(
5702 panel.worktree_creation_status.is_none(),
5703 "no worktree creation should have occurred"
5704 );
5705 });
5706 }
5707
5708 #[gpui::test]
5709 async fn test_thread_target_does_not_sync_to_external_linked_worktree_with_invalid_branch_target(
5710 cx: &mut TestAppContext,
5711 ) {
5712 use git::repository::Worktree as GitWorktree;
5713
5714 init_test(cx);
5715 cx.update(|cx| {
5716 agent::ThreadStore::init_global(cx);
5717 language_model::LanguageModelRegistry::test(cx);
5718 });
5719
5720 let fs = FakeFs::new(cx.executor());
5721 fs.insert_tree(
5722 "/project",
5723 json!({
5724 ".git": {},
5725 "src": {
5726 "main.rs": "fn main() {}"
5727 }
5728 }),
5729 )
5730 .await;
5731 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5732 fs.insert_branches(Path::new("/project/.git"), &["main", "feature-worktree"]);
5733
5734 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5735
5736 let multi_workspace =
5737 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5738
5739 let workspace = multi_workspace
5740 .read_with(cx, |multi_workspace, _cx| {
5741 multi_workspace.workspace().clone()
5742 })
5743 .unwrap();
5744
5745 workspace.update(cx, |workspace, _cx| {
5746 workspace.set_random_database_id();
5747 });
5748
5749 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5750
5751 cx.run_until_parked();
5752
5753 let panel = workspace.update_in(cx, |workspace, window, cx| {
5754 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5755 workspace.add_panel(panel.clone(), window, cx);
5756 panel
5757 });
5758
5759 cx.run_until_parked();
5760
5761 panel.update_in(cx, |panel, window, cx| {
5762 panel.set_start_thread_in(
5763 &StartThreadIn::NewWorktree {
5764 worktree_name: Some("feature worktree".to_string()),
5765 branch_target: NewWorktreeBranchTarget::CurrentBranch,
5766 },
5767 window,
5768 cx,
5769 );
5770 });
5771
5772 fs.add_linked_worktree_for_repo(
5773 Path::new("/project/.git"),
5774 true,
5775 GitWorktree {
5776 path: PathBuf::from("/linked-feature-worktree"),
5777 ref_name: Some("refs/heads/feature-worktree".into()),
5778 sha: "abcdef1".into(),
5779 is_main: false,
5780 },
5781 )
5782 .await;
5783
5784 project
5785 .update(cx, |project, cx| project.git_scans_complete(cx))
5786 .await;
5787 cx.run_until_parked();
5788
5789 panel.read_with(cx, |panel, _cx| {
5790 assert_eq!(
5791 *panel.start_thread_in(),
5792 StartThreadIn::NewWorktree {
5793 worktree_name: Some("feature worktree".to_string()),
5794 branch_target: NewWorktreeBranchTarget::CurrentBranch,
5795 },
5796 "thread target should remain a named new worktree when the external linked worktree does not match the selected branch target",
5797 );
5798 });
5799 }
5800
5801 #[gpui::test]
5802 async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
5803 init_test(cx);
5804 cx.update(|cx| {
5805 agent::ThreadStore::init_global(cx);
5806 language_model::LanguageModelRegistry::test(cx);
5807 });
5808
5809 let fs = FakeFs::new(cx.executor());
5810 fs.insert_tree(
5811 "/project",
5812 json!({
5813 ".git": {},
5814 "src": {
5815 "main.rs": "fn main() {}"
5816 }
5817 }),
5818 )
5819 .await;
5820 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5821
5822 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5823
5824 let multi_workspace =
5825 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5826
5827 let workspace = multi_workspace
5828 .read_with(cx, |multi_workspace, _cx| {
5829 multi_workspace.workspace().clone()
5830 })
5831 .unwrap();
5832
5833 workspace.update(cx, |workspace, _cx| {
5834 workspace.set_random_database_id();
5835 });
5836
5837 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5838
5839 // Wait for the project to discover the git repository.
5840 cx.run_until_parked();
5841
5842 let panel = workspace.update_in(cx, |workspace, window, cx| {
5843 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5844 workspace.add_panel(panel.clone(), window, cx);
5845 panel
5846 });
5847
5848 cx.run_until_parked();
5849
5850 // Default should be LocalProject.
5851 panel.read_with(cx, |panel, _cx| {
5852 assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
5853 });
5854
5855 // Change thread target to NewWorktree.
5856 panel.update_in(cx, |panel, window, cx| {
5857 panel.set_start_thread_in(
5858 &StartThreadIn::NewWorktree {
5859 worktree_name: None,
5860 branch_target: NewWorktreeBranchTarget::default(),
5861 },
5862 window,
5863 cx,
5864 );
5865 });
5866
5867 panel.read_with(cx, |panel, _cx| {
5868 assert_eq!(
5869 *panel.start_thread_in(),
5870 StartThreadIn::NewWorktree {
5871 worktree_name: None,
5872 branch_target: NewWorktreeBranchTarget::default(),
5873 },
5874 "thread target should be NewWorktree after set_thread_target"
5875 );
5876 });
5877
5878 // Let serialization complete.
5879 cx.run_until_parked();
5880
5881 // Load a fresh panel from the serialized data.
5882 let async_cx = cx.update(|window, cx| window.to_async(cx));
5883 let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
5884 .await
5885 .expect("panel load should succeed");
5886 cx.run_until_parked();
5887
5888 loaded_panel.read_with(cx, |panel, _cx| {
5889 assert_eq!(
5890 *panel.start_thread_in(),
5891 StartThreadIn::NewWorktree {
5892 worktree_name: None,
5893 branch_target: NewWorktreeBranchTarget::default(),
5894 },
5895 "thread target should survive serialization round-trip"
5896 );
5897 });
5898 }
5899
5900 #[gpui::test]
5901 async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
5902 init_test(cx);
5903
5904 let fs = FakeFs::new(cx.executor());
5905 cx.update(|cx| {
5906 agent::ThreadStore::init_global(cx);
5907 language_model::LanguageModelRegistry::test(cx);
5908 <dyn fs::Fs>::set_global(fs.clone(), cx);
5909 });
5910
5911 fs.insert_tree(
5912 "/project",
5913 json!({
5914 ".git": {},
5915 "src": {
5916 "main.rs": "fn main() {}"
5917 }
5918 }),
5919 )
5920 .await;
5921
5922 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5923
5924 let multi_workspace =
5925 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5926
5927 let workspace = multi_workspace
5928 .read_with(cx, |multi_workspace, _cx| {
5929 multi_workspace.workspace().clone()
5930 })
5931 .unwrap();
5932
5933 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5934
5935 let panel = workspace.update_in(cx, |workspace, window, cx| {
5936 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5937 workspace.add_panel(panel.clone(), window, cx);
5938 panel
5939 });
5940
5941 cx.run_until_parked();
5942
5943 // Simulate worktree creation in progress and reset to Uninitialized
5944 panel.update_in(cx, |panel, window, cx| {
5945 panel.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
5946 panel.active_view = ActiveView::Uninitialized;
5947 Panel::set_active(panel, true, window, cx);
5948 assert!(
5949 matches!(panel.active_view, ActiveView::Uninitialized),
5950 "set_active should not create a thread while worktree is being created"
5951 );
5952 });
5953
5954 // Clear the creation status and use open_external_thread_with_server
5955 // (which bypasses new_agent_thread) to verify the panel can transition
5956 // out of Uninitialized. We can't call set_active directly because
5957 // new_agent_thread requires full agent server infrastructure.
5958 panel.update_in(cx, |panel, window, cx| {
5959 panel.worktree_creation_status = None;
5960 panel.active_view = ActiveView::Uninitialized;
5961 panel.open_external_thread_with_server(
5962 Rc::new(StubAgentServer::default_response()),
5963 window,
5964 cx,
5965 );
5966 });
5967
5968 cx.run_until_parked();
5969
5970 panel.read_with(cx, |panel, _cx| {
5971 assert!(
5972 !matches!(panel.active_view, ActiveView::Uninitialized),
5973 "panel should transition out of Uninitialized once worktree creation is cleared"
5974 );
5975 });
5976 }
5977
5978 #[test]
5979 fn test_deserialize_agent_variants() {
5980 // PascalCase (legacy AgentType format, persisted in panel state)
5981 assert_eq!(
5982 serde_json::from_str::<Agent>(r#""NativeAgent""#).unwrap(),
5983 Agent::NativeAgent,
5984 );
5985 assert_eq!(
5986 serde_json::from_str::<Agent>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
5987 Agent::Custom {
5988 id: "my-agent".into(),
5989 },
5990 );
5991
5992 // Legacy TextThread variant deserializes to NativeAgent
5993 assert_eq!(
5994 serde_json::from_str::<Agent>(r#""TextThread""#).unwrap(),
5995 Agent::NativeAgent,
5996 );
5997
5998 // snake_case (canonical format)
5999 assert_eq!(
6000 serde_json::from_str::<Agent>(r#""native_agent""#).unwrap(),
6001 Agent::NativeAgent,
6002 );
6003 assert_eq!(
6004 serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
6005 Agent::Custom {
6006 id: "my-agent".into(),
6007 },
6008 );
6009
6010 // Serialization uses snake_case
6011 assert_eq!(
6012 serde_json::to_string(&Agent::NativeAgent).unwrap(),
6013 r#""native_agent""#,
6014 );
6015 assert_eq!(
6016 serde_json::to_string(&Agent::Custom {
6017 id: "my-agent".into()
6018 })
6019 .unwrap(),
6020 r#"{"custom":{"name":"my-agent"}}"#,
6021 );
6022 }
6023
6024 #[test]
6025 fn test_resolve_worktree_branch_target() {
6026 let existing_branches = HashSet::from_iter([
6027 "main".to_string(),
6028 "feature".to_string(),
6029 "origin/main".to_string(),
6030 ]);
6031
6032 let resolved = AgentPanel::resolve_worktree_branch_target(
6033 &NewWorktreeBranchTarget::CreateBranch {
6034 name: "new-branch".to_string(),
6035 from_ref: Some("main".to_string()),
6036 },
6037 &existing_branches,
6038 &HashSet::from_iter(["main".to_string()]),
6039 )
6040 .unwrap();
6041 assert_eq!(
6042 resolved,
6043 ("new-branch".to_string(), false, Some("main".to_string()))
6044 );
6045
6046 let resolved = AgentPanel::resolve_worktree_branch_target(
6047 &NewWorktreeBranchTarget::ExistingBranch {
6048 name: "feature".to_string(),
6049 },
6050 &existing_branches,
6051 &HashSet::default(),
6052 )
6053 .unwrap();
6054 assert_eq!(resolved, ("feature".to_string(), true, None));
6055
6056 let resolved = AgentPanel::resolve_worktree_branch_target(
6057 &NewWorktreeBranchTarget::ExistingBranch {
6058 name: "main".to_string(),
6059 },
6060 &existing_branches,
6061 &HashSet::from_iter(["main".to_string()]),
6062 )
6063 .unwrap();
6064 assert_eq!(resolved.1, false);
6065 assert_eq!(resolved.2, Some("main".to_string()));
6066 assert_ne!(resolved.0, "main");
6067 assert!(existing_branches.contains("main"));
6068 assert!(!existing_branches.contains(&resolved.0));
6069 }
6070
6071 #[gpui::test]
6072 async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
6073 init_test(cx);
6074
6075 let app_state = cx.update(|cx| {
6076 agent::ThreadStore::init_global(cx);
6077 language_model::LanguageModelRegistry::test(cx);
6078
6079 let app_state = workspace::AppState::test(cx);
6080 workspace::init(app_state.clone(), cx);
6081 app_state
6082 });
6083
6084 let fs = app_state.fs.as_fake();
6085 fs.insert_tree(
6086 "/project",
6087 json!({
6088 ".git": {},
6089 "src": {
6090 "main.rs": "fn main() {}"
6091 }
6092 }),
6093 )
6094 .await;
6095 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6096
6097 let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
6098
6099 let multi_workspace =
6100 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6101 multi_workspace
6102 .update(cx, |multi_workspace, _, cx| {
6103 multi_workspace.open_sidebar(cx);
6104 })
6105 .unwrap();
6106
6107 let workspace = multi_workspace
6108 .read_with(cx, |multi_workspace, _cx| {
6109 multi_workspace.workspace().clone()
6110 })
6111 .unwrap();
6112
6113 workspace.update(cx, |workspace, _cx| {
6114 workspace.set_random_database_id();
6115 });
6116
6117 // Register a callback so new workspaces also get an AgentPanel.
6118 cx.update(|cx| {
6119 cx.observe_new(
6120 |workspace: &mut Workspace,
6121 window: Option<&mut Window>,
6122 cx: &mut Context<Workspace>| {
6123 if let Some(window) = window {
6124 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6125 workspace.add_panel(panel, window, cx);
6126 }
6127 },
6128 )
6129 .detach();
6130 });
6131
6132 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6133
6134 // Wait for the project to discover the git repository.
6135 cx.run_until_parked();
6136
6137 let panel = workspace.update_in(cx, |workspace, window, cx| {
6138 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6139 workspace.add_panel(panel.clone(), window, cx);
6140 panel
6141 });
6142
6143 cx.run_until_parked();
6144
6145 // Open a thread (needed so there's an active thread view).
6146 panel.update_in(cx, |panel, window, cx| {
6147 panel.open_external_thread_with_server(
6148 Rc::new(StubAgentServer::default_response()),
6149 window,
6150 cx,
6151 );
6152 });
6153
6154 cx.run_until_parked();
6155
6156 // Set the selected agent to Codex (a custom agent) and start_thread_in
6157 // to NewWorktree. We do this AFTER opening the thread because
6158 // open_external_thread_with_server overrides selected_agent.
6159 panel.update_in(cx, |panel, window, cx| {
6160 panel.selected_agent = Agent::Custom {
6161 id: CODEX_ID.into(),
6162 };
6163 panel.set_start_thread_in(
6164 &StartThreadIn::NewWorktree {
6165 worktree_name: None,
6166 branch_target: NewWorktreeBranchTarget::default(),
6167 },
6168 window,
6169 cx,
6170 );
6171 });
6172
6173 // Verify the panel has the Codex agent selected.
6174 panel.read_with(cx, |panel, _cx| {
6175 assert_eq!(
6176 panel.selected_agent,
6177 Agent::Custom {
6178 id: CODEX_ID.into()
6179 },
6180 );
6181 });
6182
6183 // Directly call handle_worktree_creation_requested, which is what
6184 // handle_first_send_requested does when start_thread_in == NewWorktree.
6185 let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
6186 "Hello from test",
6187 ))];
6188 panel.update_in(cx, |panel, window, cx| {
6189 panel.handle_worktree_requested(
6190 content,
6191 WorktreeCreationArgs::New {
6192 worktree_name: None,
6193 branch_target: NewWorktreeBranchTarget::default(),
6194 },
6195 window,
6196 cx,
6197 );
6198 });
6199
6200 // Let the async worktree creation + workspace setup complete.
6201 cx.run_until_parked();
6202
6203 panel.read_with(cx, |panel, _cx| {
6204 assert_eq!(
6205 panel.start_thread_in(),
6206 &StartThreadIn::LocalProject,
6207 "the original panel should reset start_thread_in back to the local project after creating a worktree workspace",
6208 );
6209 });
6210
6211 // Find the new workspace's AgentPanel and verify it used the Codex agent.
6212 let found_codex = multi_workspace
6213 .read_with(cx, |multi_workspace, cx| {
6214 // There should be more than one workspace now (the original + the new worktree).
6215 assert!(
6216 multi_workspace.workspaces().count() > 1,
6217 "expected a new workspace to have been created, found {}",
6218 multi_workspace.workspaces().count(),
6219 );
6220
6221 // Check the newest workspace's panel for the correct agent.
6222 let new_workspace = multi_workspace
6223 .workspaces()
6224 .find(|ws| ws.entity_id() != workspace.entity_id())
6225 .expect("should find the new workspace");
6226 let new_panel = new_workspace
6227 .read(cx)
6228 .panel::<AgentPanel>(cx)
6229 .expect("new workspace should have an AgentPanel");
6230
6231 new_panel.read(cx).selected_agent.clone()
6232 })
6233 .unwrap();
6234
6235 assert_eq!(
6236 found_codex,
6237 Agent::Custom {
6238 id: CODEX_ID.into()
6239 },
6240 "the new worktree workspace should use the same agent (Codex) that was selected in the original panel",
6241 );
6242 }
6243
6244 #[gpui::test]
6245 async fn test_work_dirs_update_when_worktrees_change(cx: &mut TestAppContext) {
6246 use crate::thread_metadata_store::ThreadMetadataStore;
6247
6248 init_test(cx);
6249 cx.update(|cx| {
6250 agent::ThreadStore::init_global(cx);
6251 language_model::LanguageModelRegistry::test(cx);
6252 });
6253
6254 // Set up a project with one worktree.
6255 let fs = FakeFs::new(cx.executor());
6256 fs.insert_tree("/project_a", json!({ "file.txt": "" }))
6257 .await;
6258 let project = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
6259
6260 let multi_workspace =
6261 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6262 let workspace = multi_workspace
6263 .read_with(cx, |mw, _cx| mw.workspace().clone())
6264 .unwrap();
6265 let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
6266
6267 let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
6268 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6269 });
6270
6271 // Open thread A and send a message. With empty next_prompt_updates it
6272 // stays generating, so opening B will move A to background_threads.
6273 let connection_a = StubAgentConnection::new().with_agent_id("agent-a".into());
6274 open_thread_with_custom_connection(&panel, connection_a.clone(), &mut cx);
6275 send_message(&panel, &mut cx);
6276 let session_id_a = active_session_id(&panel, &cx);
6277
6278 // Open thread C — thread A (generating) moves to background.
6279 // Thread C completes immediately (idle), then opening B moves C to background too.
6280 let connection_c = StubAgentConnection::new().with_agent_id("agent-c".into());
6281 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6282 acp::ContentChunk::new("done".into()),
6283 )]);
6284 open_thread_with_custom_connection(&panel, connection_c.clone(), &mut cx);
6285 send_message(&panel, &mut cx);
6286 let session_id_c = active_session_id(&panel, &cx);
6287
6288 // Open thread B — thread C (idle, non-loadable) is retained in background.
6289 let connection_b = StubAgentConnection::new().with_agent_id("agent-b".into());
6290 open_thread_with_custom_connection(&panel, connection_b.clone(), &mut cx);
6291 send_message(&panel, &mut cx);
6292 let session_id_b = active_session_id(&panel, &cx);
6293
6294 let metadata_store = cx.update(|_, cx| ThreadMetadataStore::global(cx));
6295
6296 panel.read_with(&cx, |panel, _cx| {
6297 assert!(
6298 panel.background_threads.contains_key(&session_id_a),
6299 "Thread A should be in background_threads"
6300 );
6301 assert!(
6302 panel.background_threads.contains_key(&session_id_c),
6303 "Thread C should be in background_threads"
6304 );
6305 });
6306
6307 // Verify initial work_dirs for thread B contain only /project_a.
6308 let initial_b_paths = panel.read_with(&cx, |panel, cx| {
6309 let thread = panel.active_agent_thread(cx).unwrap();
6310 thread.read(cx).work_dirs().cloned().unwrap()
6311 });
6312 assert_eq!(
6313 initial_b_paths.ordered_paths().collect::<Vec<_>>(),
6314 vec![&PathBuf::from("/project_a")],
6315 "Thread B should initially have only /project_a"
6316 );
6317
6318 // Now add a second worktree to the project.
6319 fs.insert_tree("/project_b", json!({ "other.txt": "" }))
6320 .await;
6321 let (new_tree, _) = project
6322 .update(&mut cx, |project, cx| {
6323 project.find_or_create_worktree("/project_b", true, cx)
6324 })
6325 .await
6326 .unwrap();
6327 cx.read(|cx| new_tree.read(cx).as_local().unwrap().scan_complete())
6328 .await;
6329 cx.run_until_parked();
6330
6331 // Verify thread B's (active) work_dirs now include both worktrees.
6332 let updated_b_paths = panel.read_with(&cx, |panel, cx| {
6333 let thread = panel.active_agent_thread(cx).unwrap();
6334 thread.read(cx).work_dirs().cloned().unwrap()
6335 });
6336 let mut b_paths_sorted = updated_b_paths.ordered_paths().cloned().collect::<Vec<_>>();
6337 b_paths_sorted.sort();
6338 assert_eq!(
6339 b_paths_sorted,
6340 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6341 "Thread B work_dirs should include both worktrees after adding /project_b"
6342 );
6343
6344 // Verify thread A's (background) work_dirs are also updated.
6345 let updated_a_paths = panel.read_with(&cx, |panel, cx| {
6346 let bg_view = panel.background_threads.get(&session_id_a).unwrap();
6347 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6348 root_thread
6349 .read(cx)
6350 .thread
6351 .read(cx)
6352 .work_dirs()
6353 .cloned()
6354 .unwrap()
6355 });
6356 let mut a_paths_sorted = updated_a_paths.ordered_paths().cloned().collect::<Vec<_>>();
6357 a_paths_sorted.sort();
6358 assert_eq!(
6359 a_paths_sorted,
6360 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6361 "Thread A work_dirs should include both worktrees after adding /project_b"
6362 );
6363
6364 // Verify thread idle C was also updated.
6365 let updated_c_paths = panel.read_with(&cx, |panel, cx| {
6366 let bg_view = panel.background_threads.get(&session_id_c).unwrap();
6367 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6368 root_thread
6369 .read(cx)
6370 .thread
6371 .read(cx)
6372 .work_dirs()
6373 .cloned()
6374 .unwrap()
6375 });
6376 let mut c_paths_sorted = updated_c_paths.ordered_paths().cloned().collect::<Vec<_>>();
6377 c_paths_sorted.sort();
6378 assert_eq!(
6379 c_paths_sorted,
6380 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6381 "Thread C (idle background) work_dirs should include both worktrees after adding /project_b"
6382 );
6383
6384 // Verify the metadata store reflects the new paths for running threads only.
6385 cx.run_until_parked();
6386 for (label, session_id) in [("thread B", &session_id_b), ("thread A", &session_id_a)] {
6387 let metadata_paths = metadata_store.read_with(&cx, |store, _cx| {
6388 let metadata = store
6389 .entry(session_id)
6390 .unwrap_or_else(|| panic!("{label} thread metadata should exist"));
6391 metadata.folder_paths.clone()
6392 });
6393 let mut sorted = metadata_paths.ordered_paths().cloned().collect::<Vec<_>>();
6394 sorted.sort();
6395 assert_eq!(
6396 sorted,
6397 vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6398 "{label} thread metadata folder_paths should include both worktrees"
6399 );
6400 }
6401
6402 // Now remove a worktree and verify work_dirs shrink.
6403 let worktree_b_id = new_tree.read_with(&cx, |tree, _| tree.id());
6404 project.update(&mut cx, |project, cx| {
6405 project.remove_worktree(worktree_b_id, cx);
6406 });
6407 cx.run_until_parked();
6408
6409 let after_remove_b = panel.read_with(&cx, |panel, cx| {
6410 let thread = panel.active_agent_thread(cx).unwrap();
6411 thread.read(cx).work_dirs().cloned().unwrap()
6412 });
6413 assert_eq!(
6414 after_remove_b.ordered_paths().collect::<Vec<_>>(),
6415 vec![&PathBuf::from("/project_a")],
6416 "Thread B work_dirs should revert to only /project_a after removing /project_b"
6417 );
6418
6419 let after_remove_a = panel.read_with(&cx, |panel, cx| {
6420 let bg_view = panel.background_threads.get(&session_id_a).unwrap();
6421 let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6422 root_thread
6423 .read(cx)
6424 .thread
6425 .read(cx)
6426 .work_dirs()
6427 .cloned()
6428 .unwrap()
6429 });
6430 assert_eq!(
6431 after_remove_a.ordered_paths().collect::<Vec<_>>(),
6432 vec![&PathBuf::from("/project_a")],
6433 "Thread A work_dirs should revert to only /project_a after removing /project_b"
6434 );
6435 }
6436
6437 #[gpui::test]
6438 async fn test_new_workspace_inherits_global_last_used_agent(cx: &mut TestAppContext) {
6439 init_test(cx);
6440 cx.update(|cx| {
6441 agent::ThreadStore::init_global(cx);
6442 language_model::LanguageModelRegistry::test(cx);
6443 // Use an isolated DB so parallel tests can't overwrite our global key.
6444 cx.set_global(db::AppDatabase::test_new());
6445 });
6446
6447 let custom_agent = Agent::Custom {
6448 id: "my-preferred-agent".into(),
6449 };
6450
6451 // Write a known agent to the global KVP to simulate a user who has
6452 // previously used this agent in another workspace.
6453 let kvp = cx.update(|cx| KeyValueStore::global(cx));
6454 write_global_last_used_agent(kvp, custom_agent.clone()).await;
6455
6456 let fs = FakeFs::new(cx.executor());
6457 let project = Project::test(fs.clone(), [], cx).await;
6458
6459 let multi_workspace =
6460 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6461
6462 let workspace = multi_workspace
6463 .read_with(cx, |multi_workspace, _cx| {
6464 multi_workspace.workspace().clone()
6465 })
6466 .unwrap();
6467
6468 workspace.update(cx, |workspace, _cx| {
6469 workspace.set_random_database_id();
6470 });
6471
6472 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6473
6474 // Load the panel via `load()`, which reads the global fallback
6475 // asynchronously when no per-workspace state exists.
6476 let async_cx = cx.update(|window, cx| window.to_async(cx));
6477 let panel = AgentPanel::load(workspace.downgrade(), async_cx)
6478 .await
6479 .expect("panel load should succeed");
6480 cx.run_until_parked();
6481
6482 panel.read_with(cx, |panel, _cx| {
6483 assert_eq!(
6484 panel.selected_agent, custom_agent,
6485 "new workspace should inherit the global last-used agent"
6486 );
6487 });
6488 }
6489
6490 #[gpui::test]
6491 async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) {
6492 init_test(cx);
6493 cx.update(|cx| {
6494 agent::ThreadStore::init_global(cx);
6495 language_model::LanguageModelRegistry::test(cx);
6496 });
6497
6498 let fs = FakeFs::new(cx.executor());
6499 let project_a = Project::test(fs.clone(), [], cx).await;
6500 let project_b = Project::test(fs, [], cx).await;
6501
6502 let multi_workspace =
6503 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6504
6505 let workspace_a = multi_workspace
6506 .read_with(cx, |multi_workspace, _cx| {
6507 multi_workspace.workspace().clone()
6508 })
6509 .unwrap();
6510
6511 let workspace_b = multi_workspace
6512 .update(cx, |multi_workspace, window, cx| {
6513 multi_workspace.test_add_workspace(project_b.clone(), window, cx)
6514 })
6515 .unwrap();
6516
6517 workspace_a.update(cx, |workspace, _cx| {
6518 workspace.set_random_database_id();
6519 });
6520 workspace_b.update(cx, |workspace, _cx| {
6521 workspace.set_random_database_id();
6522 });
6523
6524 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6525
6526 let agent_a = Agent::Custom {
6527 id: "agent-alpha".into(),
6528 };
6529 let agent_b = Agent::Custom {
6530 id: "agent-beta".into(),
6531 };
6532
6533 // Set up workspace A with agent_a
6534 let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
6535 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6536 });
6537 panel_a.update(cx, |panel, _cx| {
6538 panel.selected_agent = agent_a.clone();
6539 });
6540
6541 // Set up workspace B with agent_b
6542 let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
6543 cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6544 });
6545 panel_b.update(cx, |panel, _cx| {
6546 panel.selected_agent = agent_b.clone();
6547 });
6548
6549 // Serialize both panels
6550 panel_a.update(cx, |panel, cx| panel.serialize(cx));
6551 panel_b.update(cx, |panel, cx| panel.serialize(cx));
6552 cx.run_until_parked();
6553
6554 // Load fresh panels from serialized state and verify independence
6555 let async_cx = cx.update(|window, cx| window.to_async(cx));
6556 let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
6557 .await
6558 .expect("panel A load should succeed");
6559 cx.run_until_parked();
6560
6561 let async_cx = cx.update(|window, cx| window.to_async(cx));
6562 let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
6563 .await
6564 .expect("panel B load should succeed");
6565 cx.run_until_parked();
6566
6567 loaded_a.read_with(cx, |panel, _cx| {
6568 assert_eq!(
6569 panel.selected_agent, agent_a,
6570 "workspace A should restore agent-alpha, not agent-beta"
6571 );
6572 });
6573
6574 loaded_b.read_with(cx, |panel, _cx| {
6575 assert_eq!(
6576 panel.selected_agent, agent_b,
6577 "workspace B should restore agent-beta, not agent-alpha"
6578 );
6579 });
6580 }
6581
6582 #[gpui::test]
6583 async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) {
6584 init_test(cx);
6585 cx.update(|cx| {
6586 agent::ThreadStore::init_global(cx);
6587 language_model::LanguageModelRegistry::test(cx);
6588 });
6589
6590 let fs = FakeFs::new(cx.executor());
6591 let project = Project::test(fs.clone(), [], cx).await;
6592
6593 let multi_workspace =
6594 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6595
6596 let workspace = multi_workspace
6597 .read_with(cx, |multi_workspace, _cx| {
6598 multi_workspace.workspace().clone()
6599 })
6600 .unwrap();
6601
6602 workspace.update(cx, |workspace, _cx| {
6603 workspace.set_random_database_id();
6604 });
6605
6606 let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6607
6608 let custom_agent = Agent::Custom {
6609 id: "my-custom-agent".into(),
6610 };
6611
6612 let panel = workspace.update_in(cx, |workspace, window, cx| {
6613 let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6614 workspace.add_panel(panel.clone(), window, cx);
6615 panel
6616 });
6617
6618 // Set selected_agent to a custom agent
6619 panel.update(cx, |panel, _cx| {
6620 panel.selected_agent = custom_agent.clone();
6621 });
6622
6623 // Call new_thread, which internally calls external_thread(None, ...)
6624 // This resolves the agent from self.selected_agent
6625 panel.update_in(cx, |panel, window, cx| {
6626 panel.new_thread(&NewThread, window, cx);
6627 });
6628
6629 panel.read_with(cx, |panel, _cx| {
6630 assert_eq!(
6631 panel.selected_agent, custom_agent,
6632 "selected_agent should remain the custom agent after new_thread"
6633 );
6634 assert!(
6635 panel.active_conversation_view().is_some(),
6636 "a thread should have been created"
6637 );
6638 });
6639 }
6640}