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