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