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