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