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