agent_panel.rs

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