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