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