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 zed_actions::agent::{
  23    AddSelectionToThread, ConflictContent, ReauthenticateAgent, ResolveConflictedFilesWithAgent,
  24    ResolveConflictsWithAgent, ReviewBranchDiff,
  25};
  26
  27use crate::thread_metadata_store::ThreadMetadataStore;
  28use crate::{
  29    AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
  30    Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
  31    OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
  32    StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
  33    agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
  34    conversation_view::{AcpThreadViewEvent, ThreadView},
  35    thread_branch_picker::ThreadBranchPicker,
  36    thread_worktree_picker::ThreadWorktreePicker,
  37    ui::EndTrialUpsell,
  38};
  39use crate::{
  40    Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
  41    NewNativeAgentThreadFromSummary,
  42};
  43use crate::{DEFAULT_THREAD_TITLE, ui::AcpOnboardingModal};
  44use crate::{ExpandMessageEditor, ThreadHistoryView};
  45use crate::{ManageProfiles, ThreadHistoryViewEvent};
  46use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
  47use agent_settings::{AgentSettings, WindowLayout};
  48use ai_onboarding::AgentPanelOnboarding;
  49use anyhow::{Context as _, Result, anyhow};
  50use client::UserStore;
  51use cloud_api_types::Plan;
  52use collections::HashMap;
  53use editor::Editor;
  54use extension::ExtensionEvents;
  55use extension_host::ExtensionStore;
  56use fs::Fs;
  57use gpui::{
  58    Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
  59    DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable, Global,
  60    KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*,
  61    pulsating_between,
  62};
  63use language::LanguageRegistry;
  64use language_model::LanguageModelRegistry;
  65use project::git_store::{GitStoreEvent, RepositoryEvent};
  66use project::project_settings::ProjectSettings;
  67use project::{Project, ProjectPath, Worktree, linked_worktree_short_name};
  68use prompt_store::{PromptStore, UserPromptId};
  69use remote::RemoteConnectionOptions;
  70use rules_library::{RulesLibrary, open_rules_library};
  71use settings::TerminalDockPosition;
  72use settings::{Settings, update_settings_file};
  73use terminal::terminal_settings::TerminalSettings;
  74use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
  75use theme_settings::ThemeSettings;
  76use ui::{
  77    Button, ButtonLike, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu,
  78    PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
  79};
  80use util::{ResultExt as _, debug_panic};
  81use workspace::{
  82    CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList,
  83    ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId,
  84    dock::{DockPosition, Panel, PanelEvent},
  85};
  86use zed_actions::{
  87    DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
  88    agent::{OpenAcpOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding},
  89    assistant::{OpenRulesLibrary, Toggle, ToggleFocus},
  90};
  91
  92const AGENT_PANEL_KEY: &str = "agent_panel";
  93const MIN_PANEL_WIDTH: Pixels = px(300.);
  94const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
  95const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
  96
  97#[derive(Serialize, Deserialize)]
  98struct LastUsedAgent {
  99    agent: Agent,
 100}
 101
 102/// Reads the most recently used agent across all workspaces. Used as a fallback
 103/// when opening a workspace that has no per-workspace agent preference yet.
 104fn read_global_last_used_agent(kvp: &KeyValueStore) -> Option<Agent> {
 105    kvp.read_kvp(LAST_USED_AGENT_KEY)
 106        .log_err()
 107        .flatten()
 108        .and_then(|json| serde_json::from_str::<LastUsedAgent>(&json).log_err())
 109        .map(|entry| entry.agent)
 110}
 111
 112async fn write_global_last_used_agent(kvp: KeyValueStore, agent: Agent) {
 113    if let Some(json) = serde_json::to_string(&LastUsedAgent { agent }).log_err() {
 114        kvp.write_kvp(LAST_USED_AGENT_KEY.to_string(), json)
 115            .await
 116            .log_err();
 117    }
 118}
 119
 120fn read_serialized_panel(
 121    workspace_id: workspace::WorkspaceId,
 122    kvp: &KeyValueStore,
 123) -> Option<SerializedAgentPanel> {
 124    let scope = kvp.scoped(AGENT_PANEL_KEY);
 125    let key = i64::from(workspace_id).to_string();
 126    scope
 127        .read(&key)
 128        .log_err()
 129        .flatten()
 130        .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
 131}
 132
 133async fn save_serialized_panel(
 134    workspace_id: workspace::WorkspaceId,
 135    panel: SerializedAgentPanel,
 136    kvp: KeyValueStore,
 137) -> Result<()> {
 138    let scope = kvp.scoped(AGENT_PANEL_KEY);
 139    let key = i64::from(workspace_id).to_string();
 140    scope.write(key, serde_json::to_string(&panel)?).await?;
 141    Ok(())
 142}
 143
 144/// Migration: reads the original single-panel format stored under the
 145/// `"agent_panel"` KVP key before per-workspace keying was introduced.
 146fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option<SerializedAgentPanel> {
 147    kvp.read_kvp(AGENT_PANEL_KEY)
 148        .log_err()
 149        .flatten()
 150        .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
 151}
 152
 153#[derive(Serialize, Deserialize, Debug)]
 154struct SerializedAgentPanel {
 155    selected_agent: Option<Agent>,
 156    #[serde(default)]
 157    last_active_thread: Option<SerializedActiveThread>,
 158    #[serde(default)]
 159    start_thread_in: Option<StartThreadIn>,
 160}
 161
 162#[derive(Serialize, Deserialize, Debug)]
 163struct SerializedActiveThread {
 164    session_id: String,
 165    agent_type: Agent,
 166    title: Option<String>,
 167    work_dirs: Option<SerializedPathList>,
 168}
 169
 170pub fn init(cx: &mut App) {
 171    cx.observe_new(
 172        |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
 173            workspace
 174                .register_action(|workspace, action: &NewThread, window, cx| {
 175                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 176                        panel.update(cx, |panel, cx| panel.new_thread(action, window, cx));
 177                        workspace.focus_panel::<AgentPanel>(window, cx);
 178                    }
 179                })
 180                .register_action(
 181                    |workspace, action: &NewNativeAgentThreadFromSummary, window, cx| {
 182                        if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 183                            panel.update(cx, |panel, cx| {
 184                                panel.new_native_agent_thread_from_summary(action, window, cx)
 185                            });
 186                            workspace.focus_panel::<AgentPanel>(window, cx);
 187                        }
 188                    },
 189                )
 190                .register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
 191                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 192                        workspace.focus_panel::<AgentPanel>(window, cx);
 193                        panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx));
 194                    }
 195                })
 196                .register_action(|workspace, _: &OpenHistory, window, cx| {
 197                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 198                        workspace.focus_panel::<AgentPanel>(window, cx);
 199                        panel.update(cx, |panel, cx| panel.open_history(window, cx));
 200                    }
 201                })
 202                .register_action(|workspace, _: &OpenSettings, window, cx| {
 203                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 204                        workspace.focus_panel::<AgentPanel>(window, cx);
 205                        panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
 206                    }
 207                })
 208                .register_action(|workspace, _action: &NewExternalAgentThread, window, cx| {
 209                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 210                        workspace.focus_panel::<AgentPanel>(window, cx);
 211                        panel.update(cx, |panel, cx| {
 212                            let id = panel.create_draft(window, cx);
 213                            panel.activate_draft(id, true, window, cx);
 214                        });
 215                    }
 216                })
 217                .register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
 218                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 219                        workspace.focus_panel::<AgentPanel>(window, cx);
 220                        panel.update(cx, |panel, cx| {
 221                            panel.deploy_rules_library(action, window, cx)
 222                        });
 223                    }
 224                })
 225                .register_action(|workspace, _: &Follow, window, cx| {
 226                    workspace.follow(CollaboratorId::Agent, window, cx);
 227                })
 228                .register_action(|workspace, _: &OpenAgentDiff, window, cx| {
 229                    let thread = workspace
 230                        .panel::<AgentPanel>(cx)
 231                        .and_then(|panel| panel.read(cx).active_conversation_view().cloned())
 232                        .and_then(|conversation| {
 233                            conversation
 234                                .read(cx)
 235                                .active_thread()
 236                                .map(|r| r.read(cx).thread.clone())
 237                        });
 238
 239                    if let Some(thread) = thread {
 240                        AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
 241                    }
 242                })
 243                .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
 244                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 245                        workspace.focus_panel::<AgentPanel>(window, cx);
 246                        panel.update(cx, |panel, cx| {
 247                            panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
 248                        });
 249                    }
 250                })
 251                .register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
 252                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 253                        workspace.focus_panel::<AgentPanel>(window, cx);
 254                        panel.update(cx, |panel, cx| {
 255                            panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
 256                        });
 257                    }
 258                })
 259                .register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
 260                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 261                        workspace.focus_panel::<AgentPanel>(window, cx);
 262                        panel.update(cx, |panel, cx| {
 263                            panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
 264                        });
 265                    }
 266                })
 267                .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
 268                    AcpOnboardingModal::toggle(workspace, window, cx)
 269                })
 270                .register_action(|_workspace, _: &ResetOnboarding, window, cx| {
 271                    window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
 272                    window.refresh();
 273                })
 274                .register_action(|workspace, _: &ResetTrialUpsell, _window, cx| {
 275                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 276                        panel.update(cx, |panel, _| {
 277                            panel
 278                                .new_user_onboarding_upsell_dismissed
 279                                .store(false, Ordering::Release);
 280                        });
 281                    }
 282                    OnboardingUpsell::set_dismissed(false, cx);
 283                })
 284                .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
 285                    TrialEndUpsell::set_dismissed(false, cx);
 286                })
 287                .register_action(|workspace, _: &ResetAgentZoom, window, cx| {
 288                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 289                        panel.update(cx, |panel, cx| {
 290                            panel.reset_agent_zoom(window, cx);
 291                        });
 292                    }
 293                })
 294                .register_action(|workspace, _: &CopyThreadToClipboard, window, cx| {
 295                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 296                        panel.update(cx, |panel, cx| {
 297                            panel.copy_thread_to_clipboard(window, cx);
 298                        });
 299                    }
 300                })
 301                .register_action(|workspace, _: &LoadThreadFromClipboard, window, cx| {
 302                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 303                        workspace.focus_panel::<AgentPanel>(window, cx);
 304                        panel.update(cx, |panel, cx| {
 305                            panel.load_thread_from_clipboard(window, cx);
 306                        });
 307                    }
 308                })
 309                .register_action(|workspace, action: &ReviewBranchDiff, window, cx| {
 310                    let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
 311                        return;
 312                    };
 313
 314                    let mention_uri = MentionUri::GitDiff {
 315                        base_ref: action.base_ref.to_string(),
 316                    };
 317                    let diff_uri = mention_uri.to_uri().to_string();
 318
 319                    let content_blocks = vec![
 320                        acp::ContentBlock::Text(acp::TextContent::new(
 321                            "Please review this branch diff carefully. Point out any issues, \
 322                             potential bugs, or improvement opportunities you find.\n\n"
 323                                .to_string(),
 324                        )),
 325                        acp::ContentBlock::Resource(acp::EmbeddedResource::new(
 326                            acp::EmbeddedResourceResource::TextResourceContents(
 327                                acp::TextResourceContents::new(
 328                                    action.diff_text.to_string(),
 329                                    diff_uri,
 330                                ),
 331                            ),
 332                        )),
 333                    ];
 334
 335                    workspace.focus_panel::<AgentPanel>(window, cx);
 336
 337                    panel.update(cx, |panel, cx| {
 338                        panel.external_thread(
 339                            None,
 340                            None,
 341                            None,
 342                            None,
 343                            Some(AgentInitialContent::ContentBlock {
 344                                blocks: content_blocks,
 345                                auto_submit: true,
 346                            }),
 347                            true,
 348                            window,
 349                            cx,
 350                        );
 351                    });
 352                })
 353                .register_action(
 354                    |workspace, action: &ResolveConflictsWithAgent, window, cx| {
 355                        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
 356                            return;
 357                        };
 358
 359                        let content_blocks = build_conflict_resolution_prompt(&action.conflicts);
 360
 361                        workspace.focus_panel::<AgentPanel>(window, cx);
 362
 363                        panel.update(cx, |panel, cx| {
 364                            panel.external_thread(
 365                                None,
 366                                None,
 367                                None,
 368                                None,
 369                                Some(AgentInitialContent::ContentBlock {
 370                                    blocks: content_blocks,
 371                                    auto_submit: true,
 372                                }),
 373                                true,
 374                                window,
 375                                cx,
 376                            );
 377                        });
 378                    },
 379                )
 380                .register_action(
 381                    |workspace, action: &ResolveConflictedFilesWithAgent, window, cx| {
 382                        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
 383                            return;
 384                        };
 385
 386                        let content_blocks =
 387                            build_conflicted_files_resolution_prompt(&action.conflicted_file_paths);
 388
 389                        workspace.focus_panel::<AgentPanel>(window, cx);
 390
 391                        panel.update(cx, |panel, cx| {
 392                            panel.external_thread(
 393                                None,
 394                                None,
 395                                None,
 396                                None,
 397                                Some(AgentInitialContent::ContentBlock {
 398                                    blocks: content_blocks,
 399                                    auto_submit: true,
 400                                }),
 401                                true,
 402                                window,
 403                                cx,
 404                            );
 405                        });
 406                    },
 407                )
 408                .register_action(|workspace, action: &StartThreadIn, window, cx| {
 409                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 410                        panel.update(cx, |panel, cx| {
 411                            panel.set_start_thread_in(action, window, cx);
 412                        });
 413                    }
 414                })
 415                .register_action(|workspace, _: &CycleStartThreadIn, window, cx| {
 416                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 417                        panel.update(cx, |panel, cx| {
 418                            panel.cycle_start_thread_in(window, cx);
 419                        });
 420                    }
 421                })
 422                .register_action(
 423                    |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| {
 424                        let active_editor = workspace
 425                            .active_item(cx)
 426                            .and_then(|item| item.act_as::<Editor>(cx));
 427                        let has_editor_selection = active_editor.is_some_and(|editor| {
 428                            editor.update(cx, |editor, cx| {
 429                                editor.has_non_empty_selection(&editor.display_snapshot(cx))
 430                            })
 431                        });
 432
 433                        let has_terminal_selection = workspace
 434                            .active_item(cx)
 435                            .and_then(|item| item.act_as::<TerminalView>(cx))
 436                            .is_some_and(|terminal_view| {
 437                                terminal_view
 438                                    .read(cx)
 439                                    .terminal()
 440                                    .read(cx)
 441                                    .last_content
 442                                    .selection_text
 443                                    .as_ref()
 444                                    .is_some_and(|text| !text.is_empty())
 445                            });
 446
 447                        let has_terminal_panel_selection =
 448                            workspace.panel::<TerminalPanel>(cx).is_some_and(|panel| {
 449                                let position = match TerminalSettings::get_global(cx).dock {
 450                                    TerminalDockPosition::Left => DockPosition::Left,
 451                                    TerminalDockPosition::Bottom => DockPosition::Bottom,
 452                                    TerminalDockPosition::Right => DockPosition::Right,
 453                                };
 454                                let dock_is_open =
 455                                    workspace.dock_at_position(position).read(cx).is_open();
 456                                dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty()
 457                            });
 458
 459                        if !has_editor_selection
 460                            && !has_terminal_selection
 461                            && !has_terminal_panel_selection
 462                        {
 463                            return;
 464                        }
 465
 466                        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
 467                            return;
 468                        };
 469
 470                        if !panel.focus_handle(cx).contains_focused(window, cx) {
 471                            workspace.toggle_panel_focus::<AgentPanel>(window, cx);
 472                        }
 473
 474                        panel.update(cx, |_, cx| {
 475                            cx.defer_in(window, move |panel, window, cx| {
 476                                if let Some(conversation_view) = panel.active_conversation_view() {
 477                                    conversation_view.update(cx, |conversation_view, cx| {
 478                                        conversation_view.insert_selections(window, cx);
 479                                    });
 480                                }
 481                            });
 482                        });
 483                    },
 484                );
 485        },
 486    )
 487    .detach();
 488}
 489
 490fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock {
 491    let mention_uri = MentionUri::MergeConflict {
 492        file_path: conflict.file_path.clone(),
 493    };
 494    acp::ContentBlock::Resource(acp::EmbeddedResource::new(
 495        acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new(
 496            conflict.conflict_text.clone(),
 497            mention_uri.to_uri().to_string(),
 498        )),
 499    ))
 500}
 501
 502fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec<acp::ContentBlock> {
 503    if conflicts.is_empty() {
 504        return Vec::new();
 505    }
 506
 507    let mut blocks = Vec::new();
 508
 509    if conflicts.len() == 1 {
 510        let conflict = &conflicts[0];
 511
 512        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
 513            "Please resolve the following merge conflict in ",
 514        )));
 515        let mention = MentionUri::File {
 516            abs_path: PathBuf::from(conflict.file_path.clone()),
 517        };
 518        blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
 519            mention.name(),
 520            mention.to_uri(),
 521        )));
 522
 523        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
 524            indoc::formatdoc!(
 525                "\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs).
 526
 527                Analyze both versions carefully and resolve the conflict by editing \
 528                the file directly. Choose the resolution that best preserves the intent \
 529                of both changes, or combine them if appropriate.
 530
 531                ",
 532                ours = conflict.ours_branch_name,
 533                theirs = conflict.theirs_branch_name,
 534            ),
 535        )));
 536    } else {
 537        let n = conflicts.len();
 538        let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect();
 539        let ours = &conflicts[0].ours_branch_name;
 540        let theirs = &conflicts[0].theirs_branch_name;
 541        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
 542            indoc::formatdoc!(
 543                "Please resolve all {n} merge conflicts below.
 544
 545                The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs).
 546
 547                For each conflict, analyze both versions carefully and resolve them \
 548                by editing the file{suffix} directly. Choose resolutions that best preserve \
 549                the intent of both changes, or combine them if appropriate.
 550
 551                ",
 552                suffix = if unique_files.len() > 1 { "s" } else { "" },
 553            ),
 554        )));
 555    }
 556
 557    for conflict in conflicts {
 558        blocks.push(conflict_resource_block(conflict));
 559    }
 560
 561    blocks
 562}
 563
 564fn build_conflicted_files_resolution_prompt(
 565    conflicted_file_paths: &[String],
 566) -> Vec<acp::ContentBlock> {
 567    if conflicted_file_paths.is_empty() {
 568        return Vec::new();
 569    }
 570
 571    let instruction = indoc::indoc!(
 572        "The following files have unresolved merge conflicts. Please open each \
 573         file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \
 574         and resolve every conflict by editing the files directly.
 575
 576         Choose resolutions that best preserve the intent of both changes, \
 577         or combine them if appropriate.
 578
 579         Files with conflicts:
 580         ",
 581    );
 582
 583    let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))];
 584    for path in conflicted_file_paths {
 585        let mention = MentionUri::File {
 586            abs_path: PathBuf::from(path),
 587        };
 588        content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
 589            mention.name(),
 590            mention.to_uri(),
 591        )));
 592        content.push(acp::ContentBlock::Text(acp::TextContent::new("\n")));
 593    }
 594    content
 595}
 596
 597/// Unique identifier for a sidebar draft thread. Not persisted across restarts.
 598/// IDs are globally unique across all AgentPanel instances within the same app.
 599#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
 600pub struct DraftId(pub usize);
 601
 602#[derive(Default)]
 603struct DraftIdCounter(usize);
 604
 605impl Global for DraftIdCounter {}
 606
 607impl DraftId {
 608    fn next(cx: &mut App) -> Self {
 609        let counter = cx.default_global::<DraftIdCounter>();
 610        let id = counter.0;
 611        counter.0 += 1;
 612        Self(id)
 613    }
 614}
 615
 616enum ActiveView {
 617    Uninitialized,
 618    AgentThread {
 619        conversation_view: Entity<ConversationView>,
 620    },
 621    History {
 622        view: Entity<ThreadHistoryView>,
 623    },
 624    Configuration,
 625}
 626
 627enum WhichFontSize {
 628    AgentFont,
 629    None,
 630}
 631
 632struct StartThreadInLabel {
 633    prefix: Option<SharedString>,
 634    label: SharedString,
 635    suffix: Option<SharedString>,
 636}
 637
 638impl StartThreadIn {
 639    fn trigger_label(&self, project: &Project, cx: &App) -> StartThreadInLabel {
 640        match self {
 641            Self::LocalProject => {
 642                let suffix = project.active_repository(cx).and_then(|repo| {
 643                    let repo = repo.read(cx);
 644                    let work_dir = &repo.original_repo_abs_path;
 645                    let visible_paths: Vec<_> = project
 646                        .visible_worktrees(cx)
 647                        .map(|wt| wt.read(cx).abs_path().to_path_buf())
 648                        .collect();
 649
 650                    for linked in repo.linked_worktrees() {
 651                        if visible_paths.contains(&linked.path) {
 652                            return Some(SharedString::from(format!(
 653                                "({})",
 654                                linked.display_name()
 655                            )));
 656                        }
 657                    }
 658
 659                    if let Some(name) = linked_worktree_short_name(
 660                        repo.original_repo_abs_path.as_ref(),
 661                        repo.work_directory_abs_path.as_ref(),
 662                    ) {
 663                        if visible_paths
 664                            .iter()
 665                            .any(|p| p.as_path() == repo.work_directory_abs_path.as_ref())
 666                        {
 667                            return Some(SharedString::from(format!("({})", name)));
 668                        }
 669                    }
 670
 671                    if visible_paths
 672                        .iter()
 673                        .any(|p| p.as_path() == work_dir.as_ref())
 674                    {
 675                        return Some("(main)".into());
 676                    }
 677
 678                    None
 679                });
 680
 681                StartThreadInLabel {
 682                    prefix: None,
 683                    label: "Current Worktree".into(),
 684                    suffix,
 685                }
 686            }
 687            Self::NewWorktree {
 688                worktree_name: Some(worktree_name),
 689                ..
 690            } => StartThreadInLabel {
 691                prefix: Some("New:".into()),
 692                label: worktree_name.clone().into(),
 693                suffix: None,
 694            },
 695            Self::NewWorktree { .. } => StartThreadInLabel {
 696                prefix: None,
 697                label: "New Git Worktree".into(),
 698                suffix: None,
 699            },
 700            Self::LinkedWorktree { display_name, .. } => StartThreadInLabel {
 701                prefix: Some("From:".into()),
 702                label: display_name.clone().into(),
 703                suffix: None,
 704            },
 705        }
 706    }
 707
 708    fn branch_trigger_label(&self, project: &Project, cx: &App) -> Option<StartThreadInLabel> {
 709        match self {
 710            Self::NewWorktree { branch_target, .. } => {
 711                let (branch_name, is_occupied) = match branch_target {
 712                    NewWorktreeBranchTarget::CurrentBranch => {
 713                        let name: SharedString = if project.repositories(cx).len() > 1 {
 714                            "current branches".into()
 715                        } else {
 716                            project
 717                                .active_repository(cx)
 718                                .and_then(|repo| {
 719                                    repo.read(cx)
 720                                        .branch
 721                                        .as_ref()
 722                                        .map(|branch| SharedString::from(branch.name().to_string()))
 723                                })
 724                                .unwrap_or_else(|| "HEAD".into())
 725                        };
 726                        (name, false)
 727                    }
 728                    NewWorktreeBranchTarget::ExistingBranch { name } => {
 729                        let occupied = Self::is_branch_occupied(name, project, cx);
 730                        (name.clone().into(), occupied)
 731                    }
 732                    NewWorktreeBranchTarget::CreateBranch {
 733                        from_ref: Some(from_ref),
 734                        ..
 735                    } => {
 736                        let occupied = Self::is_branch_occupied(from_ref, project, cx);
 737                        (from_ref.clone().into(), occupied)
 738                    }
 739                    NewWorktreeBranchTarget::CreateBranch { name, .. } => {
 740                        (name.clone().into(), false)
 741                    }
 742                };
 743
 744                let prefix = if is_occupied {
 745                    Some("New From:".into())
 746                } else {
 747                    None
 748                };
 749
 750                Some(StartThreadInLabel {
 751                    prefix,
 752                    label: branch_name,
 753                    suffix: None,
 754                })
 755            }
 756            _ => None,
 757        }
 758    }
 759
 760    fn is_branch_occupied(branch_name: &str, project: &Project, cx: &App) -> bool {
 761        project.repositories(cx).values().any(|repo| {
 762            repo.read(cx)
 763                .linked_worktrees
 764                .iter()
 765                .any(|wt| wt.branch_name() == Some(branch_name))
 766        })
 767    }
 768}
 769
 770#[derive(Clone, Debug)]
 771#[allow(dead_code)]
 772pub enum WorktreeCreationStatus {
 773    Creating,
 774    Error(SharedString),
 775}
 776
 777#[derive(Clone, Debug)]
 778enum WorktreeCreationArgs {
 779    New {
 780        worktree_name: Option<String>,
 781        branch_target: NewWorktreeBranchTarget,
 782    },
 783    Linked {
 784        worktree_path: PathBuf,
 785    },
 786}
 787
 788impl ActiveView {
 789    pub fn which_font_size_used(&self) -> WhichFontSize {
 790        match self {
 791            ActiveView::Uninitialized
 792            | ActiveView::AgentThread { .. }
 793            | ActiveView::History { .. } => WhichFontSize::AgentFont,
 794            ActiveView::Configuration => WhichFontSize::None,
 795        }
 796    }
 797}
 798
 799pub struct AgentPanel {
 800    workspace: WeakEntity<Workspace>,
 801    /// Workspace id is used as a database key
 802    workspace_id: Option<WorkspaceId>,
 803    user_store: Entity<UserStore>,
 804    project: Entity<Project>,
 805    fs: Arc<dyn Fs>,
 806    language_registry: Arc<LanguageRegistry>,
 807    thread_store: Entity<ThreadStore>,
 808    prompt_store: Option<Entity<PromptStore>>,
 809    connection_store: Entity<AgentConnectionStore>,
 810    context_server_registry: Entity<ContextServerRegistry>,
 811    configuration: Option<Entity<AgentConfiguration>>,
 812    configuration_subscription: Option<Subscription>,
 813    focus_handle: FocusHandle,
 814    active_view: ActiveView,
 815    previous_view: Option<ActiveView>,
 816    background_threads: HashMap<acp::SessionId, Entity<ConversationView>>,
 817    draft_threads: HashMap<DraftId, Entity<ConversationView>>,
 818    new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
 819    start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
 820    thread_branch_menu_handle: PopoverMenuHandle<ThreadBranchPicker>,
 821    agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
 822    agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
 823    agent_navigation_menu: Option<Entity<ContextMenu>>,
 824    _extension_subscription: Option<Subscription>,
 825    _project_subscription: Subscription,
 826    _git_store_subscription: Subscription,
 827    zoomed: bool,
 828    pending_serialization: Option<Task<Result<()>>>,
 829    new_user_onboarding: Entity<AgentPanelOnboarding>,
 830    new_user_onboarding_upsell_dismissed: AtomicBool,
 831    agent_layout_onboarding: Entity<ai_onboarding::AgentLayoutOnboarding>,
 832    agent_layout_onboarding_dismissed: AtomicBool,
 833    selected_agent: Agent,
 834    start_thread_in: StartThreadIn,
 835    worktree_creation_status: Option<(EntityId, WorktreeCreationStatus)>,
 836    _thread_view_subscription: Option<Subscription>,
 837    _active_thread_focus_subscription: Option<Subscription>,
 838    _worktree_creation_task: Option<Task<()>>,
 839    show_trust_workspace_message: bool,
 840    _active_view_observation: Option<Subscription>,
 841}
 842
 843impl AgentPanel {
 844    fn serialize(&mut self, cx: &mut App) {
 845        let Some(workspace_id) = self.workspace_id else {
 846            return;
 847        };
 848
 849        let selected_agent = self.selected_agent.clone();
 850        let start_thread_in = Some(self.start_thread_in.clone());
 851
 852        let last_active_thread = self.active_agent_thread(cx).map(|thread| {
 853            let thread = thread.read(cx);
 854            let title = thread.title();
 855            let work_dirs = thread.work_dirs().cloned();
 856            SerializedActiveThread {
 857                session_id: thread.session_id().0.to_string(),
 858                agent_type: self.selected_agent.clone(),
 859                title: title.map(|t| t.to_string()),
 860                work_dirs: work_dirs.map(|dirs| dirs.serialize()),
 861            }
 862        });
 863
 864        let kvp = KeyValueStore::global(cx);
 865        self.pending_serialization = Some(cx.background_spawn(async move {
 866            save_serialized_panel(
 867                workspace_id,
 868                SerializedAgentPanel {
 869                    selected_agent: Some(selected_agent),
 870                    last_active_thread,
 871                    start_thread_in,
 872                },
 873                kvp,
 874            )
 875            .await?;
 876            anyhow::Ok(())
 877        }));
 878    }
 879
 880    pub fn load(
 881        workspace: WeakEntity<Workspace>,
 882        mut cx: AsyncWindowContext,
 883    ) -> Task<Result<Entity<Self>>> {
 884        let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
 885        let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)).ok();
 886        cx.spawn(async move |cx| {
 887            let prompt_store = match prompt_store {
 888                Ok(prompt_store) => prompt_store.await.ok(),
 889                Err(_) => None,
 890            };
 891            let workspace_id = workspace
 892                .read_with(cx, |workspace, _| workspace.database_id())
 893                .ok()
 894                .flatten();
 895
 896            let (serialized_panel, global_last_used_agent) = cx
 897                .background_spawn(async move {
 898                    match kvp {
 899                        Some(kvp) => {
 900                            let panel = workspace_id
 901                                .and_then(|id| read_serialized_panel(id, &kvp))
 902                                .or_else(|| read_legacy_serialized_panel(&kvp));
 903                            let global_agent = read_global_last_used_agent(&kvp);
 904                            (panel, global_agent)
 905                        }
 906                        None => (None, None),
 907                    }
 908                })
 909                .await;
 910
 911            let last_active_thread = if let Some(thread_info) = serialized_panel
 912                .as_ref()
 913                .and_then(|p| p.last_active_thread.as_ref())
 914            {
 915                let session_id = acp::SessionId::new(thread_info.session_id.clone());
 916                let has_metadata = cx
 917                    .update(|_window, cx| {
 918                        let store = ThreadMetadataStore::global(cx);
 919                        store.read(cx).entry(&session_id).is_some()
 920                    })
 921                    .unwrap_or(false);
 922                if has_metadata {
 923                    Some(thread_info)
 924                } else {
 925                    log::warn!(
 926                        "last active thread {} has no metadata, skipping restoration",
 927                        thread_info.session_id
 928                    );
 929                    None
 930                }
 931            } else {
 932                None
 933            };
 934
 935            let panel = workspace.update_in(cx, |workspace, window, cx| {
 936                let panel =
 937                    cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
 938
 939                panel.update(cx, |panel, cx| {
 940                    let is_via_collab = panel.project.read(cx).is_via_collab();
 941
 942                    // Only apply a non-native global fallback to local projects.
 943                    // Collab workspaces only support NativeAgent, so inheriting a
 944                    // custom agent would cause set_active → new_agent_thread_inner
 945                    // to bypass the collab guard in external_thread.
 946                    let global_fallback = global_last_used_agent
 947                        .filter(|agent| !is_via_collab || agent.is_native());
 948
 949                    if let Some(serialized_panel) = &serialized_panel {
 950                        if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
 951                            panel.selected_agent = selected_agent;
 952                        } else if let Some(agent) = global_fallback {
 953                            panel.selected_agent = agent;
 954                        }
 955                        if let Some(ref start_thread_in) = serialized_panel.start_thread_in {
 956                            let is_valid = match &start_thread_in {
 957                                StartThreadIn::LocalProject => true,
 958                                StartThreadIn::NewWorktree { .. } => {
 959                                    let project = panel.project.read(cx);
 960                                    !project.is_via_collab()
 961                                }
 962                                StartThreadIn::LinkedWorktree { path, .. } => {
 963                                    path.exists()
 964                                }
 965                            };
 966                            if is_valid {
 967                                panel.start_thread_in = start_thread_in.clone();
 968                            } else {
 969                                log::info!(
 970                                    "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
 971                                    start_thread_in,
 972                                );
 973                            }
 974                        }
 975                    } else if let Some(agent) = global_fallback {
 976                        panel.selected_agent = agent;
 977                    }
 978                    cx.notify();
 979                });
 980
 981                if let Some(thread_info) = last_active_thread {
 982                    let agent = thread_info.agent_type.clone();
 983                    panel.update(cx, |panel, cx| {
 984                        panel.selected_agent = agent.clone();
 985                        panel.load_agent_thread(
 986                            agent,
 987                            thread_info.session_id.clone().into(),
 988                            thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)),
 989                            thread_info.title.as_ref().map(|t| t.clone().into()),
 990                            false,
 991                            window,
 992                            cx,
 993                        );
 994                    });
 995                }
 996                panel
 997            })?;
 998
 999            Ok(panel)
1000        })
1001    }
1002
1003    pub(crate) fn new(
1004        workspace: &Workspace,
1005        prompt_store: Option<Entity<PromptStore>>,
1006        window: &mut Window,
1007        cx: &mut Context<Self>,
1008    ) -> Self {
1009        let fs = workspace.app_state().fs.clone();
1010        let user_store = workspace.app_state().user_store.clone();
1011        let project = workspace.project();
1012        let language_registry = project.read(cx).languages().clone();
1013        let client = workspace.client().clone();
1014        let workspace_id = workspace.database_id();
1015        let workspace = workspace.weak_handle();
1016
1017        let context_server_registry =
1018            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1019
1020        let thread_store = ThreadStore::global(cx);
1021
1022        let active_view = ActiveView::Uninitialized;
1023
1024        let weak_panel = cx.entity().downgrade();
1025
1026        window.defer(cx, move |window, cx| {
1027            let panel = weak_panel.clone();
1028            let agent_navigation_menu =
1029                ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| {
1030                    if let Some(panel) = panel.upgrade() {
1031                        if let Some(history) = panel
1032                            .update(cx, |panel, cx| panel.history_for_selected_agent(window, cx))
1033                        {
1034                            menu = Self::populate_recently_updated_menu_section(
1035                                menu, panel, history, cx,
1036                            );
1037                            menu = menu.action("View All", Box::new(OpenHistory));
1038                        }
1039                    }
1040
1041                    menu = menu
1042                        .fixed_width(px(320.).into())
1043                        .keep_open_on_confirm(false)
1044                        .key_context("NavigationMenu");
1045
1046                    menu
1047                });
1048            weak_panel
1049                .update(cx, |panel, cx| {
1050                    cx.subscribe_in(
1051                        &agent_navigation_menu,
1052                        window,
1053                        |_, menu, _: &DismissEvent, window, cx| {
1054                            menu.update(cx, |menu, _| {
1055                                menu.clear_selected();
1056                            });
1057                            cx.focus_self(window);
1058                        },
1059                    )
1060                    .detach();
1061                    panel.agent_navigation_menu = Some(agent_navigation_menu);
1062                })
1063                .ok();
1064        });
1065
1066        let weak_panel = cx.entity().downgrade();
1067        let onboarding = cx.new(|cx| {
1068            AgentPanelOnboarding::new(
1069                user_store.clone(),
1070                client,
1071                move |_window, cx| {
1072                    weak_panel
1073                        .update(cx, |panel, cx| {
1074                            panel.dismiss_ai_onboarding(cx);
1075                        })
1076                        .ok();
1077                },
1078                cx,
1079            )
1080        });
1081
1082        let weak_panel = cx.entity().downgrade();
1083
1084        let layout = AgentSettings::get_layout(cx);
1085        let is_agent_layout = matches!(layout, WindowLayout::Agent(_));
1086
1087        let agent_layout_onboarding = cx.new(|_cx| ai_onboarding::AgentLayoutOnboarding {
1088            use_agent_layout: Arc::new({
1089                let fs = fs.clone();
1090                let weak_panel = weak_panel.clone();
1091                move |_window, cx| {
1092                    AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
1093                    weak_panel
1094                        .update(cx, |panel, cx| {
1095                            panel.dismiss_agent_layout_onboarding(cx);
1096                        })
1097                        .ok();
1098                }
1099            }),
1100            revert_to_editor_layout: Arc::new({
1101                let fs = fs.clone();
1102                let weak_panel = weak_panel.clone();
1103                move |_window, cx| {
1104                    AgentSettings::set_layout(WindowLayout::Editor(None), fs.clone(), cx);
1105                    weak_panel
1106                        .update(cx, |panel, cx| {
1107                            panel.dismiss_agent_layout_onboarding(cx);
1108                        })
1109                        .ok();
1110                }
1111            }),
1112            dismissed: Arc::new(move |_window, cx| {
1113                weak_panel
1114                    .update(cx, |panel, cx| {
1115                        panel.dismiss_agent_layout_onboarding(cx);
1116                    })
1117                    .ok();
1118            }),
1119            is_agent_layout,
1120        });
1121
1122        // Subscribe to extension events to sync agent servers when extensions change
1123        let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
1124        {
1125            Some(
1126                cx.subscribe(&extension_events, |this, _source, event, cx| match event {
1127                    extension::Event::ExtensionInstalled(_)
1128                    | extension::Event::ExtensionUninstalled(_)
1129                    | extension::Event::ExtensionsInstalledChanged => {
1130                        this.sync_agent_servers_from_extensions(cx);
1131                    }
1132                    _ => {}
1133                }),
1134            )
1135        } else {
1136            None
1137        };
1138
1139        let connection_store = cx.new(|cx| {
1140            let mut store = AgentConnectionStore::new(project.clone(), cx);
1141            // Register the native agent right away, so that it is available for
1142            // the inline assistant etc.
1143            store.request_connection(
1144                Agent::NativeAgent,
1145                Agent::NativeAgent.server(fs.clone(), thread_store.clone()),
1146                cx,
1147            );
1148            store
1149        });
1150        let _project_subscription =
1151            cx.subscribe(&project, |this, _project, event, cx| match event {
1152                project::Event::WorktreeAdded(_)
1153                | project::Event::WorktreeRemoved(_)
1154                | project::Event::WorktreeOrderChanged => {
1155                    this.update_thread_work_dirs(cx);
1156                }
1157                _ => {}
1158            });
1159        let git_store = project.read(cx).git_store().clone();
1160        let _git_store_subscription = cx.subscribe(&git_store, |this, _, event, cx| {
1161            let should_sync = matches!(
1162                event,
1163                GitStoreEvent::ActiveRepositoryChanged(_)
1164                    | GitStoreEvent::RepositoryAdded
1165                    | GitStoreEvent::RepositoryRemoved(_)
1166                    | GitStoreEvent::RepositoryUpdated(
1167                        _,
1168                        RepositoryEvent::HeadChanged
1169                            | RepositoryEvent::BranchListChanged
1170                            | RepositoryEvent::GitWorktreeListChanged,
1171                        _,
1172                    )
1173            );
1174
1175            if should_sync {
1176                this.sync_start_thread_in_with_git_state(cx);
1177            }
1178        });
1179
1180        let mut panel = Self {
1181            workspace_id,
1182            active_view,
1183            workspace,
1184            user_store,
1185            project: project.clone(),
1186            fs: fs.clone(),
1187            language_registry,
1188            prompt_store,
1189            connection_store,
1190            configuration: None,
1191            configuration_subscription: None,
1192            focus_handle: cx.focus_handle(),
1193            context_server_registry,
1194            previous_view: None,
1195            background_threads: HashMap::default(),
1196            draft_threads: HashMap::default(),
1197            new_thread_menu_handle: PopoverMenuHandle::default(),
1198            start_thread_in_menu_handle: PopoverMenuHandle::default(),
1199            thread_branch_menu_handle: PopoverMenuHandle::default(),
1200            agent_panel_menu_handle: PopoverMenuHandle::default(),
1201            agent_navigation_menu_handle: PopoverMenuHandle::default(),
1202            agent_navigation_menu: None,
1203            _extension_subscription: extension_subscription,
1204            _project_subscription,
1205            _git_store_subscription,
1206            zoomed: false,
1207            pending_serialization: None,
1208            new_user_onboarding: onboarding,
1209            agent_layout_onboarding,
1210            thread_store,
1211            selected_agent: Agent::default(),
1212            start_thread_in: StartThreadIn::default(),
1213            worktree_creation_status: None,
1214            _thread_view_subscription: None,
1215            _active_thread_focus_subscription: None,
1216            _worktree_creation_task: None,
1217            show_trust_workspace_message: false,
1218            new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
1219            agent_layout_onboarding_dismissed: AtomicBool::new(AgentLayoutOnboarding::dismissed(
1220                cx,
1221            )),
1222            _active_view_observation: None,
1223        };
1224
1225        // Initial sync of agent servers from extensions
1226        panel.sync_agent_servers_from_extensions(cx);
1227        panel
1228    }
1229
1230    pub fn toggle_focus(
1231        workspace: &mut Workspace,
1232        _: &ToggleFocus,
1233        window: &mut Window,
1234        cx: &mut Context<Workspace>,
1235    ) {
1236        if workspace
1237            .panel::<Self>(cx)
1238            .is_some_and(|panel| panel.read(cx).enabled(cx))
1239        {
1240            workspace.toggle_panel_focus::<Self>(window, cx);
1241        }
1242    }
1243
1244    pub fn toggle(
1245        workspace: &mut Workspace,
1246        _: &Toggle,
1247        window: &mut Window,
1248        cx: &mut Context<Workspace>,
1249    ) {
1250        if workspace
1251            .panel::<Self>(cx)
1252            .is_some_and(|panel| panel.read(cx).enabled(cx))
1253        {
1254            if !workspace.toggle_panel_focus::<Self>(window, cx) {
1255                workspace.close_panel::<Self>(window, cx);
1256            }
1257        }
1258    }
1259
1260    pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
1261        &self.prompt_store
1262    }
1263
1264    pub fn thread_store(&self) -> &Entity<ThreadStore> {
1265        &self.thread_store
1266    }
1267
1268    pub fn connection_store(&self) -> &Entity<AgentConnectionStore> {
1269        &self.connection_store
1270    }
1271
1272    pub fn open_thread(
1273        &mut self,
1274        session_id: acp::SessionId,
1275        work_dirs: Option<PathList>,
1276        title: Option<SharedString>,
1277        window: &mut Window,
1278        cx: &mut Context<Self>,
1279    ) {
1280        self.external_thread(
1281            Some(crate::Agent::NativeAgent),
1282            Some(session_id),
1283            work_dirs,
1284            title,
1285            None,
1286            true,
1287            window,
1288            cx,
1289        );
1290    }
1291
1292    pub(crate) fn context_server_registry(&self) -> &Entity<ContextServerRegistry> {
1293        &self.context_server_registry
1294    }
1295
1296    pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
1297        let workspace_read = workspace.read(cx);
1298
1299        workspace_read
1300            .panel::<AgentPanel>(cx)
1301            .map(|panel| {
1302                let panel_id = Entity::entity_id(&panel);
1303
1304                workspace_read.all_docks().iter().any(|dock| {
1305                    dock.read(cx)
1306                        .visible_panel()
1307                        .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
1308                })
1309            })
1310            .unwrap_or(false)
1311    }
1312
1313    /// Reset the panel to the uninitialized state, clearing any active
1314    /// thread without creating a new draft. Running threads are retained
1315    /// in the background. The sidebar suppresses the uninitialized state
1316    /// so no "Draft" entry appears.
1317    pub fn clear_active_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1318        self.set_active_view(ActiveView::Uninitialized, false, window, cx);
1319    }
1320
1321    pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
1322        let id = self.create_draft(window, cx);
1323        self.activate_draft(id, true, window, cx);
1324    }
1325
1326    /// Creates a new empty draft thread and stores it. Returns the DraftId.
1327    /// The draft is NOT activated — call `activate_draft` to show it.
1328    pub fn create_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) -> DraftId {
1329        let id = DraftId::next(cx);
1330        let workspace = self.workspace.clone();
1331        let project = self.project.clone();
1332        let fs = self.fs.clone();
1333        let thread_store = self.thread_store.clone();
1334        let agent = if self.project.read(cx).is_via_collab() {
1335            Agent::NativeAgent
1336        } else {
1337            self.selected_agent.clone()
1338        };
1339        let server = agent.server(fs, thread_store);
1340        let conversation_view = self.create_agent_thread(
1341            server, None, None, None, None, workspace, project, agent, window, cx,
1342        );
1343        self.draft_threads.insert(id, conversation_view);
1344        id
1345    }
1346
1347    pub fn activate_draft(
1348        &mut self,
1349        id: DraftId,
1350        focus: bool,
1351        window: &mut Window,
1352        cx: &mut Context<Self>,
1353    ) {
1354        let Some(conversation_view) = self.draft_threads.get(&id).cloned() else {
1355            return;
1356        };
1357        self.set_active_view(
1358            ActiveView::AgentThread { conversation_view },
1359            focus,
1360            window,
1361            cx,
1362        );
1363    }
1364
1365    /// Removes a draft thread. If it's currently active, does nothing to
1366    /// the active view — the caller should activate something else first.
1367    pub fn remove_draft(&mut self, id: DraftId) {
1368        self.draft_threads.remove(&id);
1369    }
1370
1371    /// Returns the DraftId of the currently active draft, if the active
1372    /// view is a draft thread tracked in `draft_threads`.
1373    pub fn active_draft_id(&self) -> Option<DraftId> {
1374        let active_cv = self.active_conversation_view()?;
1375        self.draft_threads
1376            .iter()
1377            .find_map(|(id, cv)| (cv.entity_id() == active_cv.entity_id()).then_some(*id))
1378    }
1379
1380    /// Returns all draft IDs, sorted newest-first.
1381    pub fn draft_ids(&self) -> Vec<DraftId> {
1382        let mut ids: Vec<DraftId> = self.draft_threads.keys().copied().collect();
1383        ids.sort_by_key(|id| std::cmp::Reverse(id.0));
1384        ids
1385    }
1386
1387    /// Returns the text from a draft's message editor, or `None` if the
1388    /// draft doesn't exist or has no text.
1389    pub fn draft_editor_text(&self, id: DraftId, cx: &App) -> Option<String> {
1390        let cv = self.draft_threads.get(&id)?;
1391        let tv = cv.read(cx).active_thread()?;
1392        let text = tv.read(cx).message_editor.read(cx).text(cx);
1393        if text.trim().is_empty() {
1394            None
1395        } else {
1396            Some(text)
1397        }
1398    }
1399
1400    /// Clears the message editor text of a tracked draft.
1401    pub fn clear_draft_editor(&self, id: DraftId, window: &mut Window, cx: &mut Context<Self>) {
1402        let Some(cv) = self.draft_threads.get(&id) else {
1403            return;
1404        };
1405        let Some(tv) = cv.read(cx).active_thread() else {
1406            return;
1407        };
1408        let editor = tv.read(cx).message_editor.clone();
1409        editor.update(cx, |editor, cx| {
1410            editor.clear(window, cx);
1411        });
1412    }
1413
1414    fn take_active_draft_initial_content(
1415        &mut self,
1416        cx: &mut Context<Self>,
1417    ) -> Option<AgentInitialContent> {
1418        self.active_thread_view(cx).and_then(|thread_view| {
1419            thread_view.update(cx, |thread_view, cx| {
1420                let draft_blocks = thread_view
1421                    .thread
1422                    .read(cx)
1423                    .draft_prompt()
1424                    .map(|draft| draft.to_vec())
1425                    .filter(|draft| !draft.is_empty());
1426
1427                let draft_blocks = draft_blocks.or_else(|| {
1428                    let text = thread_view.message_editor.read(cx).text(cx);
1429                    if text.trim().is_empty() {
1430                        None
1431                    } else {
1432                        Some(vec![acp::ContentBlock::Text(acp::TextContent::new(text))])
1433                    }
1434                });
1435
1436                draft_blocks.map(|blocks| AgentInitialContent::ContentBlock {
1437                    blocks,
1438                    auto_submit: false,
1439                })
1440            })
1441        })
1442    }
1443
1444    fn new_native_agent_thread_from_summary(
1445        &mut self,
1446        action: &NewNativeAgentThreadFromSummary,
1447        window: &mut Window,
1448        cx: &mut Context<Self>,
1449    ) {
1450        let session_id = action.from_session_id.clone();
1451
1452        let Some(history) = self
1453            .connection_store
1454            .read(cx)
1455            .entry(&Agent::NativeAgent)
1456            .and_then(|e| e.read(cx).history().cloned())
1457        else {
1458            debug_panic!("Native agent is not registered");
1459            return;
1460        };
1461
1462        cx.spawn_in(window, async move |this, cx| {
1463            this.update_in(cx, |this, window, cx| {
1464                let thread = history
1465                    .read(cx)
1466                    .session_for_id(&session_id)
1467                    .context("Session not found")?;
1468
1469                this.external_thread(
1470                    Some(Agent::NativeAgent),
1471                    None,
1472                    None,
1473                    None,
1474                    Some(AgentInitialContent::ThreadSummary {
1475                        session_id: thread.session_id,
1476                        title: thread.title,
1477                    }),
1478                    true,
1479                    window,
1480                    cx,
1481                );
1482                anyhow::Ok(())
1483            })
1484        })
1485        .detach_and_log_err(cx);
1486    }
1487
1488    fn external_thread(
1489        &mut self,
1490        agent_choice: Option<crate::Agent>,
1491        resume_session_id: Option<acp::SessionId>,
1492        work_dirs: Option<PathList>,
1493        title: Option<SharedString>,
1494        initial_content: Option<AgentInitialContent>,
1495        focus: bool,
1496        window: &mut Window,
1497        cx: &mut Context<Self>,
1498    ) {
1499        let workspace = self.workspace.clone();
1500        let project = self.project.clone();
1501        let fs = self.fs.clone();
1502        let thread_store = self.thread_store.clone();
1503
1504        let agent = agent_choice.unwrap_or_else(|| {
1505            if self.project.read(cx).is_via_collab() {
1506                Agent::NativeAgent
1507            } else {
1508                self.selected_agent.clone()
1509            }
1510        });
1511
1512        let server = agent.server(fs, thread_store);
1513        let conversation_view = self.create_agent_thread(
1514            server,
1515            resume_session_id,
1516            work_dirs,
1517            title,
1518            initial_content,
1519            workspace,
1520            project,
1521            agent,
1522            window,
1523            cx,
1524        );
1525        self.set_active_view(
1526            ActiveView::AgentThread { conversation_view },
1527            focus,
1528            window,
1529            cx,
1530        );
1531    }
1532
1533    fn deploy_rules_library(
1534        &mut self,
1535        action: &OpenRulesLibrary,
1536        _window: &mut Window,
1537        cx: &mut Context<Self>,
1538    ) {
1539        open_rules_library(
1540            self.language_registry.clone(),
1541            Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
1542            action
1543                .prompt_to_select
1544                .map(|uuid| UserPromptId(uuid).into()),
1545            cx,
1546        )
1547        .detach_and_log_err(cx);
1548    }
1549
1550    fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1551        let Some(conversation_view) = self.active_conversation_view() else {
1552            return;
1553        };
1554
1555        let Some(active_thread) = conversation_view.read(cx).active_thread().cloned() else {
1556            return;
1557        };
1558
1559        active_thread.update(cx, |active_thread, cx| {
1560            active_thread.expand_message_editor(&ExpandMessageEditor, window, cx);
1561            active_thread.focus_handle(cx).focus(window, cx);
1562        })
1563    }
1564
1565    fn has_history_for_selected_agent(&self, cx: &App) -> bool {
1566        match &self.selected_agent {
1567            Agent::NativeAgent => true,
1568            Agent::Custom { .. } => self
1569                .connection_store
1570                .read(cx)
1571                .entry(&self.selected_agent)
1572                .map_or(false, |entry| entry.read(cx).history().is_some()),
1573        }
1574    }
1575
1576    fn history_for_selected_agent(
1577        &self,
1578        window: &mut Window,
1579        cx: &mut Context<Self>,
1580    ) -> Option<Entity<ThreadHistoryView>> {
1581        let agent = self.selected_agent.clone();
1582        let history = self
1583            .connection_store
1584            .read(cx)
1585            .entry(&agent)?
1586            .read(cx)
1587            .history()?
1588            .clone();
1589        Some(self.create_thread_history_view(agent, history, window, cx))
1590    }
1591
1592    fn create_thread_history_view(
1593        &self,
1594        agent: Agent,
1595        history: Entity<ThreadHistory>,
1596        window: &mut Window,
1597        cx: &mut Context<Self>,
1598    ) -> Entity<ThreadHistoryView> {
1599        let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx));
1600        cx.subscribe_in(
1601            &view,
1602            window,
1603            move |this, _, event, window, cx| match event {
1604                ThreadHistoryViewEvent::Open(thread) => {
1605                    this.load_agent_thread(
1606                        agent.clone(),
1607                        thread.session_id.clone(),
1608                        thread.work_dirs.clone(),
1609                        thread.title.clone(),
1610                        true,
1611                        window,
1612                        cx,
1613                    );
1614                }
1615            },
1616        )
1617        .detach();
1618        view
1619    }
1620
1621    fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1622        let Some(view) = self.history_for_selected_agent(window, cx) else {
1623            return;
1624        };
1625
1626        if let ActiveView::History { view: active_view } = &self.active_view {
1627            if active_view == &view {
1628                if let Some(previous_view) = self.previous_view.take() {
1629                    self.set_active_view(previous_view, true, window, cx);
1630                }
1631                return;
1632            }
1633        }
1634
1635        self.set_active_view(ActiveView::History { view }, true, window, cx);
1636        cx.notify();
1637    }
1638
1639    pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
1640        match self.active_view {
1641            ActiveView::Configuration | ActiveView::History { .. } => {
1642                if let Some(previous_view) = self.previous_view.take() {
1643                    self.set_active_view(previous_view, true, window, cx);
1644                }
1645                cx.notify();
1646            }
1647            _ => {}
1648        }
1649    }
1650
1651    pub fn toggle_navigation_menu(
1652        &mut self,
1653        _: &ToggleNavigationMenu,
1654        window: &mut Window,
1655        cx: &mut Context<Self>,
1656    ) {
1657        if !self.has_history_for_selected_agent(cx) {
1658            return;
1659        }
1660        self.agent_navigation_menu_handle.toggle(window, cx);
1661    }
1662
1663    pub fn toggle_options_menu(
1664        &mut self,
1665        _: &ToggleOptionsMenu,
1666        window: &mut Window,
1667        cx: &mut Context<Self>,
1668    ) {
1669        self.agent_panel_menu_handle.toggle(window, cx);
1670    }
1671
1672    pub fn toggle_new_thread_menu(
1673        &mut self,
1674        _: &ToggleNewThreadMenu,
1675        window: &mut Window,
1676        cx: &mut Context<Self>,
1677    ) {
1678        self.new_thread_menu_handle.toggle(window, cx);
1679    }
1680
1681    pub fn increase_font_size(
1682        &mut self,
1683        action: &IncreaseBufferFontSize,
1684        _: &mut Window,
1685        cx: &mut Context<Self>,
1686    ) {
1687        self.handle_font_size_action(action.persist, px(1.0), cx);
1688    }
1689
1690    pub fn decrease_font_size(
1691        &mut self,
1692        action: &DecreaseBufferFontSize,
1693        _: &mut Window,
1694        cx: &mut Context<Self>,
1695    ) {
1696        self.handle_font_size_action(action.persist, px(-1.0), cx);
1697    }
1698
1699    fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
1700        match self.active_view.which_font_size_used() {
1701            WhichFontSize::AgentFont => {
1702                if persist {
1703                    update_settings_file(self.fs.clone(), cx, move |settings, cx| {
1704                        let agent_ui_font_size =
1705                            ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta;
1706                        let agent_buffer_font_size =
1707                            ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta;
1708
1709                        let _ = settings.theme.agent_ui_font_size.insert(
1710                            f32::from(theme_settings::clamp_font_size(agent_ui_font_size)).into(),
1711                        );
1712                        let _ = settings.theme.agent_buffer_font_size.insert(
1713                            f32::from(theme_settings::clamp_font_size(agent_buffer_font_size))
1714                                .into(),
1715                        );
1716                    });
1717                } else {
1718                    theme_settings::adjust_agent_ui_font_size(cx, |size| size + delta);
1719                    theme_settings::adjust_agent_buffer_font_size(cx, |size| size + delta);
1720                }
1721            }
1722            WhichFontSize::None => {}
1723        }
1724    }
1725
1726    pub fn reset_font_size(
1727        &mut self,
1728        action: &ResetBufferFontSize,
1729        _: &mut Window,
1730        cx: &mut Context<Self>,
1731    ) {
1732        if action.persist {
1733            update_settings_file(self.fs.clone(), cx, move |settings, _| {
1734                settings.theme.agent_ui_font_size = None;
1735                settings.theme.agent_buffer_font_size = None;
1736            });
1737        } else {
1738            theme_settings::reset_agent_ui_font_size(cx);
1739            theme_settings::reset_agent_buffer_font_size(cx);
1740        }
1741    }
1742
1743    pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1744        theme_settings::reset_agent_ui_font_size(cx);
1745        theme_settings::reset_agent_buffer_font_size(cx);
1746    }
1747
1748    pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
1749        if self.zoomed {
1750            cx.emit(PanelEvent::ZoomOut);
1751        } else {
1752            if !self.focus_handle(cx).contains_focused(window, cx) {
1753                cx.focus_self(window);
1754            }
1755            cx.emit(PanelEvent::ZoomIn);
1756        }
1757    }
1758
1759    pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1760        let agent_server_store = self.project.read(cx).agent_server_store().clone();
1761        let context_server_store = self.project.read(cx).context_server_store();
1762        let fs = self.fs.clone();
1763
1764        self.set_active_view(ActiveView::Configuration, true, window, cx);
1765        self.configuration = Some(cx.new(|cx| {
1766            AgentConfiguration::new(
1767                fs,
1768                agent_server_store,
1769                self.connection_store.clone(),
1770                context_server_store,
1771                self.context_server_registry.clone(),
1772                self.language_registry.clone(),
1773                self.workspace.clone(),
1774                window,
1775                cx,
1776            )
1777        }));
1778
1779        if let Some(configuration) = self.configuration.as_ref() {
1780            self.configuration_subscription = Some(cx.subscribe_in(
1781                configuration,
1782                window,
1783                Self::handle_agent_configuration_event,
1784            ));
1785
1786            configuration.focus_handle(cx).focus(window, cx);
1787        }
1788    }
1789
1790    pub(crate) fn open_active_thread_as_markdown(
1791        &mut self,
1792        _: &OpenActiveThreadAsMarkdown,
1793        window: &mut Window,
1794        cx: &mut Context<Self>,
1795    ) {
1796        if let Some(workspace) = self.workspace.upgrade()
1797            && let Some(conversation_view) = self.active_conversation_view()
1798            && let Some(active_thread) = conversation_view.read(cx).active_thread().cloned()
1799        {
1800            active_thread.update(cx, |thread, cx| {
1801                thread
1802                    .open_thread_as_markdown(workspace, window, cx)
1803                    .detach_and_log_err(cx);
1804            });
1805        }
1806    }
1807
1808    fn copy_thread_to_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1809        let Some(thread) = self.active_native_agent_thread(cx) else {
1810            Self::show_deferred_toast(&self.workspace, "No active native thread to copy", cx);
1811            return;
1812        };
1813
1814        let workspace = self.workspace.clone();
1815        let load_task = thread.read(cx).to_db(cx);
1816
1817        cx.spawn_in(window, async move |_this, cx| {
1818            let db_thread = load_task.await;
1819            let shared_thread = SharedThread::from_db_thread(&db_thread);
1820            let thread_data = shared_thread.to_bytes()?;
1821            let encoded = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &thread_data);
1822
1823            cx.update(|_window, cx| {
1824                cx.write_to_clipboard(ClipboardItem::new_string(encoded));
1825                if let Some(workspace) = workspace.upgrade() {
1826                    workspace.update(cx, |workspace, cx| {
1827                        struct ThreadCopiedToast;
1828                        workspace.show_toast(
1829                            workspace::Toast::new(
1830                                workspace::notifications::NotificationId::unique::<ThreadCopiedToast>(),
1831                                "Thread copied to clipboard (base64 encoded)",
1832                            )
1833                            .autohide(),
1834                            cx,
1835                        );
1836                    });
1837                }
1838            })?;
1839
1840            anyhow::Ok(())
1841        })
1842        .detach_and_log_err(cx);
1843    }
1844
1845    fn show_deferred_toast(
1846        workspace: &WeakEntity<workspace::Workspace>,
1847        message: &'static str,
1848        cx: &mut App,
1849    ) {
1850        let workspace = workspace.clone();
1851        cx.defer(move |cx| {
1852            if let Some(workspace) = workspace.upgrade() {
1853                workspace.update(cx, |workspace, cx| {
1854                    struct ClipboardToast;
1855                    workspace.show_toast(
1856                        workspace::Toast::new(
1857                            workspace::notifications::NotificationId::unique::<ClipboardToast>(),
1858                            message,
1859                        )
1860                        .autohide(),
1861                        cx,
1862                    );
1863                });
1864            }
1865        });
1866    }
1867
1868    fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1869        let Some(clipboard) = cx.read_from_clipboard() else {
1870            Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx);
1871            return;
1872        };
1873
1874        let Some(encoded) = clipboard.text() else {
1875            Self::show_deferred_toast(&self.workspace, "Clipboard does not contain text", cx);
1876            return;
1877        };
1878
1879        let thread_data = match base64::Engine::decode(&base64::prelude::BASE64_STANDARD, &encoded)
1880        {
1881            Ok(data) => data,
1882            Err(_) => {
1883                Self::show_deferred_toast(
1884                    &self.workspace,
1885                    "Failed to decode clipboard content (expected base64)",
1886                    cx,
1887                );
1888                return;
1889            }
1890        };
1891
1892        let shared_thread = match SharedThread::from_bytes(&thread_data) {
1893            Ok(thread) => thread,
1894            Err(_) => {
1895                Self::show_deferred_toast(
1896                    &self.workspace,
1897                    "Failed to parse thread data from clipboard",
1898                    cx,
1899                );
1900                return;
1901            }
1902        };
1903
1904        let db_thread = shared_thread.to_db_thread();
1905        let session_id = acp::SessionId::new(uuid::Uuid::new_v4().to_string());
1906        let thread_store = self.thread_store.clone();
1907        let title = db_thread.title.clone();
1908        let workspace = self.workspace.clone();
1909
1910        cx.spawn_in(window, async move |this, cx| {
1911            thread_store
1912                .update(&mut cx.clone(), |store, cx| {
1913                    store.save_thread(session_id.clone(), db_thread, Default::default(), cx)
1914                })
1915                .await?;
1916
1917            this.update_in(cx, |this, window, cx| {
1918                this.open_thread(session_id, None, Some(title), window, cx);
1919            })?;
1920
1921            this.update_in(cx, |_, _window, cx| {
1922                if let Some(workspace) = workspace.upgrade() {
1923                    workspace.update(cx, |workspace, cx| {
1924                        struct ThreadLoadedToast;
1925                        workspace.show_toast(
1926                            workspace::Toast::new(
1927                                workspace::notifications::NotificationId::unique::<ThreadLoadedToast>(),
1928                                "Thread loaded from clipboard",
1929                            )
1930                            .autohide(),
1931                            cx,
1932                        );
1933                    });
1934                }
1935            })?;
1936
1937            anyhow::Ok(())
1938        })
1939        .detach_and_log_err(cx);
1940    }
1941
1942    fn handle_agent_configuration_event(
1943        &mut self,
1944        _entity: &Entity<AgentConfiguration>,
1945        event: &AssistantConfigurationEvent,
1946        window: &mut Window,
1947        cx: &mut Context<Self>,
1948    ) {
1949        match event {
1950            AssistantConfigurationEvent::NewThread(provider) => {
1951                if LanguageModelRegistry::read_global(cx)
1952                    .default_model()
1953                    .is_none_or(|model| model.provider.id() != provider.id())
1954                    && let Some(model) = provider.default_model(cx)
1955                {
1956                    update_settings_file(self.fs.clone(), cx, move |settings, _| {
1957                        let provider = model.provider_id().0.to_string();
1958                        let enable_thinking = model.supports_thinking();
1959                        let effort = model
1960                            .default_effort_level()
1961                            .map(|effort| effort.value.to_string());
1962                        let model = model.id().0.to_string();
1963                        settings
1964                            .agent
1965                            .get_or_insert_default()
1966                            .set_model(LanguageModelSelection {
1967                                provider: LanguageModelProviderSetting(provider),
1968                                model,
1969                                enable_thinking,
1970                                effort,
1971                                speed: None,
1972                            })
1973                    });
1974                }
1975
1976                self.new_thread(&NewThread, window, cx);
1977                if let Some((thread, model)) = self
1978                    .active_native_agent_thread(cx)
1979                    .zip(provider.default_model(cx))
1980                {
1981                    thread.update(cx, |thread, cx| {
1982                        thread.set_model(model, cx);
1983                    });
1984                }
1985            }
1986        }
1987    }
1988
1989    pub fn workspace_id(&self) -> Option<WorkspaceId> {
1990        self.workspace_id
1991    }
1992
1993    pub fn background_threads(&self) -> &HashMap<acp::SessionId, Entity<ConversationView>> {
1994        &self.background_threads
1995    }
1996
1997    pub fn active_conversation_view(&self) -> Option<&Entity<ConversationView>> {
1998        match &self.active_view {
1999            ActiveView::AgentThread { conversation_view } => Some(conversation_view),
2000            _ => None,
2001        }
2002    }
2003
2004    pub fn conversation_views(&self) -> Vec<Entity<ConversationView>> {
2005        self.active_conversation_view()
2006            .into_iter()
2007            .cloned()
2008            .chain(self.background_threads.values().cloned())
2009            .collect()
2010    }
2011
2012    pub fn active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
2013        let server_view = self.active_conversation_view()?;
2014        server_view.read(cx).active_thread().cloned()
2015    }
2016
2017    pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
2018        match &self.active_view {
2019            ActiveView::AgentThread {
2020                conversation_view, ..
2021            } => conversation_view
2022                .read(cx)
2023                .active_thread()
2024                .map(|r| r.read(cx).thread.clone()),
2025            _ => None,
2026        }
2027    }
2028
2029    /// Returns the primary thread views for all retained connections: the
2030    pub fn is_background_thread(&self, session_id: &acp::SessionId) -> bool {
2031        self.background_threads.contains_key(session_id)
2032    }
2033
2034    pub fn cancel_thread(&self, session_id: &acp::SessionId, cx: &mut Context<Self>) -> bool {
2035        let conversation_views = self
2036            .active_conversation_view()
2037            .into_iter()
2038            .chain(self.background_threads.values());
2039
2040        for conversation_view in conversation_views {
2041            if let Some(thread_view) = conversation_view.read(cx).thread_view(session_id) {
2042                thread_view.update(cx, |view, cx| view.cancel_generation(cx));
2043                return true;
2044            }
2045        }
2046        false
2047    }
2048
2049    /// active thread plus any background threads that are still running or
2050    /// completed but unseen.
2051    pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
2052        let mut views = Vec::new();
2053
2054        if let Some(server_view) = self.active_conversation_view() {
2055            if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
2056                views.push(thread_view);
2057            }
2058        }
2059
2060        for server_view in self.background_threads.values() {
2061            if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
2062                views.push(thread_view);
2063            }
2064        }
2065
2066        views
2067    }
2068
2069    fn update_thread_work_dirs(&self, cx: &mut Context<Self>) {
2070        let new_work_dirs = self.project.read(cx).default_path_list(cx);
2071
2072        if let Some(conversation_view) = self.active_conversation_view() {
2073            conversation_view.update(cx, |conversation_view, cx| {
2074                conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
2075            });
2076        }
2077
2078        for conversation_view in self.background_threads.values() {
2079            conversation_view.update(cx, |conversation_view, cx| {
2080                conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
2081            });
2082        }
2083    }
2084
2085    fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context<Self>) {
2086        let ActiveView::AgentThread { conversation_view } = old_view else {
2087            return;
2088        };
2089
2090        // If this ConversationView is a tracked draft, it's already
2091        // stored in `draft_threads` — don't drop it.
2092        let is_tracked_draft = self
2093            .draft_threads
2094            .values()
2095            .any(|cv| cv.entity_id() == conversation_view.entity_id());
2096        if is_tracked_draft {
2097            return;
2098        }
2099
2100        let Some(thread_view) = conversation_view.read(cx).root_thread(cx) else {
2101            return;
2102        };
2103
2104        if thread_view.read(cx).thread.read(cx).entries().is_empty() {
2105            return;
2106        }
2107
2108        self.background_threads
2109            .insert(thread_view.read(cx).id.clone(), conversation_view);
2110        self.cleanup_background_threads(cx);
2111    }
2112
2113    /// We keep threads that are:
2114    /// - Still running
2115    /// - Do not support reloading the full session
2116    /// - Have had the most recent events (up to 5 idle threads)
2117    fn cleanup_background_threads(&mut self, cx: &App) {
2118        let mut potential_removals = self
2119            .background_threads
2120            .iter()
2121            .filter(|(_id, view)| {
2122                let Some(thread_view) = view.read(cx).root_thread(cx) else {
2123                    return true;
2124                };
2125                let thread = thread_view.read(cx).thread.read(cx);
2126                thread.connection().supports_load_session() && thread.status() == ThreadStatus::Idle
2127            })
2128            .collect::<Vec<_>>();
2129
2130        const MAX_IDLE_BACKGROUND_THREADS: usize = 5;
2131
2132        potential_removals.sort_unstable_by_key(|(_, view)| view.read(cx).updated_at(cx));
2133        let n = potential_removals
2134            .len()
2135            .saturating_sub(MAX_IDLE_BACKGROUND_THREADS);
2136        let to_remove = potential_removals
2137            .into_iter()
2138            .map(|(id, _)| id.clone())
2139            .take(n)
2140            .collect::<Vec<_>>();
2141        for id in to_remove {
2142            self.background_threads.remove(&id);
2143        }
2144    }
2145
2146    pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
2147        match &self.active_view {
2148            ActiveView::AgentThread {
2149                conversation_view, ..
2150            } => conversation_view.read(cx).as_native_thread(cx),
2151            _ => None,
2152        }
2153    }
2154
2155    fn set_active_view(
2156        &mut self,
2157        new_view: ActiveView,
2158        focus: bool,
2159        window: &mut Window,
2160        cx: &mut Context<Self>,
2161    ) {
2162        let was_in_agent_history = matches!(self.active_view, ActiveView::History { .. });
2163        let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized);
2164        let current_is_history = matches!(self.active_view, ActiveView::History { .. });
2165        let new_is_history = matches!(new_view, ActiveView::History { .. });
2166
2167        let current_is_config = matches!(self.active_view, ActiveView::Configuration);
2168        let new_is_config = matches!(new_view, ActiveView::Configuration);
2169
2170        let current_is_overlay = current_is_history || current_is_config;
2171        let new_is_overlay = new_is_history || new_is_config;
2172
2173        if current_is_uninitialized || (current_is_overlay && !new_is_overlay) {
2174            self.active_view = new_view;
2175        } else if !current_is_overlay && new_is_overlay {
2176            self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
2177        } else {
2178            let old_view = std::mem::replace(&mut self.active_view, new_view);
2179            if !new_is_overlay {
2180                if let Some(previous) = self.previous_view.take() {
2181                    self.retain_running_thread(previous, cx);
2182                }
2183            }
2184            self.retain_running_thread(old_view, cx);
2185        }
2186
2187        // Keep the toolbar's selected agent in sync with the active thread's agent.
2188        if let ActiveView::AgentThread { conversation_view } = &self.active_view {
2189            let thread_agent = conversation_view.read(cx).agent_key().clone();
2190            if self.selected_agent != thread_agent {
2191                self.selected_agent = thread_agent;
2192                self.serialize(cx);
2193            }
2194        }
2195
2196        // Subscribe to the active ThreadView's events (e.g. FirstSendRequested)
2197        // so the panel can intercept the first send for worktree creation.
2198        // Re-subscribe whenever the ConnectionView changes, since the inner
2199        // ThreadView may have been replaced (e.g. navigating between threads).
2200        self._active_view_observation = match &self.active_view {
2201            ActiveView::AgentThread { conversation_view } => {
2202                self._thread_view_subscription =
2203                    Self::subscribe_to_active_thread_view(conversation_view, window, cx);
2204                let focus_handle = conversation_view.focus_handle(cx);
2205                self._active_thread_focus_subscription =
2206                    Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| {
2207                        cx.emit(AgentPanelEvent::ThreadFocused);
2208                        cx.notify();
2209                    }));
2210                Some(cx.observe_in(
2211                    conversation_view,
2212                    window,
2213                    |this, server_view, window, cx| {
2214                        this._thread_view_subscription =
2215                            Self::subscribe_to_active_thread_view(&server_view, window, cx);
2216                        cx.emit(AgentPanelEvent::ActiveViewChanged);
2217                        this.serialize(cx);
2218                        cx.notify();
2219                    },
2220                ))
2221            }
2222            _ => {
2223                self._thread_view_subscription = None;
2224                self._active_thread_focus_subscription = None;
2225                None
2226            }
2227        };
2228
2229        if let ActiveView::History { view } = &self.active_view {
2230            if !was_in_agent_history {
2231                view.update(cx, |view, cx| {
2232                    view.history()
2233                        .update(cx, |history, cx| history.refresh_full_history(cx))
2234                });
2235            }
2236        }
2237
2238        if focus {
2239            self.focus_handle(cx).focus(window, cx);
2240        }
2241        cx.emit(AgentPanelEvent::ActiveViewChanged);
2242    }
2243
2244    fn populate_recently_updated_menu_section(
2245        mut menu: ContextMenu,
2246        panel: Entity<Self>,
2247        view: Entity<ThreadHistoryView>,
2248        cx: &mut Context<ContextMenu>,
2249    ) -> ContextMenu {
2250        let entries = view
2251            .read(cx)
2252            .history()
2253            .read(cx)
2254            .sessions()
2255            .iter()
2256            .take(RECENTLY_UPDATED_MENU_LIMIT)
2257            .cloned()
2258            .collect::<Vec<_>>();
2259
2260        if entries.is_empty() {
2261            return menu;
2262        }
2263
2264        menu = menu.header("Recently Updated");
2265
2266        for entry in entries {
2267            let title = entry
2268                .title
2269                .as_ref()
2270                .filter(|title| !title.is_empty())
2271                .cloned()
2272                .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
2273
2274            menu = menu.entry(title, None, {
2275                let panel = panel.downgrade();
2276                let entry = entry.clone();
2277                move |window, cx| {
2278                    let entry = entry.clone();
2279                    panel
2280                        .update(cx, move |this, cx| {
2281                            if let Some(agent) = this.selected_agent() {
2282                                this.load_agent_thread(
2283                                    agent,
2284                                    entry.session_id.clone(),
2285                                    entry.work_dirs.clone(),
2286                                    entry.title.clone(),
2287                                    true,
2288                                    window,
2289                                    cx,
2290                                );
2291                            }
2292                        })
2293                        .ok();
2294                }
2295            });
2296        }
2297
2298        menu.separator()
2299    }
2300
2301    fn subscribe_to_active_thread_view(
2302        server_view: &Entity<ConversationView>,
2303        window: &mut Window,
2304        cx: &mut Context<Self>,
2305    ) -> Option<Subscription> {
2306        server_view.read(cx).active_thread().cloned().map(|tv| {
2307            cx.subscribe_in(
2308                &tv,
2309                window,
2310                |this, view, event: &AcpThreadViewEvent, window, cx| match event {
2311                    AcpThreadViewEvent::FirstSendRequested { content } => {
2312                        this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
2313                    }
2314                    AcpThreadViewEvent::MessageSentOrQueued => {
2315                        // When a draft sends its first message it becomes a
2316                        // real thread. Remove it from `draft_threads` so the
2317                        // sidebar stops showing a stale draft entry.
2318                        if let Some(draft_id) = this.active_draft_id() {
2319                            this.draft_threads.remove(&draft_id);
2320                        }
2321                        let session_id = view.read(cx).thread.read(cx).session_id().clone();
2322                        cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id });
2323                    }
2324                },
2325            )
2326        })
2327    }
2328
2329    pub fn start_thread_in(&self) -> &StartThreadIn {
2330        &self.start_thread_in
2331    }
2332
2333    fn set_start_thread_in(
2334        &mut self,
2335        action: &StartThreadIn,
2336        window: &mut Window,
2337        cx: &mut Context<Self>,
2338    ) {
2339        let new_target = match action {
2340            StartThreadIn::LocalProject => StartThreadIn::LocalProject,
2341            StartThreadIn::NewWorktree { .. } => {
2342                if !self.project_has_git_repository(cx) {
2343                    log::error!(
2344                        "set_start_thread_in: cannot use worktree mode without a git repository"
2345                    );
2346                    return;
2347                }
2348                if self.project.read(cx).is_via_collab() {
2349                    log::error!(
2350                        "set_start_thread_in: cannot use worktree mode in a collab project"
2351                    );
2352                    return;
2353                }
2354                action.clone()
2355            }
2356            StartThreadIn::LinkedWorktree { .. } => {
2357                if !self.project_has_git_repository(cx) {
2358                    log::error!(
2359                        "set_start_thread_in: cannot use LinkedWorktree without a git repository"
2360                    );
2361                    return;
2362                }
2363                if self.project.read(cx).is_via_collab() {
2364                    log::error!(
2365                        "set_start_thread_in: cannot use LinkedWorktree in a collab project"
2366                    );
2367                    return;
2368                }
2369                action.clone()
2370            }
2371        };
2372        self.start_thread_in = new_target;
2373        if let Some(thread) = self.active_thread_view(cx) {
2374            thread.update(cx, |thread, cx| thread.focus_handle(cx).focus(window, cx));
2375        }
2376        self.serialize(cx);
2377        cx.notify();
2378    }
2379
2380    fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2381        let next = match &self.start_thread_in {
2382            StartThreadIn::LocalProject => StartThreadIn::NewWorktree {
2383                worktree_name: None,
2384                branch_target: NewWorktreeBranchTarget::default(),
2385            },
2386            StartThreadIn::NewWorktree { .. } | StartThreadIn::LinkedWorktree { .. } => {
2387                StartThreadIn::LocalProject
2388            }
2389        };
2390        self.set_start_thread_in(&next, window, cx);
2391    }
2392
2393    fn reset_start_thread_in_to_default(&mut self, cx: &mut Context<Self>) {
2394        use settings::{NewThreadLocation, Settings};
2395        let default = AgentSettings::get_global(cx).new_thread_location;
2396        let start_thread_in = match default {
2397            NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
2398            NewThreadLocation::NewWorktree => {
2399                if self.project_has_git_repository(cx) {
2400                    StartThreadIn::NewWorktree {
2401                        worktree_name: None,
2402                        branch_target: NewWorktreeBranchTarget::default(),
2403                    }
2404                } else {
2405                    StartThreadIn::LocalProject
2406                }
2407            }
2408        };
2409        if self.start_thread_in != start_thread_in {
2410            self.start_thread_in = start_thread_in;
2411            self.serialize(cx);
2412            cx.notify();
2413        }
2414    }
2415
2416    fn sync_start_thread_in_with_git_state(&mut self, cx: &mut Context<Self>) {
2417        if matches!(self.start_thread_in, StartThreadIn::LocalProject) {
2418            return;
2419        }
2420
2421        let visible_worktree_paths: Vec<_> = self
2422            .project
2423            .read(cx)
2424            .visible_worktrees(cx)
2425            .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
2426            .collect();
2427        let repositories = self.project.read(cx).repositories(cx);
2428        let linked_worktrees = if repositories.len() > 1 {
2429            Vec::new()
2430        } else {
2431            repositories
2432                .values()
2433                .flat_map(|repo| repo.read(cx).linked_worktrees().iter().cloned())
2434                .filter(|worktree| !visible_worktree_paths.contains(&worktree.path))
2435                .collect::<Vec<_>>()
2436        };
2437
2438        let updated_start_thread_in = match &self.start_thread_in {
2439            StartThreadIn::NewWorktree {
2440                worktree_name: Some(worktree_name),
2441                branch_target,
2442            } => {
2443                let normalized_worktree_name = worktree_name.replace(' ', "-");
2444                linked_worktrees
2445                    .iter()
2446                    .find(|worktree| {
2447                        worktree.display_name() == normalized_worktree_name
2448                            && self.linked_worktree_matches_branch_target(
2449                                worktree,
2450                                branch_target,
2451                                cx,
2452                            )
2453                    })
2454                    .map(|worktree| StartThreadIn::LinkedWorktree {
2455                        path: worktree.path.clone(),
2456                        display_name: worktree.display_name().to_string(),
2457                    })
2458            }
2459            StartThreadIn::LinkedWorktree { path, .. } => linked_worktrees
2460                .iter()
2461                .find(|worktree| worktree.path == *path)
2462                .map(|worktree| StartThreadIn::LinkedWorktree {
2463                    path: worktree.path.clone(),
2464                    display_name: worktree.display_name().to_string(),
2465                })
2466                .or(Some(StartThreadIn::LocalProject)),
2467            _ => None,
2468        };
2469
2470        if let Some(updated_start_thread_in) = updated_start_thread_in {
2471            if self.start_thread_in != updated_start_thread_in {
2472                self.start_thread_in = updated_start_thread_in;
2473                self.serialize(cx);
2474            }
2475            cx.notify();
2476        }
2477    }
2478
2479    fn linked_worktree_matches_branch_target(
2480        &self,
2481        worktree: &git::repository::Worktree,
2482        branch_target: &NewWorktreeBranchTarget,
2483        cx: &App,
2484    ) -> bool {
2485        let active_repository = self.project.read(cx).active_repository(cx);
2486        let current_branch_name = active_repository.as_ref().and_then(|repo| {
2487            repo.read(cx)
2488                .branch
2489                .as_ref()
2490                .map(|branch| branch.name().to_string())
2491        });
2492        let existing_branch_names = active_repository
2493            .as_ref()
2494            .map(|repo| {
2495                repo.read(cx)
2496                    .branch_list
2497                    .iter()
2498                    .map(|branch| branch.name().to_string())
2499                    .collect::<HashSet<_>>()
2500            })
2501            .unwrap_or_default();
2502
2503        match branch_target {
2504            NewWorktreeBranchTarget::CurrentBranch => {
2505                current_branch_name.as_deref() == worktree.branch_name()
2506            }
2507            NewWorktreeBranchTarget::ExistingBranch { name } => {
2508                existing_branch_names.contains(name)
2509                    && worktree.branch_name() == Some(name.as_str())
2510            }
2511            NewWorktreeBranchTarget::CreateBranch { name, .. } => {
2512                !existing_branch_names.contains(name)
2513                    && worktree.branch_name() == Some(name.as_str())
2514            }
2515        }
2516    }
2517
2518    pub(crate) fn selected_agent(&self) -> Option<Agent> {
2519        Some(self.selected_agent.clone())
2520    }
2521
2522    fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
2523        if let Some(extension_store) = ExtensionStore::try_global(cx) {
2524            let (manifests, extensions_dir) = {
2525                let store = extension_store.read(cx);
2526                let installed = store.installed_extensions();
2527                let manifests: Vec<_> = installed
2528                    .iter()
2529                    .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
2530                    .collect();
2531                let extensions_dir = paths::extensions_dir().join("installed");
2532                (manifests, extensions_dir)
2533            };
2534
2535            self.project.update(cx, |project, cx| {
2536                project.agent_server_store().update(cx, |store, cx| {
2537                    let manifest_refs: Vec<_> = manifests
2538                        .iter()
2539                        .map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
2540                        .collect();
2541                    store.sync_extension_agents(manifest_refs, extensions_dir, cx);
2542                });
2543            });
2544        }
2545    }
2546
2547    pub fn new_agent_thread_with_external_source_prompt(
2548        &mut self,
2549        external_source_prompt: Option<ExternalSourcePrompt>,
2550        window: &mut Window,
2551        cx: &mut Context<Self>,
2552    ) {
2553        self.external_thread(
2554            None,
2555            None,
2556            None,
2557            None,
2558            external_source_prompt.map(AgentInitialContent::from),
2559            true,
2560            window,
2561            cx,
2562        );
2563    }
2564
2565    pub fn new_agent_thread(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
2566        self.reset_start_thread_in_to_default(cx);
2567        self.new_agent_thread_inner(agent, true, window, cx);
2568    }
2569
2570    fn new_agent_thread_inner(
2571        &mut self,
2572        agent: Agent,
2573        focus: bool,
2574        window: &mut Window,
2575        cx: &mut Context<Self>,
2576    ) {
2577        let initial_content = self.take_active_draft_initial_content(cx);
2578        self.external_thread(
2579            Some(agent),
2580            None,
2581            None,
2582            None,
2583            initial_content,
2584            focus,
2585            window,
2586            cx,
2587        );
2588    }
2589
2590    pub fn load_agent_thread(
2591        &mut self,
2592        agent: Agent,
2593        session_id: acp::SessionId,
2594        work_dirs: Option<PathList>,
2595        title: Option<SharedString>,
2596        focus: bool,
2597        window: &mut Window,
2598        cx: &mut Context<Self>,
2599    ) {
2600        if self.selected_agent != agent {
2601            self.selected_agent = agent.clone();
2602            self.serialize(cx);
2603        }
2604
2605        if let Some(store) = ThreadMetadataStore::try_global(cx) {
2606            store.update(cx, |store, cx| store.unarchive(&session_id, cx));
2607        }
2608
2609        if let Some(conversation_view) = self.background_threads.remove(&session_id) {
2610            self.set_active_view(
2611                ActiveView::AgentThread { conversation_view },
2612                focus,
2613                window,
2614                cx,
2615            );
2616            return;
2617        }
2618
2619        if let ActiveView::AgentThread { conversation_view } = &self.active_view {
2620            if conversation_view
2621                .read(cx)
2622                .active_thread()
2623                .map(|t| t.read(cx).id.clone())
2624                == Some(session_id.clone())
2625            {
2626                cx.emit(AgentPanelEvent::ActiveViewChanged);
2627                return;
2628            }
2629        }
2630
2631        if let Some(ActiveView::AgentThread { conversation_view }) = &self.previous_view {
2632            if conversation_view
2633                .read(cx)
2634                .active_thread()
2635                .map(|t| t.read(cx).id.clone())
2636                == Some(session_id.clone())
2637            {
2638                let view = self.previous_view.take().unwrap();
2639                self.set_active_view(view, focus, window, cx);
2640                return;
2641            }
2642        }
2643
2644        self.external_thread(
2645            Some(agent),
2646            Some(session_id),
2647            work_dirs,
2648            title,
2649            None,
2650            focus,
2651            window,
2652            cx,
2653        );
2654    }
2655
2656    pub(crate) fn create_agent_thread(
2657        &mut self,
2658        server: Rc<dyn AgentServer>,
2659        resume_session_id: Option<acp::SessionId>,
2660        work_dirs: Option<PathList>,
2661        title: Option<SharedString>,
2662        initial_content: Option<AgentInitialContent>,
2663        workspace: WeakEntity<Workspace>,
2664        project: Entity<Project>,
2665        agent: Agent,
2666        window: &mut Window,
2667        cx: &mut Context<Self>,
2668    ) -> Entity<ConversationView> {
2669        if self.selected_agent != agent {
2670            self.selected_agent = agent.clone();
2671            self.serialize(cx);
2672        }
2673
2674        cx.background_spawn({
2675            let kvp = KeyValueStore::global(cx);
2676            let agent = agent.clone();
2677            async move {
2678                write_global_last_used_agent(kvp, agent).await;
2679            }
2680        })
2681        .detach();
2682
2683        let thread_store = server
2684            .clone()
2685            .downcast::<agent::NativeAgentServer>()
2686            .is_some()
2687            .then(|| self.thread_store.clone());
2688
2689        let connection_store = self.connection_store.clone();
2690
2691        let conversation_view = cx.new(|cx| {
2692            crate::ConversationView::new(
2693                server,
2694                connection_store,
2695                agent,
2696                resume_session_id,
2697                work_dirs,
2698                title,
2699                initial_content,
2700                workspace.clone(),
2701                project,
2702                thread_store,
2703                self.prompt_store.clone(),
2704                window,
2705                cx,
2706            )
2707        });
2708
2709        cx.observe(&conversation_view, |this, server_view, cx| {
2710            let is_active = this
2711                .active_conversation_view()
2712                .is_some_and(|active| active.entity_id() == server_view.entity_id());
2713            if is_active {
2714                cx.emit(AgentPanelEvent::ActiveViewChanged);
2715                this.serialize(cx);
2716            } else {
2717                cx.emit(AgentPanelEvent::BackgroundThreadChanged);
2718            }
2719            cx.notify();
2720        })
2721        .detach();
2722
2723        conversation_view
2724    }
2725
2726    fn active_thread_has_messages(&self, cx: &App) -> bool {
2727        self.active_agent_thread(cx)
2728            .is_some_and(|thread| !thread.read(cx).entries().is_empty())
2729    }
2730
2731    pub fn active_thread_is_draft(&self, cx: &App) -> bool {
2732        self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
2733    }
2734
2735    fn handle_first_send_requested(
2736        &mut self,
2737        thread_view: Entity<ThreadView>,
2738        content: Vec<acp::ContentBlock>,
2739        window: &mut Window,
2740        cx: &mut Context<Self>,
2741    ) {
2742        match &self.start_thread_in {
2743            StartThreadIn::NewWorktree {
2744                worktree_name,
2745                branch_target,
2746            } => {
2747                self.handle_worktree_requested(
2748                    content,
2749                    WorktreeCreationArgs::New {
2750                        worktree_name: worktree_name.clone(),
2751                        branch_target: branch_target.clone(),
2752                    },
2753                    window,
2754                    cx,
2755                );
2756            }
2757            StartThreadIn::LinkedWorktree { path, .. } => {
2758                self.handle_worktree_requested(
2759                    content,
2760                    WorktreeCreationArgs::Linked {
2761                        worktree_path: path.clone(),
2762                    },
2763                    window,
2764                    cx,
2765                );
2766            }
2767            StartThreadIn::LocalProject => {
2768                cx.defer_in(window, move |_this, window, cx| {
2769                    thread_view.update(cx, |thread_view, cx| {
2770                        let editor = thread_view.message_editor.clone();
2771                        thread_view.send_impl(editor, window, cx);
2772                    });
2773                });
2774            }
2775        }
2776    }
2777
2778    // TODO: The mapping from workspace root paths to git repositories needs a
2779    // unified approach across the codebase: this method, `sidebar::is_root_repo`,
2780    // thread persistence (which PathList is saved to the database), and thread
2781    // querying (which PathList is used to read threads back). All of these need
2782    // to agree on how repos are resolved for a given workspace, especially in
2783    // multi-root and nested-repo configurations.
2784    /// Partitions the project's visible worktrees into git-backed repositories
2785    /// and plain (non-git) paths. Git repos will have worktrees created for
2786    /// them; non-git paths are carried over to the new workspace as-is.
2787    ///
2788    /// When multiple worktrees map to the same repository, the most specific
2789    /// match wins (deepest work directory path), with a deterministic
2790    /// tie-break on entity id. Each repository appears at most once.
2791    fn classify_worktrees(
2792        &self,
2793        cx: &App,
2794    ) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
2795        let project = &self.project;
2796        let repositories = project.read(cx).repositories(cx).clone();
2797        let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
2798        let mut non_git_paths: Vec<PathBuf> = Vec::new();
2799        let mut seen_repo_ids = std::collections::HashSet::new();
2800
2801        for worktree in project.read(cx).visible_worktrees(cx) {
2802            let wt_path = worktree.read(cx).abs_path();
2803
2804            let matching_repo = repositories
2805                .iter()
2806                .filter_map(|(id, repo)| {
2807                    let work_dir = repo.read(cx).work_directory_abs_path.clone();
2808                    if wt_path.starts_with(work_dir.as_ref()) {
2809                        Some((*id, repo.clone(), work_dir.as_ref().components().count()))
2810                    } else {
2811                        None
2812                    }
2813                })
2814                .max_by(
2815                    |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
2816                        left_depth
2817                            .cmp(right_depth)
2818                            .then_with(|| left_id.cmp(right_id))
2819                    },
2820                );
2821
2822            if let Some((id, repo, _)) = matching_repo {
2823                if seen_repo_ids.insert(id) {
2824                    git_repos.push(repo);
2825                }
2826            } else {
2827                non_git_paths.push(wt_path.to_path_buf());
2828            }
2829        }
2830
2831        (git_repos, non_git_paths)
2832    }
2833
2834    fn resolve_worktree_branch_target(
2835        branch_target: &NewWorktreeBranchTarget,
2836        existing_branches: &HashSet<String>,
2837        occupied_branches: &HashSet<String>,
2838    ) -> Result<(String, bool, Option<String>)> {
2839        let generate_branch_name = || -> Result<String> {
2840            let refs: Vec<&str> = existing_branches.iter().map(|s| s.as_str()).collect();
2841            let mut rng = rand::rng();
2842            crate::branch_names::generate_branch_name(&refs, &mut rng)
2843                .ok_or_else(|| anyhow!("Failed to generate a unique branch name"))
2844        };
2845
2846        match branch_target {
2847            NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
2848                Ok((name.clone(), false, from_ref.clone()))
2849            }
2850            NewWorktreeBranchTarget::ExistingBranch { name } => {
2851                if occupied_branches.contains(name) {
2852                    Ok((generate_branch_name()?, false, Some(name.clone())))
2853                } else {
2854                    Ok((name.clone(), true, None))
2855                }
2856            }
2857            NewWorktreeBranchTarget::CurrentBranch => Ok((generate_branch_name()?, false, None)),
2858        }
2859    }
2860
2861    /// Kicks off an async git-worktree creation for each repository. Returns:
2862    ///
2863    /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
2864    ///   receiver resolves once the git worktree command finishes.
2865    /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
2866    ///   later to remap open editor tabs into the new workspace.
2867    fn start_worktree_creations(
2868        git_repos: &[Entity<project::git_store::Repository>],
2869        worktree_name: Option<String>,
2870        branch_name: &str,
2871        use_existing_branch: bool,
2872        start_point: Option<String>,
2873        worktree_directory_setting: &str,
2874        cx: &mut Context<Self>,
2875    ) -> Result<(
2876        Vec<(
2877            Entity<project::git_store::Repository>,
2878            PathBuf,
2879            futures::channel::oneshot::Receiver<Result<()>>,
2880        )>,
2881        Vec<(PathBuf, PathBuf)>,
2882    )> {
2883        let mut creation_infos = Vec::new();
2884        let mut path_remapping = Vec::new();
2885
2886        let worktree_name = worktree_name.unwrap_or_else(|| branch_name.to_string());
2887
2888        for repo in git_repos {
2889            let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
2890                let new_path =
2891                    repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
2892                let target = if use_existing_branch {
2893                    debug_assert!(
2894                        git_repos.len() == 1,
2895                        "use_existing_branch should only be true for a single repo"
2896                    );
2897                    git::repository::CreateWorktreeTarget::ExistingBranch {
2898                        branch_name: branch_name.to_string(),
2899                    }
2900                } else {
2901                    git::repository::CreateWorktreeTarget::NewBranch {
2902                        branch_name: branch_name.to_string(),
2903                        base_sha: start_point.clone(),
2904                    }
2905                };
2906                let receiver = repo.create_worktree(target, new_path.clone());
2907                let work_dir = repo.work_directory_abs_path.clone();
2908                anyhow::Ok((work_dir, new_path, receiver))
2909            })?;
2910            path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
2911            creation_infos.push((repo.clone(), new_path, receiver));
2912        }
2913
2914        Ok((creation_infos, path_remapping))
2915    }
2916
2917    /// Waits for every in-flight worktree creation to complete. If any
2918    /// creation fails, all successfully-created worktrees are rolled back
2919    /// (removed) so the project isn't left in a half-migrated state.
2920    async fn await_and_rollback_on_failure(
2921        creation_infos: Vec<(
2922            Entity<project::git_store::Repository>,
2923            PathBuf,
2924            futures::channel::oneshot::Receiver<Result<()>>,
2925        )>,
2926        fs: Arc<dyn Fs>,
2927        cx: &mut AsyncWindowContext,
2928    ) -> Result<Vec<PathBuf>> {
2929        let mut created_paths: Vec<PathBuf> = Vec::new();
2930        let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
2931            Vec::new();
2932        let mut first_error: Option<anyhow::Error> = None;
2933
2934        for (repo, new_path, receiver) in creation_infos {
2935            repos_and_paths.push((repo.clone(), new_path.clone()));
2936            match receiver.await {
2937                Ok(Ok(())) => {
2938                    created_paths.push(new_path);
2939                }
2940                Ok(Err(err)) => {
2941                    if first_error.is_none() {
2942                        first_error = Some(err);
2943                    }
2944                }
2945                Err(_canceled) => {
2946                    if first_error.is_none() {
2947                        first_error = Some(anyhow!("Worktree creation was canceled"));
2948                    }
2949                }
2950            }
2951        }
2952
2953        let Some(err) = first_error else {
2954            return Ok(created_paths);
2955        };
2956
2957        // Rollback all attempted worktrees (both successful and failed)
2958        let mut rollback_futures = Vec::new();
2959        for (rollback_repo, rollback_path) in &repos_and_paths {
2960            let receiver = cx
2961                .update(|_, cx| {
2962                    rollback_repo.update(cx, |repo, _cx| {
2963                        repo.remove_worktree(rollback_path.clone(), true)
2964                    })
2965                })
2966                .ok();
2967
2968            rollback_futures.push((rollback_path.clone(), receiver));
2969        }
2970
2971        let mut rollback_failures: Vec<String> = Vec::new();
2972        for (path, receiver_opt) in rollback_futures {
2973            let mut git_remove_failed = false;
2974
2975            if let Some(receiver) = receiver_opt {
2976                match receiver.await {
2977                    Ok(Ok(())) => {}
2978                    Ok(Err(rollback_err)) => {
2979                        log::error!(
2980                            "git worktree remove failed for {}: {rollback_err}",
2981                            path.display()
2982                        );
2983                        git_remove_failed = true;
2984                    }
2985                    Err(canceled) => {
2986                        log::error!(
2987                            "git worktree remove failed for {}: {canceled}",
2988                            path.display()
2989                        );
2990                        git_remove_failed = true;
2991                    }
2992                }
2993            } else {
2994                log::error!(
2995                    "failed to dispatch git worktree remove for {}",
2996                    path.display()
2997                );
2998                git_remove_failed = true;
2999            }
3000
3001            // `git worktree remove` normally removes this directory, but since
3002            // `git worktree remove` failed (or wasn't dispatched), manually rm the directory.
3003            if git_remove_failed {
3004                if let Err(fs_err) = fs
3005                    .remove_dir(
3006                        &path,
3007                        fs::RemoveOptions {
3008                            recursive: true,
3009                            ignore_if_not_exists: true,
3010                        },
3011                    )
3012                    .await
3013                {
3014                    let msg = format!("{}: failed to remove directory: {fs_err}", path.display());
3015                    log::error!("{}", msg);
3016                    rollback_failures.push(msg);
3017                }
3018            }
3019        }
3020        let mut error_message = format!("Failed to create worktree: {err}");
3021        if !rollback_failures.is_empty() {
3022            error_message.push_str("\n\nFailed to clean up: ");
3023            error_message.push_str(&rollback_failures.join(", "));
3024        }
3025        Err(anyhow!(error_message))
3026    }
3027
3028    fn set_worktree_creation_error(
3029        &mut self,
3030        message: SharedString,
3031        window: &mut Window,
3032        cx: &mut Context<Self>,
3033    ) {
3034        if let Some((_, status)) = &mut self.worktree_creation_status {
3035            *status = WorktreeCreationStatus::Error(message);
3036        }
3037        if matches!(self.active_view, ActiveView::Uninitialized) {
3038            let selected_agent = self.selected_agent.clone();
3039            self.new_agent_thread(selected_agent, window, cx);
3040        }
3041        cx.notify();
3042    }
3043
3044    fn handle_worktree_requested(
3045        &mut self,
3046        content: Vec<acp::ContentBlock>,
3047        args: WorktreeCreationArgs,
3048        window: &mut Window,
3049        cx: &mut Context<Self>,
3050    ) {
3051        if matches!(
3052            self.worktree_creation_status,
3053            Some((_, WorktreeCreationStatus::Creating))
3054        ) {
3055            return;
3056        }
3057
3058        let conversation_view_id = self
3059            .active_conversation_view()
3060            .map(|v| v.entity_id())
3061            .unwrap_or_else(|| EntityId::from(0u64));
3062        self.worktree_creation_status =
3063            Some((conversation_view_id, WorktreeCreationStatus::Creating));
3064        cx.notify();
3065
3066        let (git_repos, non_git_paths) = self.classify_worktrees(cx);
3067
3068        if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() {
3069            self.set_worktree_creation_error(
3070                "No git repositories found in the project".into(),
3071                window,
3072                cx,
3073            );
3074            return;
3075        }
3076
3077        let (branch_receivers, worktree_receivers, worktree_directory_setting) =
3078            if matches!(args, WorktreeCreationArgs::New { .. }) {
3079                (
3080                    Some(
3081                        git_repos
3082                            .iter()
3083                            .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
3084                            .collect::<Vec<_>>(),
3085                    ),
3086                    Some(
3087                        git_repos
3088                            .iter()
3089                            .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
3090                            .collect::<Vec<_>>(),
3091                    ),
3092                    Some(
3093                        ProjectSettings::get_global(cx)
3094                            .git
3095                            .worktree_directory
3096                            .clone(),
3097                    ),
3098                )
3099            } else {
3100                (None, None, None)
3101            };
3102
3103        let active_file_path = self.workspace.upgrade().and_then(|workspace| {
3104            let workspace = workspace.read(cx);
3105            let active_item = workspace.active_item(cx)?;
3106            let project_path = active_item.project_path(cx)?;
3107            workspace
3108                .project()
3109                .read(cx)
3110                .absolute_path(&project_path, cx)
3111        });
3112
3113        let remote_connection_options = self.project.read(cx).remote_connection_options(cx);
3114
3115        if remote_connection_options.is_some() {
3116            let is_disconnected = self
3117                .project
3118                .read(cx)
3119                .remote_client()
3120                .is_some_and(|client| client.read(cx).is_disconnected());
3121            if is_disconnected {
3122                self.set_worktree_creation_error(
3123                    "Cannot create worktree: remote connection is not active".into(),
3124                    window,
3125                    cx,
3126                );
3127                return;
3128            }
3129        }
3130
3131        let workspace = self.workspace.clone();
3132        let window_handle = window
3133            .window_handle()
3134            .downcast::<workspace::MultiWorkspace>();
3135
3136        let selected_agent = self.selected_agent();
3137
3138        let task = cx.spawn_in(window, async move |this, cx| {
3139            let (all_paths, path_remapping, has_non_git) = match args {
3140                WorktreeCreationArgs::New {
3141                    worktree_name,
3142                    branch_target,
3143                } => {
3144                    let branch_receivers = branch_receivers
3145                        .expect("branch receivers must be prepared for new worktree creation");
3146                    let worktree_receivers = worktree_receivers
3147                        .expect("worktree receivers must be prepared for new worktree creation");
3148                    let worktree_directory_setting = worktree_directory_setting
3149                        .expect("worktree directory must be prepared for new worktree creation");
3150
3151                    let mut existing_branches = HashSet::default();
3152                    for result in futures::future::join_all(branch_receivers).await {
3153                        match result {
3154                            Ok(Ok(branches)) => {
3155                                for branch in branches {
3156                                    existing_branches.insert(branch.name().to_string());
3157                                }
3158                            }
3159                            Ok(Err(err)) => {
3160                                Err::<(), _>(err).log_err();
3161                            }
3162                            Err(_) => {}
3163                        }
3164                    }
3165
3166                    let mut occupied_branches = HashSet::default();
3167                    for result in futures::future::join_all(worktree_receivers).await {
3168                        match result {
3169                            Ok(Ok(worktrees)) => {
3170                                for worktree in worktrees {
3171                                    if let Some(branch_name) = worktree.branch_name() {
3172                                        occupied_branches.insert(branch_name.to_string());
3173                                    }
3174                                }
3175                            }
3176                            Ok(Err(err)) => {
3177                                Err::<(), _>(err).log_err();
3178                            }
3179                            Err(_) => {}
3180                        }
3181                    }
3182
3183                    let (branch_name, use_existing_branch, start_point) =
3184                        match Self::resolve_worktree_branch_target(
3185                            &branch_target,
3186                            &existing_branches,
3187                            &occupied_branches,
3188                        ) {
3189                            Ok(target) => target,
3190                            Err(err) => {
3191                                this.update_in(cx, |this, window, cx| {
3192                                    this.set_worktree_creation_error(
3193                                        err.to_string().into(),
3194                                        window,
3195                                        cx,
3196                                    );
3197                                })?;
3198                                return anyhow::Ok(());
3199                            }
3200                        };
3201
3202                    let (creation_infos, path_remapping) =
3203                        match this.update_in(cx, |_this, _window, cx| {
3204                            Self::start_worktree_creations(
3205                                &git_repos,
3206                                worktree_name,
3207                                &branch_name,
3208                                use_existing_branch,
3209                                start_point,
3210                                &worktree_directory_setting,
3211                                cx,
3212                            )
3213                        }) {
3214                            Ok(Ok(result)) => result,
3215                            Ok(Err(err)) | Err(err) => {
3216                                this.update_in(cx, |this, window, cx| {
3217                                    this.set_worktree_creation_error(
3218                                        format!("Failed to validate worktree directory: {err}")
3219                                            .into(),
3220                                        window,
3221                                        cx,
3222                                    );
3223                                })
3224                                .log_err();
3225                                return anyhow::Ok(());
3226                            }
3227                        };
3228
3229                    let fs = cx.update(|_, cx| <dyn Fs>::global(cx))?;
3230
3231                    let created_paths =
3232                        match Self::await_and_rollback_on_failure(creation_infos, fs, cx).await {
3233                            Ok(paths) => paths,
3234                            Err(err) => {
3235                                this.update_in(cx, |this, window, cx| {
3236                                    this.set_worktree_creation_error(
3237                                        format!("{err}").into(),
3238                                        window,
3239                                        cx,
3240                                    );
3241                                })?;
3242                                return anyhow::Ok(());
3243                            }
3244                        };
3245
3246                    let mut all_paths = created_paths;
3247                    let has_non_git = !non_git_paths.is_empty();
3248                    all_paths.extend(non_git_paths.iter().cloned());
3249                    (all_paths, path_remapping, has_non_git)
3250                }
3251                WorktreeCreationArgs::Linked { worktree_path } => {
3252                    let mut all_paths = vec![worktree_path];
3253                    let has_non_git = !non_git_paths.is_empty();
3254                    all_paths.extend(non_git_paths.iter().cloned());
3255                    (all_paths, Vec::new(), has_non_git)
3256                }
3257            };
3258
3259            if workspace.upgrade().is_none() {
3260                this.update_in(cx, |this, window, cx| {
3261                    this.set_worktree_creation_error(
3262                        "Workspace no longer available".into(),
3263                        window,
3264                        cx,
3265                    );
3266                })?;
3267                return anyhow::Ok(());
3268            }
3269
3270            let this_for_error = this.clone();
3271            if let Err(err) = Self::open_worktree_workspace_and_start_thread(
3272                this,
3273                all_paths,
3274                window_handle,
3275                active_file_path,
3276                path_remapping,
3277                non_git_paths,
3278                has_non_git,
3279                content,
3280                selected_agent,
3281                remote_connection_options,
3282                cx,
3283            )
3284            .await
3285            {
3286                this_for_error
3287                    .update_in(cx, |this, window, cx| {
3288                        this.set_worktree_creation_error(
3289                            format!("Failed to set up workspace: {err}").into(),
3290                            window,
3291                            cx,
3292                        );
3293                    })
3294                    .log_err();
3295            }
3296            anyhow::Ok(())
3297        });
3298
3299        self._worktree_creation_task = Some(cx.background_spawn(async move {
3300            task.await.log_err();
3301        }));
3302    }
3303
3304    async fn open_worktree_workspace_and_start_thread(
3305        this: WeakEntity<Self>,
3306        all_paths: Vec<PathBuf>,
3307        window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
3308        active_file_path: Option<PathBuf>,
3309        path_remapping: Vec<(PathBuf, PathBuf)>,
3310        non_git_paths: Vec<PathBuf>,
3311        has_non_git: bool,
3312        content: Vec<acp::ContentBlock>,
3313        selected_agent: Option<Agent>,
3314        remote_connection_options: Option<RemoteConnectionOptions>,
3315        cx: &mut AsyncWindowContext,
3316    ) -> Result<()> {
3317        let window_handle = window_handle
3318            .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?;
3319
3320        let (workspace_task, modal_workspace) =
3321            window_handle.update(cx, |multi_workspace, window, cx| {
3322                let path_list = PathList::new(&all_paths);
3323                let active_workspace = multi_workspace.workspace().clone();
3324                let modal_workspace = active_workspace.clone();
3325
3326                let task = multi_workspace.find_or_create_workspace(
3327                    path_list,
3328                    remote_connection_options,
3329                    None,
3330                    move |connection_options, window, cx| {
3331                        remote_connection::connect_with_modal(
3332                            &active_workspace,
3333                            connection_options,
3334                            window,
3335                            cx,
3336                        )
3337                    },
3338                    window,
3339                    cx,
3340                );
3341                (task, modal_workspace)
3342            })?;
3343
3344        let result = workspace_task.await;
3345        remote_connection::dismiss_connection_modal(&modal_workspace, cx);
3346        let new_workspace = result?;
3347
3348        let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
3349
3350        if let Some(task) = panels_task {
3351            task.await.log_err();
3352        }
3353
3354        new_workspace
3355            .update(cx, |workspace, cx| {
3356                workspace.project().read(cx).wait_for_initial_scan(cx)
3357            })
3358            .await;
3359
3360        new_workspace
3361            .update(cx, |workspace, cx| {
3362                let repos = workspace
3363                    .project()
3364                    .read(cx)
3365                    .repositories(cx)
3366                    .values()
3367                    .cloned()
3368                    .collect::<Vec<_>>();
3369
3370                let tasks = repos
3371                    .into_iter()
3372                    .map(|repo| repo.update(cx, |repo, _| repo.barrier()));
3373                futures::future::join_all(tasks)
3374            })
3375            .await;
3376
3377        let initial_content = AgentInitialContent::ContentBlock {
3378            blocks: content,
3379            auto_submit: true,
3380        };
3381
3382        window_handle.update(cx, |_multi_workspace, window, cx| {
3383            new_workspace.update(cx, |workspace, cx| {
3384                if has_non_git {
3385                    let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
3386                    workspace.show_toast(
3387                        workspace::Toast::new(
3388                            toast_id,
3389                            "Some project folders are not git repositories. \
3390                             They were included as-is without creating a worktree.",
3391                        ),
3392                        cx,
3393                    );
3394                }
3395
3396                // If we had an active buffer, remap its path and reopen it.
3397                let had_active_file = active_file_path.is_some();
3398                let remapped_active_path = active_file_path.and_then(|original_path| {
3399                    let best_match = path_remapping
3400                        .iter()
3401                        .filter_map(|(old_root, new_root)| {
3402                            original_path.strip_prefix(old_root).ok().map(|relative| {
3403                                (old_root.components().count(), new_root.join(relative))
3404                            })
3405                        })
3406                        .max_by_key(|(depth, _)| *depth);
3407
3408                    if let Some((_, remapped_path)) = best_match {
3409                        return Some(remapped_path);
3410                    }
3411
3412                    for non_git in &non_git_paths {
3413                        if original_path.starts_with(non_git) {
3414                            return Some(original_path);
3415                        }
3416                    }
3417                    None
3418                });
3419
3420                if had_active_file && remapped_active_path.is_none() {
3421                    log::warn!(
3422                        "Active file could not be remapped to the new worktree; it will not be reopened"
3423                    );
3424                }
3425
3426                if let Some(path) = remapped_active_path {
3427                    let open_task = workspace.open_paths(
3428                        vec![path],
3429                        workspace::OpenOptions::default(),
3430                        None,
3431                        window,
3432                        cx,
3433                    );
3434                    cx.spawn(async move |_, _| -> anyhow::Result<()> {
3435                        for item in open_task.await.into_iter().flatten() {
3436                            item?;
3437                        }
3438                        Ok(())
3439                    })
3440                    .detach_and_log_err(cx);
3441                }
3442
3443                workspace.focus_panel::<AgentPanel>(window, cx);
3444
3445                // If no active buffer was open, zoom the agent panel
3446                // (equivalent to cmd-esc fullscreen behavior).
3447                // This must happen after focus_panel, which activates
3448                // and opens the panel in the dock.
3449
3450                if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3451                    panel.update(cx, |panel, cx| {
3452                        panel.external_thread(
3453                            selected_agent,
3454                            None,
3455                            None,
3456                            None,
3457                            Some(initial_content),
3458                            true,
3459                            window,
3460                            cx,
3461                        );
3462                    });
3463                }
3464            });
3465        })?;
3466
3467        window_handle.update(cx, |multi_workspace, window, cx| {
3468            multi_workspace.activate(new_workspace.clone(), window, cx);
3469
3470            new_workspace.update(cx, |workspace, cx| {
3471                workspace.run_create_worktree_tasks(window, cx);
3472            })
3473        })?;
3474
3475        this.update_in(cx, |this, window, cx| {
3476            this.worktree_creation_status = None;
3477
3478            if let Some(thread_view) = this.active_thread_view(cx) {
3479                thread_view.update(cx, |thread_view, cx| {
3480                    thread_view
3481                        .message_editor
3482                        .update(cx, |editor, cx| editor.clear(window, cx));
3483                });
3484            }
3485
3486            this.start_thread_in = StartThreadIn::LocalProject;
3487            this.serialize(cx);
3488            cx.notify();
3489        })?;
3490
3491        anyhow::Ok(())
3492    }
3493}
3494
3495impl Focusable for AgentPanel {
3496    fn focus_handle(&self, cx: &App) -> FocusHandle {
3497        match &self.active_view {
3498            ActiveView::Uninitialized => self.focus_handle.clone(),
3499            ActiveView::AgentThread {
3500                conversation_view, ..
3501            } => conversation_view.focus_handle(cx),
3502            ActiveView::History { view } => view.read(cx).focus_handle(cx),
3503            ActiveView::Configuration => {
3504                if let Some(configuration) = self.configuration.as_ref() {
3505                    configuration.focus_handle(cx)
3506                } else {
3507                    self.focus_handle.clone()
3508                }
3509            }
3510        }
3511    }
3512}
3513
3514fn agent_panel_dock_position(cx: &App) -> DockPosition {
3515    AgentSettings::get_global(cx).dock.into()
3516}
3517
3518pub enum AgentPanelEvent {
3519    ActiveViewChanged,
3520    ThreadFocused,
3521    BackgroundThreadChanged,
3522    MessageSentOrQueued { session_id: acp::SessionId },
3523}
3524
3525impl EventEmitter<PanelEvent> for AgentPanel {}
3526impl EventEmitter<AgentPanelEvent> for AgentPanel {}
3527
3528impl Panel for AgentPanel {
3529    fn persistent_name() -> &'static str {
3530        "AgentPanel"
3531    }
3532
3533    fn panel_key() -> &'static str {
3534        AGENT_PANEL_KEY
3535    }
3536
3537    fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3538        agent_panel_dock_position(cx)
3539    }
3540
3541    fn position_is_valid(&self, position: DockPosition) -> bool {
3542        position != DockPosition::Bottom
3543    }
3544
3545    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
3546        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3547            settings
3548                .agent
3549                .get_or_insert_default()
3550                .set_dock(position.into());
3551        });
3552    }
3553
3554    fn default_size(&self, window: &Window, cx: &App) -> Pixels {
3555        let settings = AgentSettings::get_global(cx);
3556        match self.position(window, cx) {
3557            DockPosition::Left | DockPosition::Right => settings.default_width,
3558            DockPosition::Bottom => settings.default_height,
3559        }
3560    }
3561
3562    fn min_size(&self, window: &Window, cx: &App) -> Option<Pixels> {
3563        match self.position(window, cx) {
3564            DockPosition::Left | DockPosition::Right => Some(MIN_PANEL_WIDTH),
3565            DockPosition::Bottom => None,
3566        }
3567    }
3568
3569    fn supports_flexible_size(&self) -> bool {
3570        true
3571    }
3572
3573    fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
3574        AgentSettings::get_global(cx).flexible
3575    }
3576
3577    fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
3578        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3579            settings
3580                .agent
3581                .get_or_insert_default()
3582                .set_flexible_size(flexible);
3583        });
3584    }
3585
3586    fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
3587        if active
3588            && matches!(self.active_view, ActiveView::Uninitialized)
3589            && !matches!(
3590                self.worktree_creation_status,
3591                Some((_, WorktreeCreationStatus::Creating))
3592            )
3593        {
3594            let id = self.create_draft(window, cx);
3595            self.activate_draft(id, false, window, cx);
3596        }
3597    }
3598
3599    fn remote_id() -> Option<proto::PanelId> {
3600        Some(proto::PanelId::AssistantPanel)
3601    }
3602
3603    fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
3604        (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
3605    }
3606
3607    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3608        Some("Agent Panel")
3609    }
3610
3611    fn toggle_action(&self) -> Box<dyn Action> {
3612        Box::new(ToggleFocus)
3613    }
3614
3615    fn activation_priority(&self) -> u32 {
3616        0
3617    }
3618
3619    fn enabled(&self, cx: &App) -> bool {
3620        AgentSettings::get_global(cx).enabled(cx)
3621    }
3622
3623    fn is_agent_panel(&self) -> bool {
3624        true
3625    }
3626
3627    fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
3628        self.zoomed
3629    }
3630
3631    fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
3632        self.zoomed = zoomed;
3633        cx.notify();
3634    }
3635}
3636
3637impl AgentPanel {
3638    fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
3639        let content = match &self.active_view {
3640            ActiveView::AgentThread { conversation_view } => {
3641                let server_view_ref = conversation_view.read(cx);
3642                let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
3643                    && server_view_ref.root_thread(cx).map_or(false, |tv| {
3644                        tv.read(cx).thread.read(cx).has_provisional_title()
3645                    });
3646
3647                if let Some(title_editor) = server_view_ref
3648                    .root_thread(cx)
3649                    .map(|r| r.read(cx).title_editor.clone())
3650                {
3651                    if is_generating_title {
3652                        Label::new(DEFAULT_THREAD_TITLE)
3653                            .color(Color::Muted)
3654                            .truncate()
3655                            .with_animation(
3656                                "generating_title",
3657                                Animation::new(Duration::from_secs(2))
3658                                    .repeat()
3659                                    .with_easing(pulsating_between(0.4, 0.8)),
3660                                |label, delta| label.alpha(delta),
3661                            )
3662                            .into_any_element()
3663                    } else {
3664                        div()
3665                            .w_full()
3666                            .on_action({
3667                                let conversation_view = conversation_view.downgrade();
3668                                move |_: &menu::Confirm, window, cx| {
3669                                    if let Some(conversation_view) = conversation_view.upgrade() {
3670                                        conversation_view.focus_handle(cx).focus(window, cx);
3671                                    }
3672                                }
3673                            })
3674                            .on_action({
3675                                let conversation_view = conversation_view.downgrade();
3676                                move |_: &editor::actions::Cancel, window, cx| {
3677                                    if let Some(conversation_view) = conversation_view.upgrade() {
3678                                        conversation_view.focus_handle(cx).focus(window, cx);
3679                                    }
3680                                }
3681                            })
3682                            .child(title_editor)
3683                            .into_any_element()
3684                    }
3685                } else {
3686                    Label::new(conversation_view.read(cx).title(cx))
3687                        .color(Color::Muted)
3688                        .truncate()
3689                        .into_any_element()
3690                }
3691            }
3692            ActiveView::History { .. } => Label::new("History").truncate().into_any_element(),
3693            ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
3694            ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
3695        };
3696
3697        h_flex()
3698            .key_context("TitleEditor")
3699            .id("TitleEditor")
3700            .flex_grow()
3701            .w_full()
3702            .max_w_full()
3703            .overflow_x_scroll()
3704            .child(content)
3705            .into_any()
3706    }
3707
3708    fn handle_regenerate_thread_title(conversation_view: Entity<ConversationView>, cx: &mut App) {
3709        conversation_view.update(cx, |conversation_view, cx| {
3710            if let Some(thread) = conversation_view.as_native_thread(cx) {
3711                thread.update(cx, |thread, cx| {
3712                    thread.generate_title(cx);
3713                });
3714            }
3715        });
3716    }
3717
3718    fn render_panel_options_menu(
3719        &self,
3720        _window: &mut Window,
3721        cx: &mut Context<Self>,
3722    ) -> impl IntoElement {
3723        let focus_handle = self.focus_handle(cx);
3724
3725        let conversation_view = match &self.active_view {
3726            ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
3727            _ => None,
3728        };
3729        let thread_with_messages = match &self.active_view {
3730            ActiveView::AgentThread { conversation_view } => {
3731                conversation_view.read(cx).has_user_submitted_prompt(cx)
3732            }
3733            _ => false,
3734        };
3735        let has_auth_methods = match &self.active_view {
3736            ActiveView::AgentThread { conversation_view } => {
3737                conversation_view.read(cx).has_auth_methods()
3738            }
3739            _ => false,
3740        };
3741
3742        PopoverMenu::new("agent-options-menu")
3743            .trigger_with_tooltip(
3744                IconButton::new("agent-options-menu", IconName::Ellipsis)
3745                    .icon_size(IconSize::Small),
3746                {
3747                    let focus_handle = focus_handle.clone();
3748                    move |_window, cx| {
3749                        Tooltip::for_action_in(
3750                            "Toggle Agent Menu",
3751                            &ToggleOptionsMenu,
3752                            &focus_handle,
3753                            cx,
3754                        )
3755                    }
3756                },
3757            )
3758            .anchor(Corner::TopRight)
3759            .with_handle(self.agent_panel_menu_handle.clone())
3760            .menu({
3761                move |window, cx| {
3762                    Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
3763                        menu = menu.context(focus_handle.clone());
3764
3765                        if thread_with_messages {
3766                            menu = menu.header("Current Thread");
3767
3768                            if let Some(conversation_view) = conversation_view.as_ref() {
3769                                menu = menu
3770                                    .entry("Regenerate Thread Title", None, {
3771                                        let conversation_view = conversation_view.clone();
3772                                        move |_, cx| {
3773                                            Self::handle_regenerate_thread_title(
3774                                                conversation_view.clone(),
3775                                                cx,
3776                                            );
3777                                        }
3778                                    })
3779                                    .separator();
3780                            }
3781                        }
3782
3783                        menu = menu
3784                            .header("MCP Servers")
3785                            .action(
3786                                "View Server Extensions",
3787                                Box::new(zed_actions::Extensions {
3788                                    category_filter: Some(
3789                                        zed_actions::ExtensionCategoryFilter::ContextServers,
3790                                    ),
3791                                    id: None,
3792                                }),
3793                            )
3794                            .action("Add Custom Server…", Box::new(AddContextServer))
3795                            .separator()
3796                            .action("Rules", Box::new(OpenRulesLibrary::default()))
3797                            .action("Profiles", Box::new(ManageProfiles::default()))
3798                            .action("Settings", Box::new(OpenSettings))
3799                            .separator()
3800                            .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar));
3801
3802                        if has_auth_methods {
3803                            menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
3804                        }
3805
3806                        menu
3807                    }))
3808                }
3809            })
3810    }
3811
3812    fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3813        let focus_handle = self.focus_handle(cx);
3814
3815        IconButton::new("go-back", IconName::ArrowLeft)
3816            .icon_size(IconSize::Small)
3817            .on_click(cx.listener(|this, _, window, cx| {
3818                this.go_back(&workspace::GoBack, window, cx);
3819            }))
3820            .tooltip({
3821                move |_window, cx| {
3822                    Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
3823                }
3824            })
3825    }
3826
3827    fn project_has_git_repository(&self, cx: &App) -> bool {
3828        !self.project.read(cx).repositories(cx).is_empty()
3829    }
3830
3831    fn is_active_view_creating_worktree(&self, _cx: &App) -> bool {
3832        match &self.worktree_creation_status {
3833            Some((view_id, WorktreeCreationStatus::Creating)) => {
3834                self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id)
3835            }
3836            _ => false,
3837        }
3838    }
3839
3840    fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3841        let focus_handle = self.focus_handle(cx);
3842
3843        let is_creating = self.is_active_view_creating_worktree(cx);
3844
3845        let trigger_parts = self
3846            .start_thread_in
3847            .trigger_label(self.project.read(cx), cx);
3848
3849        let icon = if self.start_thread_in_menu_handle.is_deployed() {
3850            IconName::ChevronUp
3851        } else {
3852            IconName::ChevronDown
3853        };
3854
3855        let trigger_button = ButtonLike::new("thread-target-trigger")
3856            .disabled(is_creating)
3857            .when_some(trigger_parts.prefix, |this, prefix| {
3858                this.child(Label::new(prefix).color(Color::Muted))
3859            })
3860            .child(Label::new(trigger_parts.label))
3861            .when_some(trigger_parts.suffix, |this, suffix| {
3862                this.child(Label::new(suffix).color(Color::Muted))
3863            })
3864            .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
3865
3866        let project = self.project.clone();
3867        let current_target = self.start_thread_in.clone();
3868        let fs = self.fs.clone();
3869
3870        PopoverMenu::new("thread-target-selector")
3871            .trigger_with_tooltip(trigger_button, {
3872                move |_window, cx| {
3873                    Tooltip::for_action_in(
3874                        "Start Thread In…",
3875                        &CycleStartThreadIn,
3876                        &focus_handle,
3877                        cx,
3878                    )
3879                }
3880            })
3881            .menu(move |window, cx| {
3882                let fs = fs.clone();
3883                Some(cx.new(|cx| {
3884                    ThreadWorktreePicker::new(project.clone(), &current_target, fs, window, cx)
3885                }))
3886            })
3887            .with_handle(self.start_thread_in_menu_handle.clone())
3888            .anchor(Corner::TopLeft)
3889            .offset(gpui::Point {
3890                x: px(1.0),
3891                y: px(1.0),
3892            })
3893    }
3894
3895    fn render_new_worktree_branch_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3896        let is_creating = self.is_active_view_creating_worktree(cx);
3897
3898        let project_ref = self.project.read(cx);
3899        let trigger_parts = self
3900            .start_thread_in
3901            .branch_trigger_label(project_ref, cx)
3902            .unwrap_or_else(|| StartThreadInLabel {
3903                prefix: Some("From:".into()),
3904                label: "HEAD".into(),
3905                suffix: None,
3906            });
3907
3908        let icon = if self.thread_branch_menu_handle.is_deployed() {
3909            IconName::ChevronUp
3910        } else {
3911            IconName::ChevronDown
3912        };
3913
3914        let trigger_button = ButtonLike::new("thread-branch-trigger")
3915            .disabled(is_creating)
3916            .when_some(trigger_parts.prefix, |this, prefix| {
3917                this.child(Label::new(prefix).color(Color::Muted))
3918            })
3919            .child(Label::new(trigger_parts.label))
3920            .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
3921
3922        let project = self.project.clone();
3923        let current_target = self.start_thread_in.clone();
3924
3925        PopoverMenu::new("thread-branch-selector")
3926            .trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…"))
3927            .menu(move |window, cx| {
3928                Some(cx.new(|cx| {
3929                    ThreadBranchPicker::new(project.clone(), &current_target, window, cx)
3930                }))
3931            })
3932            .with_handle(self.thread_branch_menu_handle.clone())
3933            .anchor(Corner::TopLeft)
3934            .offset(gpui::Point {
3935                x: px(1.0),
3936                y: px(1.0),
3937            })
3938    }
3939
3940    fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3941        let agent_server_store = self.project.read(cx).agent_server_store().clone();
3942        let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some();
3943        let focus_handle = self.focus_handle(cx);
3944
3945        let (selected_agent_custom_icon, selected_agent_label) =
3946            if let Agent::Custom { id, .. } = &self.selected_agent {
3947                let store = agent_server_store.read(cx);
3948                let icon = store.agent_icon(&id);
3949
3950                let label = store
3951                    .agent_display_name(&id)
3952                    .unwrap_or_else(|| self.selected_agent.label());
3953                (icon, label)
3954            } else {
3955                (None, self.selected_agent.label())
3956            };
3957
3958        let active_thread = match &self.active_view {
3959            ActiveView::AgentThread { conversation_view } => {
3960                conversation_view.read(cx).as_native_thread(cx)
3961            }
3962            ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
3963                None
3964            }
3965        };
3966
3967        let new_thread_menu_builder: Rc<
3968            dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
3969        > = {
3970            let selected_agent = self.selected_agent.clone();
3971            let is_agent_selected = move |agent: Agent| selected_agent == agent;
3972
3973            let workspace = self.workspace.clone();
3974            let is_via_collab = workspace
3975                .update(cx, |workspace, cx| {
3976                    workspace.project().read(cx).is_via_collab()
3977                })
3978                .unwrap_or_default();
3979
3980            let focus_handle = focus_handle.clone();
3981            let agent_server_store = agent_server_store;
3982
3983            Rc::new(move |window, cx| {
3984                telemetry::event!("New Thread Clicked");
3985
3986                let active_thread = active_thread.clone();
3987                Some(ContextMenu::build(window, cx, |menu, _window, cx| {
3988                    menu.context(focus_handle.clone())
3989                        .when_some(active_thread, |this, active_thread| {
3990                            let thread = active_thread.read(cx);
3991
3992                            if !thread.is_empty() {
3993                                let session_id = thread.id().clone();
3994                                this.item(
3995                                    ContextMenuEntry::new("New From Summary")
3996                                        .icon(IconName::ThreadFromSummary)
3997                                        .icon_color(Color::Muted)
3998                                        .handler(move |window, cx| {
3999                                            window.dispatch_action(
4000                                                Box::new(NewNativeAgentThreadFromSummary {
4001                                                    from_session_id: session_id.clone(),
4002                                                }),
4003                                                cx,
4004                                            );
4005                                        }),
4006                                )
4007                            } else {
4008                                this
4009                            }
4010                        })
4011                        .item(
4012                            ContextMenuEntry::new("Zed Agent")
4013                                .when(is_agent_selected(Agent::NativeAgent), |this| {
4014                                    this.action(Box::new(NewExternalAgentThread { agent: None }))
4015                                })
4016                                .icon(IconName::ZedAgent)
4017                                .icon_color(Color::Muted)
4018                                .handler({
4019                                    let workspace = workspace.clone();
4020                                    move |window, cx| {
4021                                        if let Some(workspace) = workspace.upgrade() {
4022                                            workspace.update(cx, |workspace, cx| {
4023                                                if let Some(panel) =
4024                                                    workspace.panel::<AgentPanel>(cx)
4025                                                {
4026                                                    panel.update(cx, |panel, cx| {
4027                                                        panel.selected_agent = Agent::NativeAgent;
4028                                                        panel.reset_start_thread_in_to_default(cx);
4029                                                        let id = panel.create_draft(window, cx);
4030                                                        panel.activate_draft(id, true, window, cx);
4031                                                    });
4032                                                }
4033                                            });
4034                                        }
4035                                    }
4036                                }),
4037                        )
4038                        .map(|mut menu| {
4039                            let agent_server_store = agent_server_store.read(cx);
4040                            let registry_store = project::AgentRegistryStore::try_global(cx);
4041                            let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
4042
4043                            struct AgentMenuItem {
4044                                id: AgentId,
4045                                display_name: SharedString,
4046                            }
4047
4048                            let agent_items = agent_server_store
4049                                .external_agents()
4050                                .map(|agent_id| {
4051                                    let display_name = agent_server_store
4052                                        .agent_display_name(agent_id)
4053                                        .or_else(|| {
4054                                            registry_store_ref
4055                                                .as_ref()
4056                                                .and_then(|store| store.agent(agent_id))
4057                                                .map(|a| a.name().clone())
4058                                        })
4059                                        .unwrap_or_else(|| agent_id.0.clone());
4060                                    AgentMenuItem {
4061                                        id: agent_id.clone(),
4062                                        display_name,
4063                                    }
4064                                })
4065                                .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
4066                                .collect::<Vec<_>>();
4067
4068                            if !agent_items.is_empty() {
4069                                menu = menu.separator().header("External Agents");
4070                            }
4071                            for item in &agent_items {
4072                                let mut entry = ContextMenuEntry::new(item.display_name.clone());
4073
4074                                let icon_path =
4075                                    agent_server_store.agent_icon(&item.id).or_else(|| {
4076                                        registry_store_ref
4077                                            .as_ref()
4078                                            .and_then(|store| store.agent(&item.id))
4079                                            .and_then(|a| a.icon_path().cloned())
4080                                    });
4081
4082                                if let Some(icon_path) = icon_path {
4083                                    entry = entry.custom_icon_svg(icon_path);
4084                                } else {
4085                                    entry = entry.icon(IconName::Sparkle);
4086                                }
4087
4088                                entry = entry
4089                                    .when(
4090                                        is_agent_selected(Agent::Custom {
4091                                            id: item.id.clone(),
4092                                        }),
4093                                        |this| {
4094                                            this.action(Box::new(NewExternalAgentThread {
4095                                                agent: None,
4096                                            }))
4097                                        },
4098                                    )
4099                                    .icon_color(Color::Muted)
4100                                    .disabled(is_via_collab)
4101                                    .handler({
4102                                        let workspace = workspace.clone();
4103                                        let agent_id = item.id.clone();
4104                                        move |window, cx| {
4105                                            if let Some(workspace) = workspace.upgrade() {
4106                                                workspace.update(cx, |workspace, cx| {
4107                                                    if let Some(panel) =
4108                                                        workspace.panel::<AgentPanel>(cx)
4109                                                    {
4110                                                        panel.update(cx, |panel, cx| {
4111                                                            panel.selected_agent = Agent::Custom {
4112                                                                id: agent_id.clone(),
4113                                                            };
4114                                                            panel.reset_start_thread_in_to_default(
4115                                                                cx,
4116                                                            );
4117                                                            let id = panel.create_draft(window, cx);
4118                                                            panel.activate_draft(
4119                                                                id, true, window, cx,
4120                                                            );
4121                                                        });
4122                                                    }
4123                                                });
4124                                            }
4125                                        }
4126                                    });
4127
4128                                menu = menu.item(entry);
4129                            }
4130
4131                            menu
4132                        })
4133                        .separator()
4134                        .item(
4135                            ContextMenuEntry::new("Add More Agents")
4136                                .icon(IconName::Plus)
4137                                .icon_color(Color::Muted)
4138                                .handler({
4139                                    move |window, cx| {
4140                                        window
4141                                            .dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
4142                                    }
4143                                }),
4144                        )
4145                }))
4146            })
4147        };
4148
4149        let is_thread_loading = self
4150            .active_conversation_view()
4151            .map(|thread| thread.read(cx).is_loading())
4152            .unwrap_or(false);
4153
4154        let has_custom_icon = selected_agent_custom_icon.is_some();
4155        let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
4156        let selected_agent_builtin_icon = self.selected_agent.icon();
4157        let selected_agent_label_for_tooltip = selected_agent_label.clone();
4158
4159        let selected_agent = div()
4160            .id("selected_agent_icon")
4161            .when_some(selected_agent_custom_icon, |this, icon_path| {
4162                this.px_1().child(
4163                    Icon::from_external_svg(icon_path)
4164                        .color(Color::Muted)
4165                        .size(IconSize::Small),
4166                )
4167            })
4168            .when(!has_custom_icon, |this| {
4169                this.when_some(selected_agent_builtin_icon, |this, icon| {
4170                    this.px_1().child(Icon::new(icon).color(Color::Muted))
4171                })
4172            })
4173            .tooltip(move |_, cx| {
4174                Tooltip::with_meta(
4175                    selected_agent_label_for_tooltip.clone(),
4176                    None,
4177                    "Selected Agent",
4178                    cx,
4179                )
4180            });
4181
4182        let selected_agent = if is_thread_loading {
4183            selected_agent
4184                .with_animation(
4185                    "pulsating-icon",
4186                    Animation::new(Duration::from_secs(1))
4187                        .repeat()
4188                        .with_easing(pulsating_between(0.2, 0.6)),
4189                    |icon, delta| icon.opacity(delta),
4190                )
4191                .into_any_element()
4192        } else {
4193            selected_agent.into_any_element()
4194        };
4195
4196        let is_empty_state = !self.active_thread_has_messages(cx);
4197
4198        let is_in_history_or_config = matches!(
4199            &self.active_view,
4200            ActiveView::History { .. } | ActiveView::Configuration
4201        );
4202
4203        let is_full_screen = self.is_zoomed(window, cx);
4204        let full_screen_button = if is_full_screen {
4205            IconButton::new("disable-full-screen", IconName::Minimize)
4206                .icon_size(IconSize::Small)
4207                .tooltip(move |_, cx| Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx))
4208                .on_click(cx.listener(move |this, _, window, cx| {
4209                    this.toggle_zoom(&ToggleZoom, window, cx);
4210                }))
4211        } else {
4212            IconButton::new("enable-full-screen", IconName::Maximize)
4213                .icon_size(IconSize::Small)
4214                .tooltip(move |_, cx| Tooltip::for_action("Enable Full Screen", &ToggleZoom, cx))
4215                .on_click(cx.listener(move |this, _, window, cx| {
4216                    this.toggle_zoom(&ToggleZoom, window, cx);
4217                }))
4218        };
4219
4220        let use_v2_empty_toolbar = is_empty_state && !is_in_history_or_config;
4221
4222        let max_content_width = AgentSettings::get_global(cx).max_content_width;
4223
4224        let base_container = h_flex()
4225            .size_full()
4226            // TODO: This is only until we remove Agent settings from the panel.
4227            .when(!is_in_history_or_config, |this| {
4228                this.max_w(max_content_width).mx_auto()
4229            })
4230            .flex_none()
4231            .justify_between()
4232            .gap_2();
4233
4234        let toolbar_content = if use_v2_empty_toolbar {
4235            let (chevron_icon, icon_color, label_color) =
4236                if self.new_thread_menu_handle.is_deployed() {
4237                    (IconName::ChevronUp, Color::Accent, Color::Accent)
4238                } else {
4239                    (IconName::ChevronDown, Color::Muted, Color::Default)
4240                };
4241
4242            let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
4243                Icon::from_external_svg(icon_path)
4244                    .size(IconSize::Small)
4245                    .color(icon_color)
4246            } else {
4247                let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
4248                Icon::new(icon_name).size(IconSize::Small).color(icon_color)
4249            };
4250
4251            let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
4252                .start_icon(agent_icon)
4253                .color(label_color)
4254                .end_icon(
4255                    Icon::new(chevron_icon)
4256                        .color(icon_color)
4257                        .size(IconSize::XSmall),
4258                );
4259
4260            let agent_selector_menu = PopoverMenu::new("new_thread_menu")
4261                .trigger_with_tooltip(agent_selector_button, {
4262                    move |_window, cx| {
4263                        Tooltip::for_action_in(
4264                            "New Thread…",
4265                            &ToggleNewThreadMenu,
4266                            &focus_handle,
4267                            cx,
4268                        )
4269                    }
4270                })
4271                .menu({
4272                    let builder = new_thread_menu_builder.clone();
4273                    move |window, cx| builder(window, cx)
4274                })
4275                .with_handle(self.new_thread_menu_handle.clone())
4276                .anchor(Corner::TopLeft)
4277                .offset(gpui::Point {
4278                    x: px(1.0),
4279                    y: px(1.0),
4280                });
4281
4282            base_container
4283                .child(
4284                    h_flex()
4285                        .size_full()
4286                        .gap(DynamicSpacing::Base04.rems(cx))
4287                        .pl(DynamicSpacing::Base04.rems(cx))
4288                        .child(agent_selector_menu)
4289                        .when(
4290                            has_visible_worktrees && self.project_has_git_repository(cx),
4291                            |this| this.child(self.render_start_thread_in_selector(cx)),
4292                        )
4293                        .when(
4294                            matches!(self.start_thread_in, StartThreadIn::NewWorktree { .. }),
4295                            |this| this.child(self.render_new_worktree_branch_selector(cx)),
4296                        ),
4297                )
4298                .child(
4299                    h_flex()
4300                        .h_full()
4301                        .flex_none()
4302                        .gap_1()
4303                        .pl_1()
4304                        .pr_1()
4305                        .child(full_screen_button)
4306                        .child(self.render_panel_options_menu(window, cx)),
4307                )
4308                .into_any_element()
4309        } else {
4310            let new_thread_menu = PopoverMenu::new("new_thread_menu")
4311                .trigger_with_tooltip(
4312                    IconButton::new("new_thread_menu_btn", IconName::Plus)
4313                        .icon_size(IconSize::Small),
4314                    {
4315                        move |_window, cx| {
4316                            Tooltip::for_action_in(
4317                                "New Thread\u{2026}",
4318                                &ToggleNewThreadMenu,
4319                                &focus_handle,
4320                                cx,
4321                            )
4322                        }
4323                    },
4324                )
4325                .anchor(Corner::TopRight)
4326                .with_handle(self.new_thread_menu_handle.clone())
4327                .menu(move |window, cx| new_thread_menu_builder(window, cx));
4328
4329            base_container
4330                .child(
4331                    h_flex()
4332                        .size_full()
4333                        .gap(DynamicSpacing::Base04.rems(cx))
4334                        .pl(DynamicSpacing::Base04.rems(cx))
4335                        .child(match &self.active_view {
4336                            ActiveView::History { .. } | ActiveView::Configuration => {
4337                                self.render_toolbar_back_button(cx).into_any_element()
4338                            }
4339                            _ => selected_agent.into_any_element(),
4340                        })
4341                        .child(self.render_title_view(window, cx)),
4342                )
4343                .child(
4344                    h_flex()
4345                        .h_full()
4346                        .flex_none()
4347                        .gap_1()
4348                        .pl_1()
4349                        .pr_1()
4350                        .child(new_thread_menu)
4351                        .child(full_screen_button)
4352                        .child(self.render_panel_options_menu(window, cx)),
4353                )
4354                .into_any_element()
4355        };
4356
4357        h_flex()
4358            .id("agent-panel-toolbar")
4359            .h(Tab::container_height(cx))
4360            .flex_shrink_0()
4361            .max_w_full()
4362            .bg(cx.theme().colors().tab_bar_background)
4363            .border_b_1()
4364            .border_color(cx.theme().colors().border)
4365            .child(toolbar_content)
4366    }
4367
4368    fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4369        let (view_id, status) = self.worktree_creation_status.as_ref()?;
4370        let active_view_id = self.active_conversation_view().map(|v| v.entity_id());
4371        if active_view_id != Some(*view_id) {
4372            return None;
4373        }
4374        match status {
4375            WorktreeCreationStatus::Creating => Some(
4376                h_flex()
4377                    .absolute()
4378                    .bottom_12()
4379                    .w_full()
4380                    .p_2()
4381                    .gap_1()
4382                    .justify_center()
4383                    .bg(cx.theme().colors().editor_background)
4384                    .child(
4385                        Icon::new(IconName::LoadCircle)
4386                            .size(IconSize::Small)
4387                            .color(Color::Muted)
4388                            .with_rotate_animation(3),
4389                    )
4390                    .child(
4391                        Label::new("Creating Worktree…")
4392                            .color(Color::Muted)
4393                            .size(LabelSize::Small),
4394                    )
4395                    .into_any_element(),
4396            ),
4397            WorktreeCreationStatus::Error(message) => Some(
4398                Callout::new()
4399                    .icon(IconName::XCircleFilled)
4400                    .severity(Severity::Error)
4401                    .title("Worktree Creation Error")
4402                    .description(message.clone())
4403                    .border_position(ui::BorderPosition::Bottom)
4404                    .dismiss_action(
4405                        IconButton::new("dismiss-worktree-error", IconName::Close)
4406                            .icon_size(IconSize::Small)
4407                            .tooltip(Tooltip::text("Dismiss"))
4408                            .on_click(cx.listener(|this, _, _, cx| {
4409                                this.worktree_creation_status = None;
4410                                cx.notify();
4411                            })),
4412                    )
4413                    .into_any_element(),
4414            ),
4415        }
4416    }
4417
4418    fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
4419        if TrialEndUpsell::dismissed(cx) {
4420            return false;
4421        }
4422
4423        match &self.active_view {
4424            ActiveView::AgentThread { .. } => {
4425                if LanguageModelRegistry::global(cx)
4426                    .read(cx)
4427                    .default_model()
4428                    .is_some_and(|model| {
4429                        model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4430                    })
4431                {
4432                    return false;
4433                }
4434            }
4435            ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4436                return false;
4437            }
4438        }
4439
4440        let plan = self.user_store.read(cx).plan();
4441        let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
4442
4443        plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
4444    }
4445
4446    fn should_render_agent_layout_onboarding(&self, cx: &mut Context<Self>) -> bool {
4447        // We only want to show this for existing users: those who
4448        // have used the agent panel before the sidebar was introduced.
4449        // We can infer that state by users having seen the onboarding
4450        // at one point, but not the agent layout onboarding.
4451
4452        let has_messages = self.active_thread_has_messages(cx);
4453        let is_dismissed = self
4454            .agent_layout_onboarding_dismissed
4455            .load(Ordering::Acquire);
4456
4457        if is_dismissed || has_messages {
4458            return false;
4459        }
4460
4461        match &self.active_view {
4462            ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4463                false
4464            }
4465            ActiveView::AgentThread { .. } => {
4466                let existing_user = self
4467                    .new_user_onboarding_upsell_dismissed
4468                    .load(Ordering::Acquire);
4469                existing_user
4470            }
4471        }
4472    }
4473
4474    fn render_agent_layout_onboarding(
4475        &self,
4476        _window: &mut Window,
4477        cx: &mut Context<Self>,
4478    ) -> Option<impl IntoElement> {
4479        if !self.should_render_agent_layout_onboarding(cx) {
4480            return None;
4481        }
4482
4483        Some(div().child(self.agent_layout_onboarding.clone()))
4484    }
4485
4486    fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context<Self>) {
4487        self.agent_layout_onboarding_dismissed
4488            .store(true, Ordering::Release);
4489        AgentLayoutOnboarding::set_dismissed(true, cx);
4490        cx.notify();
4491    }
4492
4493    fn dismiss_ai_onboarding(&mut self, cx: &mut Context<Self>) {
4494        self.new_user_onboarding_upsell_dismissed
4495            .store(true, Ordering::Release);
4496        OnboardingUpsell::set_dismissed(true, cx);
4497        self.dismiss_agent_layout_onboarding(cx);
4498        cx.notify();
4499    }
4500
4501    fn should_render_new_user_onboarding(&mut self, cx: &mut Context<Self>) -> bool {
4502        if self
4503            .new_user_onboarding_upsell_dismissed
4504            .load(Ordering::Acquire)
4505        {
4506            return false;
4507        }
4508
4509        let user_store = self.user_store.read(cx);
4510
4511        if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
4512            && user_store
4513                .subscription_period()
4514                .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
4515                .is_some_and(|date| date < chrono::Utc::now())
4516        {
4517            if !self
4518                .new_user_onboarding_upsell_dismissed
4519                .load(Ordering::Acquire)
4520            {
4521                self.dismiss_ai_onboarding(cx);
4522            }
4523            return false;
4524        }
4525
4526        let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
4527            .visible_providers()
4528            .iter()
4529            .any(|provider| {
4530                provider.is_authenticated(cx)
4531                    && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4532            });
4533
4534        match &self.active_view {
4535            ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
4536                false
4537            }
4538            ActiveView::AgentThread {
4539                conversation_view, ..
4540            } if conversation_view.read(cx).as_native_thread(cx).is_none() => false,
4541            ActiveView::AgentThread { conversation_view } => {
4542                let history_is_empty = conversation_view
4543                    .read(cx)
4544                    .history()
4545                    .is_none_or(|h| h.read(cx).is_empty());
4546                history_is_empty || !has_configured_non_zed_providers
4547            }
4548        }
4549    }
4550
4551    fn render_new_user_onboarding(
4552        &mut self,
4553        _window: &mut Window,
4554        cx: &mut Context<Self>,
4555    ) -> Option<impl IntoElement> {
4556        if !self.should_render_new_user_onboarding(cx) {
4557            return None;
4558        }
4559
4560        Some(
4561            div()
4562                .bg(cx.theme().colors().editor_background)
4563                .child(self.new_user_onboarding.clone()),
4564        )
4565    }
4566
4567    fn render_trial_end_upsell(
4568        &self,
4569        _window: &mut Window,
4570        cx: &mut Context<Self>,
4571    ) -> Option<impl IntoElement> {
4572        if !self.should_render_trial_end_upsell(cx) {
4573            return None;
4574        }
4575
4576        Some(
4577            v_flex()
4578                .absolute()
4579                .inset_0()
4580                .size_full()
4581                .bg(cx.theme().colors().panel_background)
4582                .opacity(0.85)
4583                .block_mouse_except_scroll()
4584                .child(EndTrialUpsell::new(Arc::new({
4585                    let this = cx.entity();
4586                    move |_, cx| {
4587                        this.update(cx, |_this, cx| {
4588                            TrialEndUpsell::set_dismissed(true, cx);
4589                            cx.notify();
4590                        });
4591                    }
4592                }))),
4593        )
4594    }
4595
4596    fn render_drag_target(&self, cx: &Context<Self>) -> Div {
4597        let is_local = self.project.read(cx).is_local();
4598        div()
4599            .invisible()
4600            .absolute()
4601            .top_0()
4602            .right_0()
4603            .bottom_0()
4604            .left_0()
4605            .bg(cx.theme().colors().drop_target_background)
4606            .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
4607            .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
4608            .when(is_local, |this| {
4609                this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
4610            })
4611            .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
4612                let item = tab.pane.read(cx).item_for_index(tab.ix);
4613                let project_paths = item
4614                    .and_then(|item| item.project_path(cx))
4615                    .into_iter()
4616                    .collect::<Vec<_>>();
4617                this.handle_drop(project_paths, vec![], window, cx);
4618            }))
4619            .on_drop(
4620                cx.listener(move |this, selection: &DraggedSelection, window, cx| {
4621                    let project_paths = selection
4622                        .items()
4623                        .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
4624                        .collect::<Vec<_>>();
4625                    this.handle_drop(project_paths, vec![], window, cx);
4626                }),
4627            )
4628            .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
4629                let tasks = paths
4630                    .paths()
4631                    .iter()
4632                    .map(|path| {
4633                        Workspace::project_path_for_path(this.project.clone(), path, false, cx)
4634                    })
4635                    .collect::<Vec<_>>();
4636                cx.spawn_in(window, async move |this, cx| {
4637                    let mut paths = vec![];
4638                    let mut added_worktrees = vec![];
4639                    let opened_paths = futures::future::join_all(tasks).await;
4640                    for entry in opened_paths {
4641                        if let Some((worktree, project_path)) = entry.log_err() {
4642                            added_worktrees.push(worktree);
4643                            paths.push(project_path);
4644                        }
4645                    }
4646                    this.update_in(cx, |this, window, cx| {
4647                        this.handle_drop(paths, added_worktrees, window, cx);
4648                    })
4649                    .ok();
4650                })
4651                .detach();
4652            }))
4653    }
4654
4655    fn handle_drop(
4656        &mut self,
4657        paths: Vec<ProjectPath>,
4658        added_worktrees: Vec<Entity<Worktree>>,
4659        window: &mut Window,
4660        cx: &mut Context<Self>,
4661    ) {
4662        match &self.active_view {
4663            ActiveView::AgentThread { conversation_view } => {
4664                conversation_view.update(cx, |conversation_view, cx| {
4665                    conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
4666                });
4667            }
4668            ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4669        }
4670    }
4671
4672    fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
4673        if !self.show_trust_workspace_message {
4674            return None;
4675        }
4676
4677        let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
4678
4679        Some(
4680            Callout::new()
4681                .icon(IconName::Warning)
4682                .severity(Severity::Warning)
4683                .border_position(ui::BorderPosition::Bottom)
4684                .title("You're in Restricted Mode")
4685                .description(description)
4686                .actions_slot(
4687                    Button::new("open-trust-modal", "Configure Project Trust")
4688                        .label_size(LabelSize::Small)
4689                        .style(ButtonStyle::Outlined)
4690                        .on_click({
4691                            cx.listener(move |this, _, window, cx| {
4692                                this.workspace
4693                                    .update(cx, |workspace, cx| {
4694                                        workspace
4695                                            .show_worktree_trust_security_modal(true, window, cx)
4696                                    })
4697                                    .log_err();
4698                            })
4699                        }),
4700                ),
4701        )
4702    }
4703
4704    fn key_context(&self) -> KeyContext {
4705        let mut key_context = KeyContext::new_with_defaults();
4706        key_context.add("AgentPanel");
4707        match &self.active_view {
4708            ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
4709            ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
4710        }
4711        key_context
4712    }
4713}
4714
4715impl Render for AgentPanel {
4716    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4717        // WARNING: Changes to this element hierarchy can have
4718        // non-obvious implications to the layout of children.
4719        //
4720        // If you need to change it, please confirm:
4721        // - The message editor expands (cmd-option-esc) correctly
4722        // - When expanded, the buttons at the bottom of the panel are displayed correctly
4723        // - Font size works as expected and can be changed with cmd-+/cmd-
4724        // - Scrolling in all views works as expected
4725        // - Files can be dropped into the panel
4726        let content = v_flex()
4727            .relative()
4728            .size_full()
4729            .justify_between()
4730            .key_context(self.key_context())
4731            .on_action(cx.listener(|this, action: &NewThread, window, cx| {
4732                this.new_thread(action, window, cx);
4733            }))
4734            .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
4735                this.open_history(window, cx);
4736            }))
4737            .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
4738                this.open_configuration(window, cx);
4739            }))
4740            .on_action(cx.listener(Self::open_active_thread_as_markdown))
4741            .on_action(cx.listener(Self::deploy_rules_library))
4742            .on_action(cx.listener(Self::go_back))
4743            .on_action(cx.listener(Self::toggle_navigation_menu))
4744            .on_action(cx.listener(Self::toggle_options_menu))
4745            .on_action(cx.listener(Self::increase_font_size))
4746            .on_action(cx.listener(Self::decrease_font_size))
4747            .on_action(cx.listener(Self::reset_font_size))
4748            .on_action(cx.listener(Self::toggle_zoom))
4749            .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
4750                if let Some(conversation_view) = this.active_conversation_view() {
4751                    conversation_view.update(cx, |conversation_view, cx| {
4752                        conversation_view.reauthenticate(window, cx)
4753                    })
4754                }
4755            }))
4756            .child(self.render_toolbar(window, cx))
4757            .children(self.render_workspace_trust_message(cx))
4758            .children(self.render_new_user_onboarding(window, cx))
4759            .children(self.render_agent_layout_onboarding(window, cx))
4760            .map(|parent| match &self.active_view {
4761                ActiveView::Uninitialized => parent,
4762                ActiveView::AgentThread {
4763                    conversation_view, ..
4764                } => parent
4765                    .child(conversation_view.clone())
4766                    .child(self.render_drag_target(cx)),
4767                ActiveView::History { view } => parent.child(view.clone()),
4768                ActiveView::Configuration => parent.children(self.configuration.clone()),
4769            })
4770            .children(self.render_worktree_creation_status(cx))
4771            .children(self.render_trial_end_upsell(window, cx));
4772
4773        match self.active_view.which_font_size_used() {
4774            WhichFontSize::AgentFont => {
4775                WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
4776                    .size_full()
4777                    .child(content)
4778                    .into_any()
4779            }
4780            _ => content.into_any(),
4781        }
4782    }
4783}
4784
4785struct PromptLibraryInlineAssist {
4786    workspace: WeakEntity<Workspace>,
4787}
4788
4789impl PromptLibraryInlineAssist {
4790    pub fn new(workspace: WeakEntity<Workspace>) -> Self {
4791        Self { workspace }
4792    }
4793}
4794
4795impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
4796    fn assist(
4797        &self,
4798        prompt_editor: &Entity<Editor>,
4799        initial_prompt: Option<String>,
4800        window: &mut Window,
4801        cx: &mut Context<RulesLibrary>,
4802    ) {
4803        InlineAssistant::update_global(cx, |assistant, cx| {
4804            let Some(workspace) = self.workspace.upgrade() else {
4805                return;
4806            };
4807            let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4808                return;
4809            };
4810            let history = panel
4811                .read(cx)
4812                .connection_store()
4813                .read(cx)
4814                .entry(&crate::Agent::NativeAgent)
4815                .and_then(|s| s.read(cx).history())
4816                .map(|h| h.downgrade());
4817            let project = workspace.read(cx).project().downgrade();
4818            let panel = panel.read(cx);
4819            let thread_store = panel.thread_store().clone();
4820            assistant.assist(
4821                prompt_editor,
4822                self.workspace.clone(),
4823                project,
4824                thread_store,
4825                None,
4826                history,
4827                initial_prompt,
4828                window,
4829                cx,
4830            );
4831        })
4832    }
4833
4834    fn focus_agent_panel(
4835        &self,
4836        workspace: &mut Workspace,
4837        window: &mut Window,
4838        cx: &mut Context<Workspace>,
4839    ) -> bool {
4840        workspace.focus_panel::<AgentPanel>(window, cx).is_some()
4841    }
4842}
4843
4844struct OnboardingUpsell;
4845
4846impl Dismissable for OnboardingUpsell {
4847    const KEY: &'static str = "dismissed-trial-upsell";
4848}
4849
4850struct AgentLayoutOnboarding;
4851
4852impl Dismissable for AgentLayoutOnboarding {
4853    const KEY: &'static str = "dismissed-agent-layout-onboarding";
4854}
4855
4856struct TrialEndUpsell;
4857
4858impl Dismissable for TrialEndUpsell {
4859    const KEY: &'static str = "dismissed-trial-end-upsell";
4860}
4861
4862/// Test-only helper methods
4863#[cfg(any(test, feature = "test-support"))]
4864impl AgentPanel {
4865    pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
4866        Self::new(workspace, None, window, cx)
4867    }
4868
4869    /// Opens an external thread using an arbitrary AgentServer.
4870    ///
4871    /// This is a test-only helper that allows visual tests and integration tests
4872    /// to inject a stub server without modifying production code paths.
4873    /// Not compiled into production builds.
4874    pub fn open_external_thread_with_server(
4875        &mut self,
4876        server: Rc<dyn AgentServer>,
4877        window: &mut Window,
4878        cx: &mut Context<Self>,
4879    ) {
4880        let workspace = self.workspace.clone();
4881        let project = self.project.clone();
4882
4883        let ext_agent = Agent::Custom {
4884            id: server.agent_id(),
4885        };
4886
4887        let conversation_view = self.create_agent_thread(
4888            server, None, None, None, None, workspace, project, ext_agent, window, cx,
4889        );
4890        self.set_active_view(
4891            ActiveView::AgentThread { conversation_view },
4892            true,
4893            window,
4894            cx,
4895        );
4896    }
4897
4898    /// Returns the currently active thread view, if any.
4899    ///
4900    /// This is a test-only accessor that exposes the private `active_thread_view()`
4901    /// method for test assertions. Not compiled into production builds.
4902    pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConversationView>> {
4903        self.active_conversation_view()
4904    }
4905
4906    /// Sets the start_thread_in value directly, bypassing validation.
4907    ///
4908    /// This is a test-only helper for visual tests that need to show specific
4909    /// start_thread_in states without requiring a real git repository.
4910    pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
4911        self.start_thread_in = target;
4912        cx.notify();
4913    }
4914
4915    /// Returns the current worktree creation status.
4916    ///
4917    /// This is a test-only helper for visual tests.
4918    pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
4919        self.worktree_creation_status.as_ref().map(|(_, s)| s)
4920    }
4921
4922    /// Sets the worktree creation status directly, associating it with the
4923    /// currently active conversation view.
4924    ///
4925    /// This is a test-only helper for visual tests that need to show the
4926    /// "Creating worktree…" spinner or error banners.
4927    pub fn set_worktree_creation_status_for_tests(
4928        &mut self,
4929        status: Option<WorktreeCreationStatus>,
4930        cx: &mut Context<Self>,
4931    ) {
4932        self.worktree_creation_status = status.map(|s| {
4933            let view_id = self
4934                .active_conversation_view()
4935                .map(|v| v.entity_id())
4936                .unwrap_or_else(|| EntityId::from(0u64));
4937            (view_id, s)
4938        });
4939        cx.notify();
4940    }
4941
4942    /// Opens the history view.
4943    ///
4944    /// This is a test-only helper that exposes the private `open_history()`
4945    /// method for visual tests.
4946    pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4947        self.open_history(window, cx);
4948    }
4949
4950    /// Opens the start_thread_in selector popover menu.
4951    ///
4952    /// This is a test-only helper for visual tests.
4953    pub fn open_start_thread_in_menu_for_tests(
4954        &mut self,
4955        window: &mut Window,
4956        cx: &mut Context<Self>,
4957    ) {
4958        self.start_thread_in_menu_handle.show(window, cx);
4959    }
4960
4961    /// Dismisses the start_thread_in dropdown menu.
4962    ///
4963    /// This is a test-only helper for visual tests.
4964    pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
4965        self.start_thread_in_menu_handle.hide(cx);
4966    }
4967}
4968
4969#[cfg(test)]
4970mod tests {
4971    use super::*;
4972    use crate::conversation_view::tests::{StubAgentServer, init_test};
4973    use crate::test_support::{
4974        active_session_id, open_thread_with_connection, open_thread_with_custom_connection,
4975        send_message,
4976    };
4977    use acp_thread::{StubAgentConnection, ThreadStatus};
4978    use agent_servers::CODEX_ID;
4979    use feature_flags::FeatureFlagAppExt;
4980    use fs::FakeFs;
4981    use gpui::{TestAppContext, VisualTestContext};
4982    use project::Project;
4983    use serde_json::json;
4984    use std::path::Path;
4985    use std::time::Instant;
4986    use workspace::MultiWorkspace;
4987
4988    #[gpui::test]
4989    async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
4990        init_test(cx);
4991        cx.update(|cx| {
4992            agent::ThreadStore::init_global(cx);
4993            language_model::LanguageModelRegistry::test(cx);
4994        });
4995
4996        // Create a MultiWorkspace window with two workspaces.
4997        let fs = FakeFs::new(cx.executor());
4998        let project_a = Project::test(fs.clone(), [], cx).await;
4999        let project_b = Project::test(fs, [], cx).await;
5000
5001        let multi_workspace =
5002            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5003
5004        let workspace_a = multi_workspace
5005            .read_with(cx, |multi_workspace, _cx| {
5006                multi_workspace.workspace().clone()
5007            })
5008            .unwrap();
5009
5010        let workspace_b = multi_workspace
5011            .update(cx, |multi_workspace, window, cx| {
5012                multi_workspace.test_add_workspace(project_b.clone(), window, cx)
5013            })
5014            .unwrap();
5015
5016        workspace_a.update(cx, |workspace, _cx| {
5017            workspace.set_random_database_id();
5018        });
5019        workspace_b.update(cx, |workspace, _cx| {
5020            workspace.set_random_database_id();
5021        });
5022
5023        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5024
5025        // Set up workspace A: with an active thread.
5026        let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
5027            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5028        });
5029
5030        panel_a.update_in(cx, |panel, window, cx| {
5031            panel.open_external_thread_with_server(
5032                Rc::new(StubAgentServer::default_response()),
5033                window,
5034                cx,
5035            );
5036        });
5037
5038        cx.run_until_parked();
5039
5040        panel_a.read_with(cx, |panel, cx| {
5041            assert!(
5042                panel.active_agent_thread(cx).is_some(),
5043                "workspace A should have an active thread after connection"
5044            );
5045        });
5046
5047        send_message(&panel_a, cx);
5048
5049        let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
5050
5051        // Set up workspace B: ClaudeCode, no active thread.
5052        let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
5053            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5054        });
5055
5056        panel_b.update(cx, |panel, _cx| {
5057            panel.selected_agent = Agent::Custom {
5058                id: "claude-acp".into(),
5059            };
5060        });
5061
5062        // Serialize both panels.
5063        panel_a.update(cx, |panel, cx| panel.serialize(cx));
5064        panel_b.update(cx, |panel, cx| panel.serialize(cx));
5065        cx.run_until_parked();
5066
5067        // Load fresh panels for each workspace and verify independent state.
5068        let async_cx = cx.update(|window, cx| window.to_async(cx));
5069        let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
5070            .await
5071            .expect("panel A load should succeed");
5072        cx.run_until_parked();
5073
5074        let async_cx = cx.update(|window, cx| window.to_async(cx));
5075        let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
5076            .await
5077            .expect("panel B load should succeed");
5078        cx.run_until_parked();
5079
5080        // Workspace A should restore its thread and agent type
5081        loaded_a.read_with(cx, |panel, _cx| {
5082            assert_eq!(
5083                panel.selected_agent, agent_type_a,
5084                "workspace A agent type should be restored"
5085            );
5086            assert!(
5087                panel.active_conversation_view().is_some(),
5088                "workspace A should have its active thread restored"
5089            );
5090        });
5091
5092        // Workspace B should restore its own agent type, with no thread
5093        loaded_b.read_with(cx, |panel, _cx| {
5094            assert_eq!(
5095                panel.selected_agent,
5096                Agent::Custom {
5097                    id: "claude-acp".into()
5098                },
5099                "workspace B agent type should be restored"
5100            );
5101            assert!(
5102                panel.active_conversation_view().is_none(),
5103                "workspace B should have no active thread"
5104            );
5105        });
5106    }
5107
5108    #[gpui::test]
5109    async fn test_non_native_thread_without_metadata_is_not_restored(cx: &mut TestAppContext) {
5110        init_test(cx);
5111        cx.update(|cx| {
5112            agent::ThreadStore::init_global(cx);
5113            language_model::LanguageModelRegistry::test(cx);
5114        });
5115
5116        let fs = FakeFs::new(cx.executor());
5117        let project = Project::test(fs, [], cx).await;
5118
5119        let multi_workspace =
5120            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5121
5122        let workspace = multi_workspace
5123            .read_with(cx, |multi_workspace, _cx| {
5124                multi_workspace.workspace().clone()
5125            })
5126            .unwrap();
5127
5128        workspace.update(cx, |workspace, _cx| {
5129            workspace.set_random_database_id();
5130        });
5131
5132        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5133
5134        let panel = workspace.update_in(cx, |workspace, window, cx| {
5135            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5136        });
5137
5138        panel.update_in(cx, |panel, window, cx| {
5139            panel.open_external_thread_with_server(
5140                Rc::new(StubAgentServer::default_response()),
5141                window,
5142                cx,
5143            );
5144        });
5145
5146        cx.run_until_parked();
5147
5148        panel.read_with(cx, |panel, cx| {
5149            assert!(
5150                panel.active_agent_thread(cx).is_some(),
5151                "should have an active thread after connection"
5152            );
5153        });
5154
5155        // Serialize without ever sending a message, so no thread metadata exists.
5156        panel.update(cx, |panel, cx| panel.serialize(cx));
5157        cx.run_until_parked();
5158
5159        let async_cx = cx.update(|window, cx| window.to_async(cx));
5160        let loaded = AgentPanel::load(workspace.downgrade(), async_cx)
5161            .await
5162            .expect("panel load should succeed");
5163        cx.run_until_parked();
5164
5165        loaded.read_with(cx, |panel, _cx| {
5166            assert!(
5167                panel.active_conversation_view().is_none(),
5168                "thread without metadata should not be restored"
5169            );
5170        });
5171    }
5172
5173    /// Extracts the text from a Text content block, panicking if it's not Text.
5174    fn expect_text_block(block: &acp::ContentBlock) -> &str {
5175        match block {
5176            acp::ContentBlock::Text(t) => t.text.as_str(),
5177            other => panic!("expected Text block, got {:?}", other),
5178        }
5179    }
5180
5181    /// Extracts the (text_content, uri) from a Resource content block, panicking
5182    /// if it's not a TextResourceContents resource.
5183    fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
5184        match block {
5185            acp::ContentBlock::Resource(r) => match &r.resource {
5186                acp::EmbeddedResourceResource::TextResourceContents(t) => {
5187                    (t.text.as_str(), t.uri.as_str())
5188                }
5189                other => panic!("expected TextResourceContents, got {:?}", other),
5190            },
5191            other => panic!("expected Resource block, got {:?}", other),
5192        }
5193    }
5194
5195    #[test]
5196    fn test_build_conflict_resolution_prompt_single_conflict() {
5197        let conflicts = vec![ConflictContent {
5198            file_path: "src/main.rs".to_string(),
5199            conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
5200                .to_string(),
5201            ours_branch_name: "HEAD".to_string(),
5202            theirs_branch_name: "feature".to_string(),
5203        }];
5204
5205        let blocks = build_conflict_resolution_prompt(&conflicts);
5206        // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
5207        assert_eq!(
5208            blocks.len(),
5209            4,
5210            "expected 2 text + 1 resource link + 1 resource block"
5211        );
5212
5213        let intro_text = expect_text_block(&blocks[0]);
5214        assert!(
5215            intro_text.contains("Please resolve the following merge conflict in"),
5216            "prompt should include single-conflict intro text"
5217        );
5218
5219        match &blocks[1] {
5220            acp::ContentBlock::ResourceLink(link) => {
5221                assert!(
5222                    link.uri.contains("file://"),
5223                    "resource link URI should use file scheme"
5224                );
5225                assert!(
5226                    link.uri.contains("main.rs"),
5227                    "resource link URI should reference file path"
5228                );
5229            }
5230            other => panic!("expected ResourceLink block, got {:?}", other),
5231        }
5232
5233        let body_text = expect_text_block(&blocks[2]);
5234        assert!(
5235            body_text.contains("`HEAD` (ours)"),
5236            "prompt should mention ours branch"
5237        );
5238        assert!(
5239            body_text.contains("`feature` (theirs)"),
5240            "prompt should mention theirs branch"
5241        );
5242        assert!(
5243            body_text.contains("editing the file directly"),
5244            "prompt should instruct the agent to edit the file"
5245        );
5246
5247        let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
5248        assert!(
5249            resource_text.contains("<<<<<<< HEAD"),
5250            "resource should contain the conflict text"
5251        );
5252        assert!(
5253            resource_uri.contains("merge-conflict"),
5254            "resource URI should use the merge-conflict scheme"
5255        );
5256        assert!(
5257            resource_uri.contains("main.rs"),
5258            "resource URI should reference the file path"
5259        );
5260    }
5261
5262    #[test]
5263    fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
5264        let conflicts = vec![
5265            ConflictContent {
5266                file_path: "src/lib.rs".to_string(),
5267                conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
5268                    .to_string(),
5269                ours_branch_name: "main".to_string(),
5270                theirs_branch_name: "dev".to_string(),
5271            },
5272            ConflictContent {
5273                file_path: "src/lib.rs".to_string(),
5274                conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
5275                    .to_string(),
5276                ours_branch_name: "main".to_string(),
5277                theirs_branch_name: "dev".to_string(),
5278            },
5279        ];
5280
5281        let blocks = build_conflict_resolution_prompt(&conflicts);
5282        // 1 Text instruction + 2 Resource blocks
5283        assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5284
5285        let text = expect_text_block(&blocks[0]);
5286        assert!(
5287            text.contains("all 2 merge conflicts"),
5288            "prompt should mention the total count"
5289        );
5290        assert!(
5291            text.contains("`main` (ours)"),
5292            "prompt should mention ours branch"
5293        );
5294        assert!(
5295            text.contains("`dev` (theirs)"),
5296            "prompt should mention theirs branch"
5297        );
5298        // Single file, so "file" not "files"
5299        assert!(
5300            text.contains("file directly"),
5301            "single file should use singular 'file'"
5302        );
5303
5304        let (resource_a, _) = expect_resource_block(&blocks[1]);
5305        let (resource_b, _) = expect_resource_block(&blocks[2]);
5306        assert!(
5307            resource_a.contains("fn a()"),
5308            "first resource should contain first conflict"
5309        );
5310        assert!(
5311            resource_b.contains("fn b()"),
5312            "second resource should contain second conflict"
5313        );
5314    }
5315
5316    #[test]
5317    fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
5318        let conflicts = vec![
5319            ConflictContent {
5320                file_path: "src/a.rs".to_string(),
5321                conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
5322                ours_branch_name: "main".to_string(),
5323                theirs_branch_name: "dev".to_string(),
5324            },
5325            ConflictContent {
5326                file_path: "src/b.rs".to_string(),
5327                conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
5328                ours_branch_name: "main".to_string(),
5329                theirs_branch_name: "dev".to_string(),
5330            },
5331        ];
5332
5333        let blocks = build_conflict_resolution_prompt(&conflicts);
5334        // 1 Text instruction + 2 Resource blocks
5335        assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5336
5337        let text = expect_text_block(&blocks[0]);
5338        assert!(
5339            text.contains("files directly"),
5340            "multiple files should use plural 'files'"
5341        );
5342
5343        let (_, uri_a) = expect_resource_block(&blocks[1]);
5344        let (_, uri_b) = expect_resource_block(&blocks[2]);
5345        assert!(
5346            uri_a.contains("a.rs"),
5347            "first resource URI should reference a.rs"
5348        );
5349        assert!(
5350            uri_b.contains("b.rs"),
5351            "second resource URI should reference b.rs"
5352        );
5353    }
5354
5355    #[test]
5356    fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
5357        let file_paths = vec![
5358            "src/main.rs".to_string(),
5359            "src/lib.rs".to_string(),
5360            "tests/integration.rs".to_string(),
5361        ];
5362
5363        let blocks = build_conflicted_files_resolution_prompt(&file_paths);
5364        // 1 instruction Text block + (ResourceLink + newline Text) per file
5365        assert_eq!(
5366            blocks.len(),
5367            1 + (file_paths.len() * 2),
5368            "expected instruction text plus resource links and separators"
5369        );
5370
5371        let text = expect_text_block(&blocks[0]);
5372        assert!(
5373            text.contains("unresolved merge conflicts"),
5374            "prompt should describe the task"
5375        );
5376        assert!(
5377            text.contains("conflict markers"),
5378            "prompt should mention conflict markers"
5379        );
5380
5381        for (index, path) in file_paths.iter().enumerate() {
5382            let link_index = 1 + (index * 2);
5383            let newline_index = link_index + 1;
5384
5385            match &blocks[link_index] {
5386                acp::ContentBlock::ResourceLink(link) => {
5387                    assert!(
5388                        link.uri.contains("file://"),
5389                        "resource link URI should use file scheme"
5390                    );
5391                    assert!(
5392                        link.uri.contains(path),
5393                        "resource link URI should reference file path: {path}"
5394                    );
5395                }
5396                other => panic!(
5397                    "expected ResourceLink block at index {}, got {:?}",
5398                    link_index, other
5399                ),
5400            }
5401
5402            let separator = expect_text_block(&blocks[newline_index]);
5403            assert_eq!(
5404                separator, "\n",
5405                "expected newline separator after each file"
5406            );
5407        }
5408    }
5409
5410    #[test]
5411    fn test_build_conflict_resolution_prompt_empty_conflicts() {
5412        let blocks = build_conflict_resolution_prompt(&[]);
5413        assert!(
5414            blocks.is_empty(),
5415            "empty conflicts should produce no blocks, got {} blocks",
5416            blocks.len()
5417        );
5418    }
5419
5420    #[test]
5421    fn test_build_conflicted_files_resolution_prompt_empty_paths() {
5422        let blocks = build_conflicted_files_resolution_prompt(&[]);
5423        assert!(
5424            blocks.is_empty(),
5425            "empty paths should produce no blocks, got {} blocks",
5426            blocks.len()
5427        );
5428    }
5429
5430    #[test]
5431    fn test_conflict_resource_block_structure() {
5432        let conflict = ConflictContent {
5433            file_path: "src/utils.rs".to_string(),
5434            conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
5435            ours_branch_name: "HEAD".to_string(),
5436            theirs_branch_name: "branch".to_string(),
5437        };
5438
5439        let block = conflict_resource_block(&conflict);
5440        let (text, uri) = expect_resource_block(&block);
5441
5442        assert_eq!(
5443            text, conflict.conflict_text,
5444            "resource text should be the raw conflict"
5445        );
5446        assert!(
5447            uri.starts_with("zed:///agent/merge-conflict"),
5448            "URI should use the zed merge-conflict scheme, got: {uri}"
5449        );
5450        assert!(uri.contains("utils.rs"), "URI should encode the file path");
5451    }
5452
5453    fn open_generating_thread_with_loadable_connection(
5454        panel: &Entity<AgentPanel>,
5455        connection: &StubAgentConnection,
5456        cx: &mut VisualTestContext,
5457    ) -> acp::SessionId {
5458        open_thread_with_custom_connection(panel, connection.clone(), cx);
5459        let session_id = active_session_id(panel, cx);
5460        send_message(panel, cx);
5461        cx.update(|_, cx| {
5462            connection.send_update(
5463                session_id.clone(),
5464                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
5465                cx,
5466            );
5467        });
5468        cx.run_until_parked();
5469        session_id
5470    }
5471
5472    fn open_idle_thread_with_non_loadable_connection(
5473        panel: &Entity<AgentPanel>,
5474        connection: &StubAgentConnection,
5475        cx: &mut VisualTestContext,
5476    ) -> acp::SessionId {
5477        open_thread_with_custom_connection(panel, connection.clone(), cx);
5478        let session_id = active_session_id(panel, cx);
5479
5480        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5481            acp::ContentChunk::new("done".into()),
5482        )]);
5483        send_message(panel, cx);
5484
5485        session_id
5486    }
5487
5488    async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
5489        init_test(cx);
5490        cx.update(|cx| {
5491            agent::ThreadStore::init_global(cx);
5492            language_model::LanguageModelRegistry::test(cx);
5493        });
5494
5495        let fs = FakeFs::new(cx.executor());
5496        let project = Project::test(fs.clone(), [], cx).await;
5497
5498        let multi_workspace =
5499            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5500
5501        let workspace = multi_workspace
5502            .read_with(cx, |mw, _cx| mw.workspace().clone())
5503            .unwrap();
5504
5505        let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
5506
5507        let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
5508            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5509        });
5510
5511        (panel, cx)
5512    }
5513
5514    #[gpui::test]
5515    async fn test_empty_draft_thread_not_retained_when_navigating_away(cx: &mut TestAppContext) {
5516        let (panel, mut cx) = setup_panel(cx).await;
5517
5518        let connection_a = StubAgentConnection::new();
5519        open_thread_with_connection(&panel, connection_a, &mut cx);
5520        let session_id_a = active_session_id(&panel, &cx);
5521
5522        panel.read_with(&cx, |panel, cx| {
5523            let thread = panel.active_agent_thread(cx).unwrap();
5524            assert!(
5525                thread.read(cx).entries().is_empty(),
5526                "newly opened draft thread should have no entries"
5527            );
5528            assert!(panel.background_threads.is_empty());
5529        });
5530
5531        let connection_b = StubAgentConnection::new();
5532        open_thread_with_connection(&panel, connection_b, &mut cx);
5533
5534        panel.read_with(&cx, |panel, _cx| {
5535            assert!(
5536                panel.background_threads.is_empty(),
5537                "empty draft thread should not be retained in background_threads"
5538            );
5539            assert!(
5540                !panel.background_threads.contains_key(&session_id_a),
5541                "empty draft thread should not be keyed in background_threads"
5542            );
5543        });
5544    }
5545
5546    #[gpui::test]
5547    async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5548        let (panel, mut cx) = setup_panel(cx).await;
5549
5550        let connection_a = StubAgentConnection::new();
5551        open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5552        send_message(&panel, &mut cx);
5553
5554        let session_id_a = active_session_id(&panel, &cx);
5555
5556        // Send a chunk to keep thread A generating (don't end the turn).
5557        cx.update(|_, cx| {
5558            connection_a.send_update(
5559                session_id_a.clone(),
5560                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5561                cx,
5562            );
5563        });
5564        cx.run_until_parked();
5565
5566        // Verify thread A is generating.
5567        panel.read_with(&cx, |panel, cx| {
5568            let thread = panel.active_agent_thread(cx).unwrap();
5569            assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
5570            assert!(panel.background_threads.is_empty());
5571        });
5572
5573        // Open a new thread B — thread A should be retained in background.
5574        let connection_b = StubAgentConnection::new();
5575        open_thread_with_connection(&panel, connection_b, &mut cx);
5576
5577        panel.read_with(&cx, |panel, _cx| {
5578            assert_eq!(
5579                panel.background_threads.len(),
5580                1,
5581                "Running thread A should be retained in background_views"
5582            );
5583            assert!(
5584                panel.background_threads.contains_key(&session_id_a),
5585                "Background view should be keyed by thread A's session ID"
5586            );
5587        });
5588    }
5589
5590    #[gpui::test]
5591    async fn test_idle_non_loadable_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5592        let (panel, mut cx) = setup_panel(cx).await;
5593
5594        let connection_a = StubAgentConnection::new();
5595        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5596            acp::ContentChunk::new("Response".into()),
5597        )]);
5598        open_thread_with_connection(&panel, connection_a, &mut cx);
5599        send_message(&panel, &mut cx);
5600
5601        let weak_view_a = panel.read_with(&cx, |panel, _cx| {
5602            panel.active_conversation_view().unwrap().downgrade()
5603        });
5604        let session_id_a = active_session_id(&panel, &cx);
5605
5606        // Thread A should be idle (auto-completed via set_next_prompt_updates).
5607        panel.read_with(&cx, |panel, cx| {
5608            let thread = panel.active_agent_thread(cx).unwrap();
5609            assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
5610        });
5611
5612        // Open a new thread B — thread A should be retained because it is not loadable.
5613        let connection_b = StubAgentConnection::new();
5614        open_thread_with_connection(&panel, connection_b, &mut cx);
5615
5616        panel.read_with(&cx, |panel, _cx| {
5617            assert_eq!(
5618                panel.background_threads.len(),
5619                1,
5620                "Idle non-loadable thread A should be retained in background_views"
5621            );
5622            assert!(
5623                panel.background_threads.contains_key(&session_id_a),
5624                "Background view should be keyed by thread A's session ID"
5625            );
5626        });
5627
5628        assert!(
5629            weak_view_a.upgrade().is_some(),
5630            "Idle non-loadable ConnectionView should still be retained"
5631        );
5632    }
5633
5634    #[gpui::test]
5635    async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
5636        let (panel, mut cx) = setup_panel(cx).await;
5637
5638        let connection_a = StubAgentConnection::new();
5639        open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5640        send_message(&panel, &mut cx);
5641
5642        let session_id_a = active_session_id(&panel, &cx);
5643
5644        // Keep thread A generating.
5645        cx.update(|_, cx| {
5646            connection_a.send_update(
5647                session_id_a.clone(),
5648                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5649                cx,
5650            );
5651        });
5652        cx.run_until_parked();
5653
5654        // Open thread B — thread A goes to background.
5655        let connection_b = StubAgentConnection::new();
5656        open_thread_with_connection(&panel, connection_b, &mut cx);
5657        send_message(&panel, &mut cx);
5658
5659        let session_id_b = active_session_id(&panel, &cx);
5660
5661        panel.read_with(&cx, |panel, _cx| {
5662            assert_eq!(panel.background_threads.len(), 1);
5663            assert!(panel.background_threads.contains_key(&session_id_a));
5664        });
5665
5666        // Load thread A back via load_agent_thread — should promote from background.
5667        panel.update_in(&mut cx, |panel, window, cx| {
5668            panel.load_agent_thread(
5669                panel.selected_agent().expect("selected agent must be set"),
5670                session_id_a.clone(),
5671                None,
5672                None,
5673                true,
5674                window,
5675                cx,
5676            );
5677        });
5678
5679        // Thread A should now be the active view, promoted from background.
5680        let active_session = active_session_id(&panel, &cx);
5681        assert_eq!(
5682            active_session, session_id_a,
5683            "Thread A should be the active thread after promotion"
5684        );
5685
5686        panel.read_with(&cx, |panel, _cx| {
5687            assert!(
5688                !panel.background_threads.contains_key(&session_id_a),
5689                "Promoted thread A should no longer be in background_views"
5690            );
5691            assert!(
5692                panel.background_threads.contains_key(&session_id_b),
5693                "Thread B (idle, non-loadable) should remain retained in background_views"
5694            );
5695        });
5696    }
5697
5698    #[gpui::test]
5699    async fn test_cleanup_background_threads_keeps_five_most_recent_idle_loadable_threads(
5700        cx: &mut TestAppContext,
5701    ) {
5702        let (panel, mut cx) = setup_panel(cx).await;
5703        let connection = StubAgentConnection::new()
5704            .with_supports_load_session(true)
5705            .with_agent_id("loadable-stub".into())
5706            .with_telemetry_id("loadable-stub".into());
5707        let mut session_ids = Vec::new();
5708
5709        for _ in 0..7 {
5710            session_ids.push(open_generating_thread_with_loadable_connection(
5711                &panel,
5712                &connection,
5713                &mut cx,
5714            ));
5715        }
5716
5717        let base_time = Instant::now();
5718
5719        for session_id in session_ids.iter().take(6) {
5720            connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5721        }
5722        cx.run_until_parked();
5723
5724        panel.update(&mut cx, |panel, cx| {
5725            for (index, session_id) in session_ids.iter().take(6).enumerate() {
5726                let conversation_view = panel
5727                    .background_threads
5728                    .get(session_id)
5729                    .expect("background thread should exist")
5730                    .clone();
5731                conversation_view.update(cx, |view, cx| {
5732                    view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5733                });
5734            }
5735            panel.cleanup_background_threads(cx);
5736        });
5737
5738        panel.read_with(&cx, |panel, _cx| {
5739            assert_eq!(
5740                panel.background_threads.len(),
5741                5,
5742                "cleanup should keep at most five idle loadable background threads"
5743            );
5744            assert!(
5745                !panel.background_threads.contains_key(&session_ids[0]),
5746                "oldest idle loadable background thread should be removed"
5747            );
5748            for session_id in &session_ids[1..6] {
5749                assert!(
5750                    panel.background_threads.contains_key(session_id),
5751                    "more recent idle loadable background threads should be retained"
5752                );
5753            }
5754            assert!(
5755                !panel.background_threads.contains_key(&session_ids[6]),
5756                "the active thread should not also be stored as a background thread"
5757            );
5758        });
5759    }
5760
5761    #[gpui::test]
5762    async fn test_cleanup_background_threads_preserves_idle_non_loadable_threads(
5763        cx: &mut TestAppContext,
5764    ) {
5765        let (panel, mut cx) = setup_panel(cx).await;
5766
5767        let non_loadable_connection = StubAgentConnection::new();
5768        let non_loadable_session_id = open_idle_thread_with_non_loadable_connection(
5769            &panel,
5770            &non_loadable_connection,
5771            &mut cx,
5772        );
5773
5774        let loadable_connection = StubAgentConnection::new()
5775            .with_supports_load_session(true)
5776            .with_agent_id("loadable-stub".into())
5777            .with_telemetry_id("loadable-stub".into());
5778        let mut loadable_session_ids = Vec::new();
5779
5780        for _ in 0..7 {
5781            loadable_session_ids.push(open_generating_thread_with_loadable_connection(
5782                &panel,
5783                &loadable_connection,
5784                &mut cx,
5785            ));
5786        }
5787
5788        let base_time = Instant::now();
5789
5790        for session_id in loadable_session_ids.iter().take(6) {
5791            loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5792        }
5793        cx.run_until_parked();
5794
5795        panel.update(&mut cx, |panel, cx| {
5796            for (index, session_id) in loadable_session_ids.iter().take(6).enumerate() {
5797                let conversation_view = panel
5798                    .background_threads
5799                    .get(session_id)
5800                    .expect("background thread should exist")
5801                    .clone();
5802                conversation_view.update(cx, |view, cx| {
5803                    view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5804                });
5805            }
5806            panel.cleanup_background_threads(cx);
5807        });
5808
5809        panel.read_with(&cx, |panel, _cx| {
5810            assert_eq!(
5811                panel.background_threads.len(),
5812                6,
5813                "cleanup should keep the non-loadable idle thread in addition to five loadable ones"
5814            );
5815            assert!(
5816                panel
5817                    .background_threads
5818                    .contains_key(&non_loadable_session_id),
5819                "idle non-loadable background threads should not be cleanup candidates"
5820            );
5821            assert!(
5822                !panel
5823                    .background_threads
5824                    .contains_key(&loadable_session_ids[0]),
5825                "oldest idle loadable background thread should still be removed"
5826            );
5827            for session_id in &loadable_session_ids[1..6] {
5828                assert!(
5829                    panel.background_threads.contains_key(session_id),
5830                    "more recent idle loadable background threads should be retained"
5831                );
5832            }
5833            assert!(
5834                !panel
5835                    .background_threads
5836                    .contains_key(&loadable_session_ids[6]),
5837                "the active loadable thread should not also be stored as a background thread"
5838            );
5839        });
5840    }
5841
5842    #[gpui::test]
5843    async fn test_thread_target_local_project(cx: &mut TestAppContext) {
5844        init_test(cx);
5845        cx.update(|cx| {
5846            agent::ThreadStore::init_global(cx);
5847            language_model::LanguageModelRegistry::test(cx);
5848        });
5849
5850        let fs = FakeFs::new(cx.executor());
5851        fs.insert_tree(
5852            "/project",
5853            json!({
5854                ".git": {},
5855                "src": {
5856                    "main.rs": "fn main() {}"
5857                }
5858            }),
5859        )
5860        .await;
5861        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5862
5863        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5864
5865        let multi_workspace =
5866            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5867
5868        let workspace = multi_workspace
5869            .read_with(cx, |multi_workspace, _cx| {
5870                multi_workspace.workspace().clone()
5871            })
5872            .unwrap();
5873
5874        workspace.update(cx, |workspace, _cx| {
5875            workspace.set_random_database_id();
5876        });
5877
5878        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5879
5880        // Wait for the project to discover the git repository.
5881        cx.run_until_parked();
5882
5883        let panel = workspace.update_in(cx, |workspace, window, cx| {
5884            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5885            workspace.add_panel(panel.clone(), window, cx);
5886            panel
5887        });
5888
5889        cx.run_until_parked();
5890
5891        // Default thread target should be LocalProject.
5892        panel.read_with(cx, |panel, _cx| {
5893            assert_eq!(
5894                *panel.start_thread_in(),
5895                StartThreadIn::LocalProject,
5896                "default thread target should be LocalProject"
5897            );
5898        });
5899
5900        // Start a new thread with the default LocalProject target.
5901        // Use StubAgentServer so the thread connects immediately in tests.
5902        panel.update_in(cx, |panel, window, cx| {
5903            panel.open_external_thread_with_server(
5904                Rc::new(StubAgentServer::default_response()),
5905                window,
5906                cx,
5907            );
5908        });
5909
5910        cx.run_until_parked();
5911
5912        // MultiWorkspace should still have exactly one workspace (no worktree created).
5913        multi_workspace
5914            .read_with(cx, |multi_workspace, _cx| {
5915                assert_eq!(
5916                    multi_workspace.workspaces().count(),
5917                    1,
5918                    "LocalProject should not create a new workspace"
5919                );
5920            })
5921            .unwrap();
5922
5923        // The thread should be active in the panel.
5924        panel.read_with(cx, |panel, cx| {
5925            assert!(
5926                panel.active_agent_thread(cx).is_some(),
5927                "a thread should be running in the current workspace"
5928            );
5929        });
5930
5931        // The thread target should still be LocalProject (unchanged).
5932        panel.read_with(cx, |panel, _cx| {
5933            assert_eq!(
5934                *panel.start_thread_in(),
5935                StartThreadIn::LocalProject,
5936                "thread target should remain LocalProject"
5937            );
5938        });
5939
5940        // No worktree creation status should be set.
5941        panel.read_with(cx, |panel, _cx| {
5942            assert!(
5943                panel.worktree_creation_status.is_none(),
5944                "no worktree creation should have occurred"
5945            );
5946        });
5947    }
5948
5949    #[gpui::test]
5950    async fn test_thread_target_does_not_sync_to_external_linked_worktree_with_invalid_branch_target(
5951        cx: &mut TestAppContext,
5952    ) {
5953        use git::repository::Worktree as GitWorktree;
5954
5955        init_test(cx);
5956        cx.update(|cx| {
5957            agent::ThreadStore::init_global(cx);
5958            language_model::LanguageModelRegistry::test(cx);
5959        });
5960
5961        let fs = FakeFs::new(cx.executor());
5962        fs.insert_tree(
5963            "/project",
5964            json!({
5965                ".git": {},
5966                "src": {
5967                    "main.rs": "fn main() {}"
5968                }
5969            }),
5970        )
5971        .await;
5972        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5973        fs.insert_branches(Path::new("/project/.git"), &["main", "feature-worktree"]);
5974
5975        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5976
5977        let multi_workspace =
5978            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5979
5980        let workspace = multi_workspace
5981            .read_with(cx, |multi_workspace, _cx| {
5982                multi_workspace.workspace().clone()
5983            })
5984            .unwrap();
5985
5986        workspace.update(cx, |workspace, _cx| {
5987            workspace.set_random_database_id();
5988        });
5989
5990        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5991
5992        cx.run_until_parked();
5993
5994        let panel = workspace.update_in(cx, |workspace, window, cx| {
5995            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5996            workspace.add_panel(panel.clone(), window, cx);
5997            panel
5998        });
5999
6000        cx.run_until_parked();
6001
6002        panel.update_in(cx, |panel, window, cx| {
6003            panel.set_start_thread_in(
6004                &StartThreadIn::NewWorktree {
6005                    worktree_name: Some("feature worktree".to_string()),
6006                    branch_target: NewWorktreeBranchTarget::CurrentBranch,
6007                },
6008                window,
6009                cx,
6010            );
6011        });
6012
6013        fs.add_linked_worktree_for_repo(
6014            Path::new("/project/.git"),
6015            true,
6016            GitWorktree {
6017                path: PathBuf::from("/linked-feature-worktree"),
6018                ref_name: Some("refs/heads/feature-worktree".into()),
6019                sha: "abcdef1".into(),
6020                is_main: false,
6021            },
6022        )
6023        .await;
6024
6025        project
6026            .update(cx, |project, cx| project.git_scans_complete(cx))
6027            .await;
6028        cx.run_until_parked();
6029
6030        panel.read_with(cx, |panel, _cx| {
6031            assert_eq!(
6032                *panel.start_thread_in(),
6033                StartThreadIn::NewWorktree {
6034                    worktree_name: Some("feature worktree".to_string()),
6035                    branch_target: NewWorktreeBranchTarget::CurrentBranch,
6036                },
6037                "thread target should remain a named new worktree when the external linked worktree does not match the selected branch target",
6038            );
6039        });
6040    }
6041
6042    #[gpui::test]
6043    async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
6044        init_test(cx);
6045        cx.update(|cx| {
6046            agent::ThreadStore::init_global(cx);
6047            language_model::LanguageModelRegistry::test(cx);
6048        });
6049
6050        let fs = FakeFs::new(cx.executor());
6051        fs.insert_tree(
6052            "/project",
6053            json!({
6054                ".git": {},
6055                "src": {
6056                    "main.rs": "fn main() {}"
6057                }
6058            }),
6059        )
6060        .await;
6061        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6062
6063        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6064
6065        let multi_workspace =
6066            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6067
6068        let workspace = multi_workspace
6069            .read_with(cx, |multi_workspace, _cx| {
6070                multi_workspace.workspace().clone()
6071            })
6072            .unwrap();
6073
6074        workspace.update(cx, |workspace, _cx| {
6075            workspace.set_random_database_id();
6076        });
6077
6078        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6079
6080        // Wait for the project to discover the git repository.
6081        cx.run_until_parked();
6082
6083        let panel = workspace.update_in(cx, |workspace, window, cx| {
6084            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6085            workspace.add_panel(panel.clone(), window, cx);
6086            panel
6087        });
6088
6089        cx.run_until_parked();
6090
6091        // Default should be LocalProject.
6092        panel.read_with(cx, |panel, _cx| {
6093            assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
6094        });
6095
6096        // Change thread target to NewWorktree.
6097        panel.update_in(cx, |panel, window, cx| {
6098            panel.set_start_thread_in(
6099                &StartThreadIn::NewWorktree {
6100                    worktree_name: None,
6101                    branch_target: NewWorktreeBranchTarget::default(),
6102                },
6103                window,
6104                cx,
6105            );
6106        });
6107
6108        panel.read_with(cx, |panel, _cx| {
6109            assert_eq!(
6110                *panel.start_thread_in(),
6111                StartThreadIn::NewWorktree {
6112                    worktree_name: None,
6113                    branch_target: NewWorktreeBranchTarget::default(),
6114                },
6115                "thread target should be NewWorktree after set_thread_target"
6116            );
6117        });
6118
6119        // Let serialization complete.
6120        cx.run_until_parked();
6121
6122        // Load a fresh panel from the serialized data.
6123        let async_cx = cx.update(|window, cx| window.to_async(cx));
6124        let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
6125            .await
6126            .expect("panel load should succeed");
6127        cx.run_until_parked();
6128
6129        loaded_panel.read_with(cx, |panel, _cx| {
6130            assert_eq!(
6131                *panel.start_thread_in(),
6132                StartThreadIn::NewWorktree {
6133                    worktree_name: None,
6134                    branch_target: NewWorktreeBranchTarget::default(),
6135                },
6136                "thread target should survive serialization round-trip"
6137            );
6138        });
6139    }
6140
6141    #[gpui::test]
6142    async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
6143        init_test(cx);
6144
6145        let fs = FakeFs::new(cx.executor());
6146        cx.update(|cx| {
6147            agent::ThreadStore::init_global(cx);
6148            language_model::LanguageModelRegistry::test(cx);
6149            <dyn fs::Fs>::set_global(fs.clone(), cx);
6150        });
6151
6152        fs.insert_tree(
6153            "/project",
6154            json!({
6155                ".git": {},
6156                "src": {
6157                    "main.rs": "fn main() {}"
6158                }
6159            }),
6160        )
6161        .await;
6162
6163        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6164
6165        let multi_workspace =
6166            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6167
6168        let workspace = multi_workspace
6169            .read_with(cx, |multi_workspace, _cx| {
6170                multi_workspace.workspace().clone()
6171            })
6172            .unwrap();
6173
6174        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6175
6176        let panel = workspace.update_in(cx, |workspace, window, cx| {
6177            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6178            workspace.add_panel(panel.clone(), window, cx);
6179            panel
6180        });
6181
6182        cx.run_until_parked();
6183
6184        // Simulate worktree creation in progress and reset to Uninitialized
6185        panel.update_in(cx, |panel, window, cx| {
6186            panel.worktree_creation_status =
6187                Some((EntityId::from(0u64), WorktreeCreationStatus::Creating));
6188            panel.active_view = ActiveView::Uninitialized;
6189            Panel::set_active(panel, true, window, cx);
6190            assert!(
6191                matches!(panel.active_view, ActiveView::Uninitialized),
6192                "set_active should not create a thread while worktree is being created"
6193            );
6194        });
6195
6196        // Clear the creation status and use open_external_thread_with_server
6197        // (which bypasses new_agent_thread) to verify the panel can transition
6198        // out of Uninitialized. We can't call set_active directly because
6199        // new_agent_thread requires full agent server infrastructure.
6200        panel.update_in(cx, |panel, window, cx| {
6201            panel.worktree_creation_status = None;
6202            panel.active_view = ActiveView::Uninitialized;
6203            panel.open_external_thread_with_server(
6204                Rc::new(StubAgentServer::default_response()),
6205                window,
6206                cx,
6207            );
6208        });
6209
6210        cx.run_until_parked();
6211
6212        panel.read_with(cx, |panel, _cx| {
6213            assert!(
6214                !matches!(panel.active_view, ActiveView::Uninitialized),
6215                "panel should transition out of Uninitialized once worktree creation is cleared"
6216            );
6217        });
6218    }
6219
6220    #[test]
6221    fn test_deserialize_agent_variants() {
6222        // PascalCase (legacy AgentType format, persisted in panel state)
6223        assert_eq!(
6224            serde_json::from_str::<Agent>(r#""NativeAgent""#).unwrap(),
6225            Agent::NativeAgent,
6226        );
6227        assert_eq!(
6228            serde_json::from_str::<Agent>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
6229            Agent::Custom {
6230                id: "my-agent".into(),
6231            },
6232        );
6233
6234        // Legacy TextThread variant deserializes to NativeAgent
6235        assert_eq!(
6236            serde_json::from_str::<Agent>(r#""TextThread""#).unwrap(),
6237            Agent::NativeAgent,
6238        );
6239
6240        // snake_case (canonical format)
6241        assert_eq!(
6242            serde_json::from_str::<Agent>(r#""native_agent""#).unwrap(),
6243            Agent::NativeAgent,
6244        );
6245        assert_eq!(
6246            serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
6247            Agent::Custom {
6248                id: "my-agent".into(),
6249            },
6250        );
6251
6252        // Serialization uses snake_case
6253        assert_eq!(
6254            serde_json::to_string(&Agent::NativeAgent).unwrap(),
6255            r#""native_agent""#,
6256        );
6257        assert_eq!(
6258            serde_json::to_string(&Agent::Custom {
6259                id: "my-agent".into()
6260            })
6261            .unwrap(),
6262            r#"{"custom":{"name":"my-agent"}}"#,
6263        );
6264    }
6265
6266    #[test]
6267    fn test_resolve_worktree_branch_target() {
6268        let existing_branches = HashSet::from_iter([
6269            "main".to_string(),
6270            "feature".to_string(),
6271            "origin/main".to_string(),
6272        ]);
6273
6274        let resolved = AgentPanel::resolve_worktree_branch_target(
6275            &NewWorktreeBranchTarget::CreateBranch {
6276                name: "new-branch".to_string(),
6277                from_ref: Some("main".to_string()),
6278            },
6279            &existing_branches,
6280            &HashSet::from_iter(["main".to_string()]),
6281        )
6282        .unwrap();
6283        assert_eq!(
6284            resolved,
6285            ("new-branch".to_string(), false, Some("main".to_string()))
6286        );
6287
6288        let resolved = AgentPanel::resolve_worktree_branch_target(
6289            &NewWorktreeBranchTarget::ExistingBranch {
6290                name: "feature".to_string(),
6291            },
6292            &existing_branches,
6293            &HashSet::default(),
6294        )
6295        .unwrap();
6296        assert_eq!(resolved, ("feature".to_string(), true, None));
6297
6298        let resolved = AgentPanel::resolve_worktree_branch_target(
6299            &NewWorktreeBranchTarget::ExistingBranch {
6300                name: "main".to_string(),
6301            },
6302            &existing_branches,
6303            &HashSet::from_iter(["main".to_string()]),
6304        )
6305        .unwrap();
6306        assert_eq!(resolved.1, false);
6307        assert_eq!(resolved.2, Some("main".to_string()));
6308        assert_ne!(resolved.0, "main");
6309        assert!(existing_branches.contains("main"));
6310        assert!(!existing_branches.contains(&resolved.0));
6311    }
6312
6313    #[gpui::test]
6314    async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
6315        init_test(cx);
6316
6317        let app_state = cx.update(|cx| {
6318            agent::ThreadStore::init_global(cx);
6319            language_model::LanguageModelRegistry::test(cx);
6320
6321            let app_state = workspace::AppState::test(cx);
6322            workspace::init(app_state.clone(), cx);
6323            app_state
6324        });
6325
6326        let fs = app_state.fs.as_fake();
6327        fs.insert_tree(
6328            "/project",
6329            json!({
6330                ".git": {},
6331                "src": {
6332                    "main.rs": "fn main() {}"
6333                }
6334            }),
6335        )
6336        .await;
6337        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6338
6339        let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
6340
6341        let multi_workspace =
6342            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6343        multi_workspace
6344            .update(cx, |multi_workspace, _, cx| {
6345                multi_workspace.open_sidebar(cx);
6346            })
6347            .unwrap();
6348
6349        let workspace = multi_workspace
6350            .read_with(cx, |multi_workspace, _cx| {
6351                multi_workspace.workspace().clone()
6352            })
6353            .unwrap();
6354
6355        workspace.update(cx, |workspace, _cx| {
6356            workspace.set_random_database_id();
6357        });
6358
6359        // Register a callback so new workspaces also get an AgentPanel.
6360        cx.update(|cx| {
6361            cx.observe_new(
6362                |workspace: &mut Workspace,
6363                 window: Option<&mut Window>,
6364                 cx: &mut Context<Workspace>| {
6365                    if let Some(window) = window {
6366                        let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6367                        workspace.add_panel(panel, window, cx);
6368                    }
6369                },
6370            )
6371            .detach();
6372        });
6373
6374        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6375
6376        // Wait for the project to discover the git repository.
6377        cx.run_until_parked();
6378
6379        let panel = workspace.update_in(cx, |workspace, window, cx| {
6380            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6381            workspace.add_panel(panel.clone(), window, cx);
6382            panel
6383        });
6384
6385        cx.run_until_parked();
6386
6387        // Open a thread (needed so there's an active thread view).
6388        panel.update_in(cx, |panel, window, cx| {
6389            panel.open_external_thread_with_server(
6390                Rc::new(StubAgentServer::default_response()),
6391                window,
6392                cx,
6393            );
6394        });
6395
6396        cx.run_until_parked();
6397
6398        // Set the selected agent to Codex (a custom agent) and start_thread_in
6399        // to NewWorktree. We do this AFTER opening the thread because
6400        // open_external_thread_with_server overrides selected_agent.
6401        panel.update_in(cx, |panel, window, cx| {
6402            panel.selected_agent = Agent::Custom {
6403                id: CODEX_ID.into(),
6404            };
6405            panel.set_start_thread_in(
6406                &StartThreadIn::NewWorktree {
6407                    worktree_name: None,
6408                    branch_target: NewWorktreeBranchTarget::default(),
6409                },
6410                window,
6411                cx,
6412            );
6413        });
6414
6415        // Verify the panel has the Codex agent selected.
6416        panel.read_with(cx, |panel, _cx| {
6417            assert_eq!(
6418                panel.selected_agent,
6419                Agent::Custom {
6420                    id: CODEX_ID.into()
6421                },
6422            );
6423        });
6424
6425        // Directly call handle_worktree_creation_requested, which is what
6426        // handle_first_send_requested does when start_thread_in == NewWorktree.
6427        let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
6428            "Hello from test",
6429        ))];
6430        panel.update_in(cx, |panel, window, cx| {
6431            panel.handle_worktree_requested(
6432                content,
6433                WorktreeCreationArgs::New {
6434                    worktree_name: None,
6435                    branch_target: NewWorktreeBranchTarget::default(),
6436                },
6437                window,
6438                cx,
6439            );
6440        });
6441
6442        // Let the async worktree creation + workspace setup complete.
6443        cx.run_until_parked();
6444
6445        panel.read_with(cx, |panel, _cx| {
6446            assert_eq!(
6447                panel.start_thread_in(),
6448                &StartThreadIn::LocalProject,
6449                "the original panel should reset start_thread_in back to the local project after creating a worktree workspace",
6450            );
6451        });
6452
6453        // Find the new workspace's AgentPanel and verify it used the Codex agent.
6454        let found_codex = multi_workspace
6455            .read_with(cx, |multi_workspace, cx| {
6456                // There should be more than one workspace now (the original + the new worktree).
6457                assert!(
6458                    multi_workspace.workspaces().count() > 1,
6459                    "expected a new workspace to have been created, found {}",
6460                    multi_workspace.workspaces().count(),
6461                );
6462
6463                // Check the newest workspace's panel for the correct agent.
6464                let new_workspace = multi_workspace
6465                    .workspaces()
6466                    .find(|ws| ws.entity_id() != workspace.entity_id())
6467                    .expect("should find the new workspace");
6468                let new_panel = new_workspace
6469                    .read(cx)
6470                    .panel::<AgentPanel>(cx)
6471                    .expect("new workspace should have an AgentPanel");
6472
6473                new_panel.read(cx).selected_agent.clone()
6474            })
6475            .unwrap();
6476
6477        assert_eq!(
6478            found_codex,
6479            Agent::Custom {
6480                id: CODEX_ID.into()
6481            },
6482            "the new worktree workspace should use the same agent (Codex) that was selected in the original panel",
6483        );
6484    }
6485
6486    #[gpui::test]
6487    async fn test_work_dirs_update_when_worktrees_change(cx: &mut TestAppContext) {
6488        use crate::thread_metadata_store::ThreadMetadataStore;
6489
6490        init_test(cx);
6491        cx.update(|cx| {
6492            agent::ThreadStore::init_global(cx);
6493            language_model::LanguageModelRegistry::test(cx);
6494        });
6495
6496        // Set up a project with one worktree.
6497        let fs = FakeFs::new(cx.executor());
6498        fs.insert_tree("/project_a", json!({ "file.txt": "" }))
6499            .await;
6500        let project = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
6501
6502        let multi_workspace =
6503            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6504        let workspace = multi_workspace
6505            .read_with(cx, |mw, _cx| mw.workspace().clone())
6506            .unwrap();
6507        let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
6508
6509        let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
6510            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6511        });
6512
6513        // Open thread A and send a message. With empty next_prompt_updates it
6514        // stays generating, so opening B will move A to background_threads.
6515        let connection_a = StubAgentConnection::new().with_agent_id("agent-a".into());
6516        open_thread_with_custom_connection(&panel, connection_a.clone(), &mut cx);
6517        send_message(&panel, &mut cx);
6518        let session_id_a = active_session_id(&panel, &cx);
6519
6520        // Open thread C — thread A (generating) moves to background.
6521        // Thread C completes immediately (idle), then opening B moves C to background too.
6522        let connection_c = StubAgentConnection::new().with_agent_id("agent-c".into());
6523        connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6524            acp::ContentChunk::new("done".into()),
6525        )]);
6526        open_thread_with_custom_connection(&panel, connection_c.clone(), &mut cx);
6527        send_message(&panel, &mut cx);
6528        let session_id_c = active_session_id(&panel, &cx);
6529
6530        // Open thread B — thread C (idle, non-loadable) is retained in background.
6531        let connection_b = StubAgentConnection::new().with_agent_id("agent-b".into());
6532        open_thread_with_custom_connection(&panel, connection_b.clone(), &mut cx);
6533        send_message(&panel, &mut cx);
6534        let session_id_b = active_session_id(&panel, &cx);
6535
6536        let metadata_store = cx.update(|_, cx| ThreadMetadataStore::global(cx));
6537
6538        panel.read_with(&cx, |panel, _cx| {
6539            assert!(
6540                panel.background_threads.contains_key(&session_id_a),
6541                "Thread A should be in background_threads"
6542            );
6543            assert!(
6544                panel.background_threads.contains_key(&session_id_c),
6545                "Thread C should be in background_threads"
6546            );
6547        });
6548
6549        // Verify initial work_dirs for thread B contain only /project_a.
6550        let initial_b_paths = panel.read_with(&cx, |panel, cx| {
6551            let thread = panel.active_agent_thread(cx).unwrap();
6552            thread.read(cx).work_dirs().cloned().unwrap()
6553        });
6554        assert_eq!(
6555            initial_b_paths.ordered_paths().collect::<Vec<_>>(),
6556            vec![&PathBuf::from("/project_a")],
6557            "Thread B should initially have only /project_a"
6558        );
6559
6560        // Now add a second worktree to the project.
6561        fs.insert_tree("/project_b", json!({ "other.txt": "" }))
6562            .await;
6563        let (new_tree, _) = project
6564            .update(&mut cx, |project, cx| {
6565                project.find_or_create_worktree("/project_b", true, cx)
6566            })
6567            .await
6568            .unwrap();
6569        cx.read(|cx| new_tree.read(cx).as_local().unwrap().scan_complete())
6570            .await;
6571        cx.run_until_parked();
6572
6573        // Verify thread B's (active) work_dirs now include both worktrees.
6574        let updated_b_paths = panel.read_with(&cx, |panel, cx| {
6575            let thread = panel.active_agent_thread(cx).unwrap();
6576            thread.read(cx).work_dirs().cloned().unwrap()
6577        });
6578        let mut b_paths_sorted = updated_b_paths.ordered_paths().cloned().collect::<Vec<_>>();
6579        b_paths_sorted.sort();
6580        assert_eq!(
6581            b_paths_sorted,
6582            vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6583            "Thread B work_dirs should include both worktrees after adding /project_b"
6584        );
6585
6586        // Verify thread A's (background) work_dirs are also updated.
6587        let updated_a_paths = panel.read_with(&cx, |panel, cx| {
6588            let bg_view = panel.background_threads.get(&session_id_a).unwrap();
6589            let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6590            root_thread
6591                .read(cx)
6592                .thread
6593                .read(cx)
6594                .work_dirs()
6595                .cloned()
6596                .unwrap()
6597        });
6598        let mut a_paths_sorted = updated_a_paths.ordered_paths().cloned().collect::<Vec<_>>();
6599        a_paths_sorted.sort();
6600        assert_eq!(
6601            a_paths_sorted,
6602            vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6603            "Thread A work_dirs should include both worktrees after adding /project_b"
6604        );
6605
6606        // Verify thread idle C was also updated.
6607        let updated_c_paths = panel.read_with(&cx, |panel, cx| {
6608            let bg_view = panel.background_threads.get(&session_id_c).unwrap();
6609            let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6610            root_thread
6611                .read(cx)
6612                .thread
6613                .read(cx)
6614                .work_dirs()
6615                .cloned()
6616                .unwrap()
6617        });
6618        let mut c_paths_sorted = updated_c_paths.ordered_paths().cloned().collect::<Vec<_>>();
6619        c_paths_sorted.sort();
6620        assert_eq!(
6621            c_paths_sorted,
6622            vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6623            "Thread C (idle background) work_dirs should include both worktrees after adding /project_b"
6624        );
6625
6626        // Verify the metadata store reflects the new paths for running threads only.
6627        cx.run_until_parked();
6628        for (label, session_id) in [("thread B", &session_id_b), ("thread A", &session_id_a)] {
6629            let metadata_paths = metadata_store.read_with(&cx, |store, _cx| {
6630                let metadata = store
6631                    .entry(session_id)
6632                    .unwrap_or_else(|| panic!("{label} thread metadata should exist"));
6633                metadata.folder_paths().clone()
6634            });
6635            let mut sorted = metadata_paths.ordered_paths().cloned().collect::<Vec<_>>();
6636            sorted.sort();
6637            assert_eq!(
6638                sorted,
6639                vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6640                "{label} thread metadata folder_paths should include both worktrees"
6641            );
6642        }
6643
6644        // Now remove a worktree and verify work_dirs shrink.
6645        let worktree_b_id = new_tree.read_with(&cx, |tree, _| tree.id());
6646        project.update(&mut cx, |project, cx| {
6647            project.remove_worktree(worktree_b_id, cx);
6648        });
6649        cx.run_until_parked();
6650
6651        let after_remove_b = panel.read_with(&cx, |panel, cx| {
6652            let thread = panel.active_agent_thread(cx).unwrap();
6653            thread.read(cx).work_dirs().cloned().unwrap()
6654        });
6655        assert_eq!(
6656            after_remove_b.ordered_paths().collect::<Vec<_>>(),
6657            vec![&PathBuf::from("/project_a")],
6658            "Thread B work_dirs should revert to only /project_a after removing /project_b"
6659        );
6660
6661        let after_remove_a = panel.read_with(&cx, |panel, cx| {
6662            let bg_view = panel.background_threads.get(&session_id_a).unwrap();
6663            let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6664            root_thread
6665                .read(cx)
6666                .thread
6667                .read(cx)
6668                .work_dirs()
6669                .cloned()
6670                .unwrap()
6671        });
6672        assert_eq!(
6673            after_remove_a.ordered_paths().collect::<Vec<_>>(),
6674            vec![&PathBuf::from("/project_a")],
6675            "Thread A work_dirs should revert to only /project_a after removing /project_b"
6676        );
6677    }
6678
6679    #[gpui::test]
6680    async fn test_new_workspace_inherits_global_last_used_agent(cx: &mut TestAppContext) {
6681        init_test(cx);
6682        cx.update(|cx| {
6683            agent::ThreadStore::init_global(cx);
6684            language_model::LanguageModelRegistry::test(cx);
6685            // Use an isolated DB so parallel tests can't overwrite our global key.
6686            cx.set_global(db::AppDatabase::test_new());
6687        });
6688
6689        let custom_agent = Agent::Custom {
6690            id: "my-preferred-agent".into(),
6691        };
6692
6693        // Write a known agent to the global KVP to simulate a user who has
6694        // previously used this agent in another workspace.
6695        let kvp = cx.update(|cx| KeyValueStore::global(cx));
6696        write_global_last_used_agent(kvp, custom_agent.clone()).await;
6697
6698        let fs = FakeFs::new(cx.executor());
6699        let project = Project::test(fs.clone(), [], cx).await;
6700
6701        let multi_workspace =
6702            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6703
6704        let workspace = multi_workspace
6705            .read_with(cx, |multi_workspace, _cx| {
6706                multi_workspace.workspace().clone()
6707            })
6708            .unwrap();
6709
6710        workspace.update(cx, |workspace, _cx| {
6711            workspace.set_random_database_id();
6712        });
6713
6714        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6715
6716        // Load the panel via `load()`, which reads the global fallback
6717        // asynchronously when no per-workspace state exists.
6718        let async_cx = cx.update(|window, cx| window.to_async(cx));
6719        let panel = AgentPanel::load(workspace.downgrade(), async_cx)
6720            .await
6721            .expect("panel load should succeed");
6722        cx.run_until_parked();
6723
6724        panel.read_with(cx, |panel, _cx| {
6725            assert_eq!(
6726                panel.selected_agent, custom_agent,
6727                "new workspace should inherit the global last-used agent"
6728            );
6729        });
6730    }
6731
6732    #[gpui::test]
6733    async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) {
6734        init_test(cx);
6735        cx.update(|cx| {
6736            agent::ThreadStore::init_global(cx);
6737            language_model::LanguageModelRegistry::test(cx);
6738        });
6739
6740        let fs = FakeFs::new(cx.executor());
6741        let project_a = Project::test(fs.clone(), [], cx).await;
6742        let project_b = Project::test(fs, [], cx).await;
6743
6744        let multi_workspace =
6745            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6746
6747        let workspace_a = multi_workspace
6748            .read_with(cx, |multi_workspace, _cx| {
6749                multi_workspace.workspace().clone()
6750            })
6751            .unwrap();
6752
6753        let workspace_b = multi_workspace
6754            .update(cx, |multi_workspace, window, cx| {
6755                multi_workspace.test_add_workspace(project_b.clone(), window, cx)
6756            })
6757            .unwrap();
6758
6759        workspace_a.update(cx, |workspace, _cx| {
6760            workspace.set_random_database_id();
6761        });
6762        workspace_b.update(cx, |workspace, _cx| {
6763            workspace.set_random_database_id();
6764        });
6765
6766        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6767
6768        let agent_a = Agent::Custom {
6769            id: "agent-alpha".into(),
6770        };
6771        let agent_b = Agent::Custom {
6772            id: "agent-beta".into(),
6773        };
6774
6775        // Set up workspace A with agent_a
6776        let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
6777            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6778        });
6779        panel_a.update(cx, |panel, _cx| {
6780            panel.selected_agent = agent_a.clone();
6781        });
6782
6783        // Set up workspace B with agent_b
6784        let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
6785            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6786        });
6787        panel_b.update(cx, |panel, _cx| {
6788            panel.selected_agent = agent_b.clone();
6789        });
6790
6791        // Serialize both panels
6792        panel_a.update(cx, |panel, cx| panel.serialize(cx));
6793        panel_b.update(cx, |panel, cx| panel.serialize(cx));
6794        cx.run_until_parked();
6795
6796        // Load fresh panels from serialized state and verify independence
6797        let async_cx = cx.update(|window, cx| window.to_async(cx));
6798        let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
6799            .await
6800            .expect("panel A load should succeed");
6801        cx.run_until_parked();
6802
6803        let async_cx = cx.update(|window, cx| window.to_async(cx));
6804        let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
6805            .await
6806            .expect("panel B load should succeed");
6807        cx.run_until_parked();
6808
6809        loaded_a.read_with(cx, |panel, _cx| {
6810            assert_eq!(
6811                panel.selected_agent, agent_a,
6812                "workspace A should restore agent-alpha, not agent-beta"
6813            );
6814        });
6815
6816        loaded_b.read_with(cx, |panel, _cx| {
6817            assert_eq!(
6818                panel.selected_agent, agent_b,
6819                "workspace B should restore agent-beta, not agent-alpha"
6820            );
6821        });
6822    }
6823
6824    #[gpui::test]
6825    async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) {
6826        init_test(cx);
6827        cx.update(|cx| {
6828            agent::ThreadStore::init_global(cx);
6829            language_model::LanguageModelRegistry::test(cx);
6830        });
6831
6832        let fs = FakeFs::new(cx.executor());
6833        let project = Project::test(fs.clone(), [], cx).await;
6834
6835        let multi_workspace =
6836            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6837
6838        let workspace = multi_workspace
6839            .read_with(cx, |multi_workspace, _cx| {
6840                multi_workspace.workspace().clone()
6841            })
6842            .unwrap();
6843
6844        workspace.update(cx, |workspace, _cx| {
6845            workspace.set_random_database_id();
6846        });
6847
6848        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6849
6850        let custom_agent = Agent::Custom {
6851            id: "my-custom-agent".into(),
6852        };
6853
6854        let panel = workspace.update_in(cx, |workspace, window, cx| {
6855            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6856            workspace.add_panel(panel.clone(), window, cx);
6857            panel
6858        });
6859
6860        // Set selected_agent to a custom agent
6861        panel.update(cx, |panel, _cx| {
6862            panel.selected_agent = custom_agent.clone();
6863        });
6864
6865        // Call new_thread, which internally calls external_thread(None, ...)
6866        // This resolves the agent from self.selected_agent
6867        panel.update_in(cx, |panel, window, cx| {
6868            panel.new_thread(&NewThread, window, cx);
6869        });
6870
6871        panel.read_with(cx, |panel, _cx| {
6872            assert_eq!(
6873                panel.selected_agent, custom_agent,
6874                "selected_agent should remain the custom agent after new_thread"
6875            );
6876            assert!(
6877                panel.active_conversation_view().is_some(),
6878                "a thread should have been created"
6879            );
6880        });
6881    }
6882
6883    #[gpui::test]
6884    async fn test_rollback_all_succeed_returns_ok(cx: &mut TestAppContext) {
6885        init_test(cx);
6886        let fs = FakeFs::new(cx.executor());
6887        cx.update(|cx| {
6888            cx.update_flags(true, vec!["agent-v2".to_string()]);
6889            agent::ThreadStore::init_global(cx);
6890            language_model::LanguageModelRegistry::test(cx);
6891            <dyn fs::Fs>::set_global(fs.clone(), cx);
6892        });
6893
6894        fs.insert_tree(
6895            "/project",
6896            json!({
6897                ".git": {},
6898                "src": { "main.rs": "fn main() {}" }
6899            }),
6900        )
6901        .await;
6902
6903        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6904        cx.executor().run_until_parked();
6905
6906        let repository = project.read_with(cx, |project, cx| {
6907            project.repositories(cx).values().next().unwrap().clone()
6908        });
6909
6910        let multi_workspace =
6911            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6912
6913        let path_a = PathBuf::from("/worktrees/branch/project_a");
6914        let path_b = PathBuf::from("/worktrees/branch/project_b");
6915
6916        let (sender_a, receiver_a) = futures::channel::oneshot::channel::<Result<()>>();
6917        let (sender_b, receiver_b) = futures::channel::oneshot::channel::<Result<()>>();
6918        sender_a.send(Ok(())).unwrap();
6919        sender_b.send(Ok(())).unwrap();
6920
6921        let creation_infos = vec![
6922            (repository.clone(), path_a.clone(), receiver_a),
6923            (repository.clone(), path_b.clone(), receiver_b),
6924        ];
6925
6926        let fs_clone = fs.clone();
6927        let result = multi_workspace
6928            .update(cx, |_, window, cx| {
6929                window.spawn(cx, async move |cx| {
6930                    AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
6931                })
6932            })
6933            .unwrap()
6934            .await;
6935
6936        let paths = result.expect("all succeed should return Ok");
6937        assert_eq!(paths, vec![path_a, path_b]);
6938    }
6939
6940    #[gpui::test]
6941    async fn test_rollback_on_failure_attempts_all_worktrees(cx: &mut TestAppContext) {
6942        init_test(cx);
6943        let fs = FakeFs::new(cx.executor());
6944        cx.update(|cx| {
6945            cx.update_flags(true, vec!["agent-v2".to_string()]);
6946            agent::ThreadStore::init_global(cx);
6947            language_model::LanguageModelRegistry::test(cx);
6948            <dyn fs::Fs>::set_global(fs.clone(), cx);
6949        });
6950
6951        fs.insert_tree(
6952            "/project",
6953            json!({
6954                ".git": {},
6955                "src": { "main.rs": "fn main() {}" }
6956            }),
6957        )
6958        .await;
6959
6960        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6961        cx.executor().run_until_parked();
6962
6963        let repository = project.read_with(cx, |project, cx| {
6964            project.repositories(cx).values().next().unwrap().clone()
6965        });
6966
6967        // Actually create a worktree so it exists in FakeFs for rollback to find.
6968        let success_path = PathBuf::from("/worktrees/branch/project");
6969        cx.update(|cx| {
6970            repository.update(cx, |repo, _| {
6971                repo.create_worktree(
6972                    git::repository::CreateWorktreeTarget::NewBranch {
6973                        branch_name: "branch".to_string(),
6974                        base_sha: None,
6975                    },
6976                    success_path.clone(),
6977                )
6978            })
6979        })
6980        .await
6981        .unwrap()
6982        .unwrap();
6983        cx.executor().run_until_parked();
6984
6985        // Verify the worktree directory exists before rollback.
6986        assert!(
6987            fs.is_dir(&success_path).await,
6988            "worktree directory should exist before rollback"
6989        );
6990
6991        let multi_workspace =
6992            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6993
6994        // Build creation_infos: one success, one failure.
6995        let failed_path = PathBuf::from("/worktrees/branch/failed_project");
6996
6997        let (sender_ok, receiver_ok) = futures::channel::oneshot::channel::<Result<()>>();
6998        let (sender_err, receiver_err) = futures::channel::oneshot::channel::<Result<()>>();
6999        sender_ok.send(Ok(())).unwrap();
7000        sender_err
7001            .send(Err(anyhow!("branch already exists")))
7002            .unwrap();
7003
7004        let creation_infos = vec![
7005            (repository.clone(), success_path.clone(), receiver_ok),
7006            (repository.clone(), failed_path.clone(), receiver_err),
7007        ];
7008
7009        let fs_clone = fs.clone();
7010        let result = multi_workspace
7011            .update(cx, |_, window, cx| {
7012                window.spawn(cx, async move |cx| {
7013                    AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7014                })
7015            })
7016            .unwrap()
7017            .await;
7018
7019        assert!(
7020            result.is_err(),
7021            "should return error when any creation fails"
7022        );
7023        let err_msg = result.unwrap_err().to_string();
7024        assert!(
7025            err_msg.contains("branch already exists"),
7026            "error should mention the original failure: {err_msg}"
7027        );
7028
7029        // The successful worktree should have been rolled back by git.
7030        cx.executor().run_until_parked();
7031        assert!(
7032            !fs.is_dir(&success_path).await,
7033            "successful worktree directory should be removed by rollback"
7034        );
7035    }
7036
7037    #[gpui::test]
7038    async fn test_rollback_on_canceled_receiver(cx: &mut TestAppContext) {
7039        init_test(cx);
7040        let fs = FakeFs::new(cx.executor());
7041        cx.update(|cx| {
7042            cx.update_flags(true, vec!["agent-v2".to_string()]);
7043            agent::ThreadStore::init_global(cx);
7044            language_model::LanguageModelRegistry::test(cx);
7045            <dyn fs::Fs>::set_global(fs.clone(), cx);
7046        });
7047
7048        fs.insert_tree(
7049            "/project",
7050            json!({
7051                ".git": {},
7052                "src": { "main.rs": "fn main() {}" }
7053            }),
7054        )
7055        .await;
7056
7057        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7058        cx.executor().run_until_parked();
7059
7060        let repository = project.read_with(cx, |project, cx| {
7061            project.repositories(cx).values().next().unwrap().clone()
7062        });
7063
7064        let multi_workspace =
7065            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7066
7067        let path = PathBuf::from("/worktrees/branch/project");
7068
7069        // Drop the sender to simulate a canceled receiver.
7070        let (_sender, receiver) = futures::channel::oneshot::channel::<Result<()>>();
7071        drop(_sender);
7072
7073        let creation_infos = vec![(repository.clone(), path.clone(), receiver)];
7074
7075        let fs_clone = fs.clone();
7076        let result = multi_workspace
7077            .update(cx, |_, window, cx| {
7078                window.spawn(cx, async move |cx| {
7079                    AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7080                })
7081            })
7082            .unwrap()
7083            .await;
7084
7085        assert!(
7086            result.is_err(),
7087            "should return error when receiver is canceled"
7088        );
7089        let err_msg = result.unwrap_err().to_string();
7090        assert!(
7091            err_msg.contains("canceled"),
7092            "error should mention cancellation: {err_msg}"
7093        );
7094    }
7095
7096    #[gpui::test]
7097    async fn test_rollback_cleans_up_orphan_directories(cx: &mut TestAppContext) {
7098        init_test(cx);
7099        let fs = FakeFs::new(cx.executor());
7100        cx.update(|cx| {
7101            cx.update_flags(true, vec!["agent-v2".to_string()]);
7102            agent::ThreadStore::init_global(cx);
7103            language_model::LanguageModelRegistry::test(cx);
7104            <dyn fs::Fs>::set_global(fs.clone(), cx);
7105        });
7106
7107        fs.insert_tree(
7108            "/project",
7109            json!({
7110                ".git": {},
7111                "src": { "main.rs": "fn main() {}" }
7112            }),
7113        )
7114        .await;
7115
7116        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
7117        cx.executor().run_until_parked();
7118
7119        let repository = project.read_with(cx, |project, cx| {
7120            project.repositories(cx).values().next().unwrap().clone()
7121        });
7122
7123        let multi_workspace =
7124            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7125
7126        // Simulate the orphan state: create_dir_all was called but git
7127        // worktree add failed, leaving a directory with leftover files.
7128        let orphan_path = PathBuf::from("/worktrees/branch/orphan_project");
7129        fs.insert_tree(
7130            "/worktrees/branch/orphan_project",
7131            json!({ "leftover.txt": "junk" }),
7132        )
7133        .await;
7134
7135        assert!(
7136            fs.is_dir(&orphan_path).await,
7137            "orphan dir should exist before rollback"
7138        );
7139
7140        let (sender, receiver) = futures::channel::oneshot::channel::<Result<()>>();
7141        sender.send(Err(anyhow!("hook failed"))).unwrap();
7142
7143        let creation_infos = vec![(repository.clone(), orphan_path.clone(), receiver)];
7144
7145        let fs_clone = fs.clone();
7146        let result = multi_workspace
7147            .update(cx, |_, window, cx| {
7148                window.spawn(cx, async move |cx| {
7149                    AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
7150                })
7151            })
7152            .unwrap()
7153            .await;
7154
7155        cx.executor().run_until_parked();
7156
7157        assert!(result.is_err());
7158        assert!(
7159            !fs.is_dir(&orphan_path).await,
7160            "orphan worktree directory should be removed by filesystem cleanup"
7161        );
7162    }
7163
7164    #[gpui::test]
7165    async fn test_worktree_creation_for_remote_project(
7166        cx: &mut TestAppContext,
7167        server_cx: &mut TestAppContext,
7168    ) {
7169        init_test(cx);
7170
7171        let app_state = cx.update(|cx| {
7172            agent::ThreadStore::init_global(cx);
7173            language_model::LanguageModelRegistry::test(cx);
7174
7175            let app_state = workspace::AppState::test(cx);
7176            workspace::init(app_state.clone(), cx);
7177            app_state
7178        });
7179
7180        server_cx.update(|cx| {
7181            release_channel::init(semver::Version::new(0, 0, 0), cx);
7182        });
7183
7184        // Set up the remote server side with a git repo.
7185        let server_fs = FakeFs::new(server_cx.executor());
7186        server_fs
7187            .insert_tree(
7188                "/project",
7189                json!({
7190                    ".git": {},
7191                    "src": {
7192                        "main.rs": "fn main() {}"
7193                    }
7194                }),
7195            )
7196            .await;
7197        server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
7198
7199        // Create a mock remote connection.
7200        let (opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
7201
7202        server_cx.update(remote_server::HeadlessProject::init);
7203        let server_executor = server_cx.executor();
7204        let _headless = server_cx.new(|cx| {
7205            remote_server::HeadlessProject::new(
7206                remote_server::HeadlessAppState {
7207                    session: server_session,
7208                    fs: server_fs.clone(),
7209                    http_client: Arc::new(http_client::BlockedHttpClient),
7210                    node_runtime: node_runtime::NodeRuntime::unavailable(),
7211                    languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
7212                    extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
7213                    startup_time: Instant::now(),
7214                },
7215                false,
7216                cx,
7217            )
7218        });
7219
7220        // Connect the client side and build a remote project.
7221        // Use a separate Client to avoid double-registering proto handlers
7222        // (Workspace::test_new creates its own WorkspaceStore from the
7223        // project's client).
7224        let remote_client = remote::RemoteClient::connect_mock(opts, cx).await;
7225        let project = cx.update(|cx| {
7226            let project_client = client::Client::new(
7227                Arc::new(clock::FakeSystemClock::new()),
7228                http_client::FakeHttpClient::with_404_response(),
7229                cx,
7230            );
7231            let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
7232            project::Project::remote(
7233                remote_client,
7234                project_client,
7235                node_runtime::NodeRuntime::unavailable(),
7236                user_store,
7237                app_state.languages.clone(),
7238                app_state.fs.clone(),
7239                false,
7240                cx,
7241            )
7242        });
7243
7244        // Open the remote path as a worktree in the project.
7245        let worktree_path = Path::new("/project");
7246        project
7247            .update(cx, |project, cx| {
7248                project.find_or_create_worktree(worktree_path, true, cx)
7249            })
7250            .await
7251            .expect("should be able to open remote worktree");
7252        cx.run_until_parked();
7253
7254        // Verify the project is indeed remote.
7255        project.read_with(cx, |project, cx| {
7256            assert!(!project.is_local(), "project should be remote, not local");
7257            assert!(
7258                project.remote_connection_options(cx).is_some(),
7259                "project should have remote connection options"
7260            );
7261        });
7262
7263        // Create the workspace and agent panel.
7264        let multi_workspace =
7265            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7266        multi_workspace
7267            .update(cx, |multi_workspace, _, cx| {
7268                multi_workspace.open_sidebar(cx);
7269            })
7270            .unwrap();
7271
7272        let workspace = multi_workspace
7273            .read_with(cx, |mw, _cx| mw.workspace().clone())
7274            .unwrap();
7275
7276        workspace.update(cx, |workspace, _cx| {
7277            workspace.set_random_database_id();
7278        });
7279
7280        // Register a callback so new workspaces also get an AgentPanel.
7281        cx.update(|cx| {
7282            cx.observe_new(
7283                |workspace: &mut Workspace,
7284                 window: Option<&mut Window>,
7285                 cx: &mut Context<Workspace>| {
7286                    if let Some(window) = window {
7287                        let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7288                        workspace.add_panel(panel, window, cx);
7289                    }
7290                },
7291            )
7292            .detach();
7293        });
7294
7295        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7296        cx.run_until_parked();
7297
7298        let panel = workspace.update_in(cx, |workspace, window, cx| {
7299            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7300            workspace.add_panel(panel.clone(), window, cx);
7301            panel
7302        });
7303
7304        cx.run_until_parked();
7305
7306        // Open a thread.
7307        panel.update_in(cx, |panel, window, cx| {
7308            panel.open_external_thread_with_server(
7309                Rc::new(StubAgentServer::default_response()),
7310                window,
7311                cx,
7312            );
7313        });
7314        cx.run_until_parked();
7315
7316        // Set start_thread_in to LinkedWorktree to bypass git worktree
7317        // creation and directly test workspace opening for a known path.
7318        let linked_path = PathBuf::from("/project");
7319        panel.update_in(cx, |panel, window, cx| {
7320            panel.set_start_thread_in(
7321                &StartThreadIn::LinkedWorktree {
7322                    path: linked_path.clone(),
7323                    display_name: "project".to_string(),
7324                },
7325                window,
7326                cx,
7327            );
7328        });
7329
7330        // Trigger worktree creation.
7331        let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
7332            "Hello from remote test",
7333        ))];
7334        panel.update_in(cx, |panel, window, cx| {
7335            panel.handle_worktree_requested(
7336                content,
7337                WorktreeCreationArgs::Linked {
7338                    worktree_path: linked_path,
7339                },
7340                window,
7341                cx,
7342            );
7343        });
7344
7345        // The refactored code uses `find_or_create_workspace`, which
7346        // finds the existing remote workspace (matching paths + host)
7347        // and reuses it instead of creating a new connection.
7348        cx.run_until_parked();
7349
7350        // The task should have completed: the existing workspace was
7351        // found and reused.
7352        panel.read_with(cx, |panel, _cx| {
7353            assert!(
7354                panel.worktree_creation_status.is_none(),
7355                "worktree creation should have completed, but status is: {:?}",
7356                panel.worktree_creation_status
7357            );
7358        });
7359
7360        // The existing remote workspace was reused — no new workspace
7361        // should have been created.
7362        multi_workspace
7363            .read_with(cx, |multi_workspace, cx| {
7364                let project = workspace.read(cx).project().clone();
7365                assert!(
7366                    !project.read(cx).is_local(),
7367                    "workspace project should still be remote, not local"
7368                );
7369                assert_eq!(
7370                    multi_workspace.workspaces().count(),
7371                    1,
7372                    "existing remote workspace should be reused, not a new one created"
7373                );
7374            })
7375            .unwrap();
7376    }
7377
7378    #[gpui::test]
7379    async fn test_selected_agent_syncs_when_navigating_between_threads(cx: &mut TestAppContext) {
7380        let (panel, mut cx) = setup_panel(cx).await;
7381
7382        let custom_agent = Agent::Custom {
7383            id: "my-custom-agent".into(),
7384        };
7385
7386        // Create a draft thread with the custom agent.
7387        panel.update(&mut cx, |panel, _cx| {
7388            panel.selected_agent = custom_agent.clone();
7389        });
7390        panel.update_in(&mut cx, |panel, window, cx| {
7391            panel.new_thread(&NewThread, window, cx);
7392        });
7393        let draft_id = panel.read_with(&cx, |panel, _cx| {
7394            assert_eq!(panel.selected_agent, custom_agent);
7395            panel
7396                .active_draft_id()
7397                .expect("should have an active draft")
7398        });
7399
7400        // Open a different thread (stub agent) — this navigates away from the draft.
7401        let connection = StubAgentConnection::new();
7402        let stub_agent = Agent::Custom { id: "Test".into() };
7403        open_thread_with_connection(&panel, connection.clone(), &mut cx);
7404        let other_session_id = active_session_id(&panel, &cx);
7405
7406        // Send a message so the thread is retained when we navigate away.
7407        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7408            acp::ContentChunk::new("response".into()),
7409        )]);
7410        send_message(&panel, &mut cx);
7411        cx.run_until_parked();
7412
7413        panel.read_with(&cx, |panel, _cx| {
7414            assert_ne!(
7415                panel.selected_agent, custom_agent,
7416                "selected_agent should have changed to the stub agent"
7417            );
7418        });
7419
7420        // Navigate back to the draft thread.
7421        panel.update_in(&mut cx, |panel, window, cx| {
7422            panel.activate_draft(draft_id, true, window, cx);
7423        });
7424
7425        panel.read_with(&cx, |panel, _cx| {
7426            assert_eq!(
7427                panel.selected_agent, custom_agent,
7428                "selected_agent should sync back to the draft's agent"
7429            );
7430        });
7431
7432        // Navigate to the other thread via load_agent_thread (simulating history click).
7433        panel.update_in(&mut cx, |panel, window, cx| {
7434            panel.load_agent_thread(
7435                stub_agent.clone(),
7436                other_session_id,
7437                None,
7438                None,
7439                true,
7440                window,
7441                cx,
7442            );
7443        });
7444
7445        panel.read_with(&cx, |panel, _cx| {
7446            assert_eq!(
7447                panel.selected_agent, stub_agent,
7448                "selected_agent should sync to the loaded thread's agent"
7449            );
7450        });
7451    }
7452
7453    #[gpui::test]
7454    async fn test_classify_worktrees_skips_non_git_root_with_nested_repo(cx: &mut TestAppContext) {
7455        init_test(cx);
7456        cx.update(|cx| {
7457            agent::ThreadStore::init_global(cx);
7458            language_model::LanguageModelRegistry::test(cx);
7459        });
7460
7461        let fs = FakeFs::new(cx.executor());
7462        fs.insert_tree(
7463            "/repo_a",
7464            json!({
7465                ".git": {},
7466                "src": { "main.rs": "" }
7467            }),
7468        )
7469        .await;
7470        fs.insert_tree(
7471            "/repo_b",
7472            json!({
7473                ".git": {},
7474                "src": { "lib.rs": "" }
7475            }),
7476        )
7477        .await;
7478        // `plain_dir` is NOT a git repo, but contains a nested git repo.
7479        fs.insert_tree(
7480            "/plain_dir",
7481            json!({
7482                "nested_repo": {
7483                    ".git": {},
7484                    "src": { "lib.rs": "" }
7485                }
7486            }),
7487        )
7488        .await;
7489
7490        let project = Project::test(
7491            fs.clone(),
7492            [
7493                Path::new("/repo_a"),
7494                Path::new("/repo_b"),
7495                Path::new("/plain_dir"),
7496            ],
7497            cx,
7498        )
7499        .await;
7500
7501        // Let the worktree scanner discover all `.git` directories.
7502        cx.executor().run_until_parked();
7503
7504        let multi_workspace =
7505            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7506
7507        let workspace = multi_workspace
7508            .read_with(cx, |mw, _cx| mw.workspace().clone())
7509            .unwrap();
7510
7511        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7512
7513        let panel = workspace.update_in(cx, |workspace, window, cx| {
7514            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
7515        });
7516
7517        cx.run_until_parked();
7518
7519        panel.read_with(cx, |panel, cx| {
7520            let (git_repos, non_git_paths) = panel.classify_worktrees(cx);
7521
7522            let git_work_dirs: Vec<PathBuf> = git_repos
7523                .iter()
7524                .map(|repo| repo.read(cx).work_directory_abs_path.to_path_buf())
7525                .collect();
7526
7527            assert_eq!(
7528                git_repos.len(),
7529                2,
7530                "only repo_a and repo_b should be classified as git repos, \
7531                 but got: {git_work_dirs:?}"
7532            );
7533            assert!(
7534                git_work_dirs.contains(&PathBuf::from("/repo_a")),
7535                "repo_a should be in git_repos: {git_work_dirs:?}"
7536            );
7537            assert!(
7538                git_work_dirs.contains(&PathBuf::from("/repo_b")),
7539                "repo_b should be in git_repos: {git_work_dirs:?}"
7540            );
7541
7542            assert_eq!(
7543                non_git_paths,
7544                vec![PathBuf::from("/plain_dir")],
7545                "plain_dir should be classified as a non-git path \
7546                 (not matched to nested_repo inside it)"
7547            );
7548        });
7549    }
7550}