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