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