Files
zed/crates/agent_ui/src/agent_panel.rs
T
Danilo Leal 751e6500a8 Adjust worktree name display in agent panel and git picker (#53351)
This PR adjust the name of the worktree within the agent panel: we
display `main` if you're in the main worktree and then the worktree name
when in a linked one. Additionally, I also took the opportunity to fix
something that confused me in the general worktree picker: for the list
item that represented the main worktree, we were using the current
checked out branch name for that item's name. I think that's a bit
incorrect, given the _name_ of that worktree isn't the name of the
branch you have active at that moment. For now, I'm using simply `main`
as the worktree name. Lastly, also added a little blue check icon in the
worktree _and branch_ picker to highlight which worktree/branch is
currently active.

| main worktree | linked worktree | main worktree in picker |
|--------|--------|--------|
| <img width="802" height="338" alt="Screenshot 2026-04-07 at 10  32
2@2x"
src="https://github.com/user-attachments/assets/aad993e6-adc3-4c8d-b01a-abb2597182e7"
/> | <img width="804" height="332" alt="Screenshot 2026-04-07 at 10 
32@2x"
src="https://github.com/user-attachments/assets/e59c5b86-8cee-476c-ab7b-32ae87249c1a"
/> | <img width="804" height="856" alt="Screenshot 2026-04-07 at 10 
36@2x"
src="https://github.com/user-attachments/assets/282d89a8-570e-4e62-b703-853bed9b21ff"
/> |

Release Notes:

- Git: Fixed display of the main worktree name in the worktree picker.
2026-04-07 23:21:17 -03:00

6352 lines
236 KiB
Rust

use std::{
path::PathBuf,
rc::Rc,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use acp_thread::{AcpThread, MentionUri, ThreadStatus};
use agent::{ContextServerRegistry, SharedThread, ThreadStore};
use agent_client_protocol as acp;
use agent_servers::AgentServer;
use collections::HashSet;
use db::kvp::{Dismissable, KeyValueStore};
use itertools::Itertools;
use project::AgentId;
use serde::{Deserialize, Serialize};
use settings::{LanguageModelProviderSetting, LanguageModelSelection};
use zed_actions::agent::{
AddSelectionToThread, ConflictContent, ReauthenticateAgent, ResolveConflictedFilesWithAgent,
ResolveConflictsWithAgent, ReviewBranchDiff,
};
use crate::thread_metadata_store::ThreadMetadataStore;
use crate::{
AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
conversation_view::{AcpThreadViewEvent, ThreadView},
thread_branch_picker::ThreadBranchPicker,
thread_worktree_picker::ThreadWorktreePicker,
ui::EndTrialUpsell,
};
use crate::{
Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
NewNativeAgentThreadFromSummary,
};
use crate::{DEFAULT_THREAD_TITLE, ui::AcpOnboardingModal};
use crate::{ExpandMessageEditor, ThreadHistoryView};
use crate::{ManageProfiles, ThreadHistoryViewEvent};
use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
use agent_settings::{AgentSettings, WindowLayout};
use ai_onboarding::AgentPanelOnboarding;
use anyhow::{Context as _, Result, anyhow};
use client::UserStore;
use cloud_api_types::Plan;
use collections::HashMap;
use editor::Editor;
use extension::ExtensionEvents;
use extension_host::ExtensionStore;
use fs::Fs;
use gpui::{
Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
use language_model::LanguageModelRegistry;
use project::project_settings::ProjectSettings;
use project::{Project, ProjectPath, Worktree, linked_worktree_short_name};
use prompt_store::{PromptStore, UserPromptId};
use rules_library::{RulesLibrary, open_rules_library};
use settings::TerminalDockPosition;
use settings::{Settings, update_settings_file};
use terminal::terminal_settings::TerminalSettings;
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use theme_settings::ThemeSettings;
use ui::{
Button, ButtonLike, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu,
PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
};
use util::{ResultExt as _, debug_panic};
use workspace::{
CollaboratorId, DraggedSelection, DraggedTab, OpenMode, OpenResult, PathList,
SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId,
dock::{DockPosition, Panel, PanelEvent},
};
use zed_actions::{
DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
agent::{OpenAcpOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding},
assistant::{OpenRulesLibrary, Toggle, ToggleFocus},
};
const AGENT_PANEL_KEY: &str = "agent_panel";
const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
#[derive(Serialize, Deserialize)]
struct LastUsedAgent {
agent: Agent,
}
/// Reads the most recently used agent across all workspaces. Used as a fallback
/// when opening a workspace that has no per-workspace agent preference yet.
fn read_global_last_used_agent(kvp: &KeyValueStore) -> Option<Agent> {
kvp.read_kvp(LAST_USED_AGENT_KEY)
.log_err()
.flatten()
.and_then(|json| serde_json::from_str::<LastUsedAgent>(&json).log_err())
.map(|entry| entry.agent)
}
async fn write_global_last_used_agent(kvp: KeyValueStore, agent: Agent) {
if let Some(json) = serde_json::to_string(&LastUsedAgent { agent }).log_err() {
kvp.write_kvp(LAST_USED_AGENT_KEY.to_string(), json)
.await
.log_err();
}
}
fn read_serialized_panel(
workspace_id: workspace::WorkspaceId,
kvp: &KeyValueStore,
) -> Option<SerializedAgentPanel> {
let scope = kvp.scoped(AGENT_PANEL_KEY);
let key = i64::from(workspace_id).to_string();
scope
.read(&key)
.log_err()
.flatten()
.and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
}
async fn save_serialized_panel(
workspace_id: workspace::WorkspaceId,
panel: SerializedAgentPanel,
kvp: KeyValueStore,
) -> Result<()> {
let scope = kvp.scoped(AGENT_PANEL_KEY);
let key = i64::from(workspace_id).to_string();
scope.write(key, serde_json::to_string(&panel)?).await?;
Ok(())
}
/// Migration: reads the original single-panel format stored under the
/// `"agent_panel"` KVP key before per-workspace keying was introduced.
fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option<SerializedAgentPanel> {
kvp.read_kvp(AGENT_PANEL_KEY)
.log_err()
.flatten()
.and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
}
#[derive(Serialize, Deserialize, Debug)]
struct SerializedAgentPanel {
selected_agent: Option<Agent>,
#[serde(default)]
last_active_thread: Option<SerializedActiveThread>,
#[serde(default)]
start_thread_in: Option<StartThreadIn>,
}
#[derive(Serialize, Deserialize, Debug)]
struct SerializedActiveThread {
session_id: String,
agent_type: Agent,
title: Option<String>,
work_dirs: Option<SerializedPathList>,
}
pub fn init(cx: &mut App) {
cx.observe_new(
|workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
workspace
.register_action(|workspace, action: &NewThread, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| panel.new_thread(action, window, cx));
workspace.focus_panel::<AgentPanel>(window, cx);
}
})
.register_action(
|workspace, action: &NewNativeAgentThreadFromSummary, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.new_native_agent_thread_from_summary(action, window, cx)
});
workspace.focus_panel::<AgentPanel>(window, cx);
}
},
)
.register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx));
}
})
.register_action(|workspace, _: &OpenHistory, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| panel.open_history(window, cx));
}
})
.register_action(|workspace, _: &OpenSettings, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
}
})
.register_action(|workspace, action: &NewExternalAgentThread, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.external_thread(
action.agent.clone(),
None,
None,
None,
None,
true,
window,
cx,
)
});
}
})
.register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.deploy_rules_library(action, window, cx)
});
}
})
.register_action(|workspace, _: &Follow, window, cx| {
workspace.follow(CollaboratorId::Agent, window, cx);
})
.register_action(|workspace, _: &OpenAgentDiff, window, cx| {
let thread = workspace
.panel::<AgentPanel>(cx)
.and_then(|panel| panel.read(cx).active_conversation_view().cloned())
.and_then(|conversation| {
conversation
.read(cx)
.active_thread()
.map(|r| r.read(cx).thread.clone())
});
if let Some(thread) = thread {
AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
}
})
.register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
});
}
})
.register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
});
}
})
.register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
});
}
})
.register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
AcpOnboardingModal::toggle(workspace, window, cx)
})
.register_action(|_workspace, _: &ResetOnboarding, window, cx| {
window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
window.refresh();
})
.register_action(|workspace, _: &ResetTrialUpsell, _window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, _| {
panel
.new_user_onboarding_upsell_dismissed
.store(false, Ordering::Release);
});
}
OnboardingUpsell::set_dismissed(false, cx);
})
.register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
TrialEndUpsell::set_dismissed(false, cx);
})
.register_action(|workspace, _: &ResetAgentZoom, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.reset_agent_zoom(window, cx);
});
}
})
.register_action(|workspace, _: &CopyThreadToClipboard, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.copy_thread_to_clipboard(window, cx);
});
}
})
.register_action(|workspace, _: &LoadThreadFromClipboard, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.load_thread_from_clipboard(window, cx);
});
}
})
.register_action(|workspace, action: &ReviewBranchDiff, window, cx| {
let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
return;
};
let mention_uri = MentionUri::GitDiff {
base_ref: action.base_ref.to_string(),
};
let diff_uri = mention_uri.to_uri().to_string();
let content_blocks = vec![
acp::ContentBlock::Text(acp::TextContent::new(
"Please review this branch diff carefully. Point out any issues, \
potential bugs, or improvement opportunities you find.\n\n"
.to_string(),
)),
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents::new(
action.diff_text.to_string(),
diff_uri,
),
),
)),
];
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.external_thread(
None,
None,
None,
None,
Some(AgentInitialContent::ContentBlock {
blocks: content_blocks,
auto_submit: true,
}),
true,
window,
cx,
);
});
})
.register_action(
|workspace, action: &ResolveConflictsWithAgent, window, cx| {
let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
return;
};
let content_blocks = build_conflict_resolution_prompt(&action.conflicts);
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.external_thread(
None,
None,
None,
None,
Some(AgentInitialContent::ContentBlock {
blocks: content_blocks,
auto_submit: true,
}),
true,
window,
cx,
);
});
},
)
.register_action(
|workspace, action: &ResolveConflictedFilesWithAgent, window, cx| {
let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
return;
};
let content_blocks =
build_conflicted_files_resolution_prompt(&action.conflicted_file_paths);
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.external_thread(
None,
None,
None,
None,
Some(AgentInitialContent::ContentBlock {
blocks: content_blocks,
auto_submit: true,
}),
true,
window,
cx,
);
});
},
)
.register_action(|workspace, action: &StartThreadIn, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.set_start_thread_in(action, window, cx);
});
}
})
.register_action(|workspace, _: &CycleStartThreadIn, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.cycle_start_thread_in(window, cx);
});
}
})
.register_action(
|workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| {
let active_editor = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx));
let has_editor_selection = active_editor.is_some_and(|editor| {
editor.update(cx, |editor, cx| {
editor.has_non_empty_selection(&editor.display_snapshot(cx))
})
});
let has_terminal_selection = workspace
.active_item(cx)
.and_then(|item| item.act_as::<TerminalView>(cx))
.is_some_and(|terminal_view| {
terminal_view
.read(cx)
.terminal()
.read(cx)
.last_content
.selection_text
.as_ref()
.is_some_and(|text| !text.is_empty())
});
let has_terminal_panel_selection =
workspace.panel::<TerminalPanel>(cx).is_some_and(|panel| {
let position = match TerminalSettings::get_global(cx).dock {
TerminalDockPosition::Left => DockPosition::Left,
TerminalDockPosition::Bottom => DockPosition::Bottom,
TerminalDockPosition::Right => DockPosition::Right,
};
let dock_is_open =
workspace.dock_at_position(position).read(cx).is_open();
dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty()
});
if !has_editor_selection
&& !has_terminal_selection
&& !has_terminal_panel_selection
{
return;
}
let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
return;
};
if !panel.focus_handle(cx).contains_focused(window, cx) {
workspace.toggle_panel_focus::<AgentPanel>(window, cx);
}
panel.update(cx, |_, cx| {
cx.defer_in(window, move |panel, window, cx| {
if let Some(conversation_view) = panel.active_conversation_view() {
conversation_view.update(cx, |conversation_view, cx| {
conversation_view.insert_selections(window, cx);
});
}
});
});
},
);
},
)
.detach();
}
fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock {
let mention_uri = MentionUri::MergeConflict {
file_path: conflict.file_path.clone(),
};
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new(
conflict.conflict_text.clone(),
mention_uri.to_uri().to_string(),
)),
))
}
fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec<acp::ContentBlock> {
if conflicts.is_empty() {
return Vec::new();
}
let mut blocks = Vec::new();
if conflicts.len() == 1 {
let conflict = &conflicts[0];
blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
"Please resolve the following merge conflict in ",
)));
let mention = MentionUri::File {
abs_path: PathBuf::from(conflict.file_path.clone()),
};
blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
mention.name(),
mention.to_uri(),
)));
blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
indoc::formatdoc!(
"\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs).
Analyze both versions carefully and resolve the conflict by editing \
the file directly. Choose the resolution that best preserves the intent \
of both changes, or combine them if appropriate.
",
ours = conflict.ours_branch_name,
theirs = conflict.theirs_branch_name,
),
)));
} else {
let n = conflicts.len();
let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect();
let ours = &conflicts[0].ours_branch_name;
let theirs = &conflicts[0].theirs_branch_name;
blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
indoc::formatdoc!(
"Please resolve all {n} merge conflicts below.
The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs).
For each conflict, analyze both versions carefully and resolve them \
by editing the file{suffix} directly. Choose resolutions that best preserve \
the intent of both changes, or combine them if appropriate.
",
suffix = if unique_files.len() > 1 { "s" } else { "" },
),
)));
}
for conflict in conflicts {
blocks.push(conflict_resource_block(conflict));
}
blocks
}
fn build_conflicted_files_resolution_prompt(
conflicted_file_paths: &[String],
) -> Vec<acp::ContentBlock> {
if conflicted_file_paths.is_empty() {
return Vec::new();
}
let instruction = indoc::indoc!(
"The following files have unresolved merge conflicts. Please open each \
file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \
and resolve every conflict by editing the files directly.
Choose resolutions that best preserve the intent of both changes, \
or combine them if appropriate.
Files with conflicts:
",
);
let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))];
for path in conflicted_file_paths {
let mention = MentionUri::File {
abs_path: PathBuf::from(path),
};
content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
mention.name(),
mention.to_uri(),
)));
content.push(acp::ContentBlock::Text(acp::TextContent::new("\n")));
}
content
}
enum ActiveView {
Uninitialized,
AgentThread {
conversation_view: Entity<ConversationView>,
},
History {
view: Entity<ThreadHistoryView>,
},
Configuration,
}
enum WhichFontSize {
AgentFont,
None,
}
struct StartThreadInLabel {
prefix: Option<SharedString>,
label: SharedString,
suffix: Option<SharedString>,
}
impl StartThreadIn {
fn trigger_label(&self, project: &Project, cx: &App) -> StartThreadInLabel {
match self {
Self::LocalProject => {
let suffix = project.active_repository(cx).and_then(|repo| {
let repo = repo.read(cx);
let work_dir = &repo.original_repo_abs_path;
let visible_paths: Vec<_> = project
.visible_worktrees(cx)
.map(|wt| wt.read(cx).abs_path().to_path_buf())
.collect();
for linked in repo.linked_worktrees() {
if visible_paths.contains(&linked.path) {
return Some(SharedString::from(format!(
"({})",
linked.display_name()
)));
}
}
if let Some(name) = linked_worktree_short_name(
repo.original_repo_abs_path.as_ref(),
repo.work_directory_abs_path.as_ref(),
) {
if visible_paths
.iter()
.any(|p| p.as_path() == repo.work_directory_abs_path.as_ref())
{
return Some(SharedString::from(format!("({})", name)));
}
}
if visible_paths
.iter()
.any(|p| p.as_path() == work_dir.as_ref())
{
return Some("(main)".into());
}
None
});
StartThreadInLabel {
prefix: None,
label: "Current Worktree".into(),
suffix,
}
}
Self::NewWorktree {
worktree_name: Some(worktree_name),
..
} => StartThreadInLabel {
prefix: Some("New:".into()),
label: worktree_name.clone().into(),
suffix: None,
},
Self::NewWorktree { .. } => StartThreadInLabel {
prefix: None,
label: "New Git Worktree".into(),
suffix: None,
},
Self::LinkedWorktree { display_name, .. } => StartThreadInLabel {
prefix: Some("From:".into()),
label: display_name.clone().into(),
suffix: None,
},
}
}
fn branch_trigger_label(&self, project: &Project, cx: &App) -> Option<StartThreadInLabel> {
match self {
Self::NewWorktree { branch_target, .. } => {
let (branch_name, is_occupied) = match branch_target {
NewWorktreeBranchTarget::CurrentBranch => {
let name: SharedString = if project.repositories(cx).len() > 1 {
"current branches".into()
} else {
project
.active_repository(cx)
.and_then(|repo| {
repo.read(cx)
.branch
.as_ref()
.map(|branch| SharedString::from(branch.name().to_string()))
})
.unwrap_or_else(|| "HEAD".into())
};
(name, false)
}
NewWorktreeBranchTarget::ExistingBranch { name } => {
let occupied = Self::is_branch_occupied(name, project, cx);
(name.clone().into(), occupied)
}
NewWorktreeBranchTarget::CreateBranch {
from_ref: Some(from_ref),
..
} => {
let occupied = Self::is_branch_occupied(from_ref, project, cx);
(from_ref.clone().into(), occupied)
}
NewWorktreeBranchTarget::CreateBranch { name, .. } => {
(name.clone().into(), false)
}
};
let prefix = if is_occupied {
Some("New From:".into())
} else {
None
};
Some(StartThreadInLabel {
prefix,
label: branch_name,
suffix: None,
})
}
_ => None,
}
}
fn is_branch_occupied(branch_name: &str, project: &Project, cx: &App) -> bool {
project.repositories(cx).values().any(|repo| {
repo.read(cx)
.linked_worktrees
.iter()
.any(|wt| wt.branch_name() == Some(branch_name))
})
}
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub enum WorktreeCreationStatus {
Creating,
Error(SharedString),
}
#[derive(Clone, Debug)]
enum WorktreeCreationArgs {
New {
worktree_name: Option<String>,
branch_target: NewWorktreeBranchTarget,
},
Linked {
worktree_path: PathBuf,
},
}
impl ActiveView {
pub fn which_font_size_used(&self) -> WhichFontSize {
match self {
ActiveView::Uninitialized
| ActiveView::AgentThread { .. }
| ActiveView::History { .. } => WhichFontSize::AgentFont,
ActiveView::Configuration => WhichFontSize::None,
}
}
}
pub struct AgentPanel {
workspace: WeakEntity<Workspace>,
/// Workspace id is used as a database key
workspace_id: Option<WorkspaceId>,
user_store: Entity<UserStore>,
project: Entity<Project>,
fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
connection_store: Entity<AgentConnectionStore>,
context_server_registry: Entity<ContextServerRegistry>,
configuration: Option<Entity<AgentConfiguration>>,
configuration_subscription: Option<Subscription>,
focus_handle: FocusHandle,
active_view: ActiveView,
previous_view: Option<ActiveView>,
background_threads: HashMap<acp::SessionId, Entity<ConversationView>>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
thread_branch_menu_handle: PopoverMenuHandle<ThreadBranchPicker>,
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu: Option<Entity<ContextMenu>>,
_extension_subscription: Option<Subscription>,
_project_subscription: Subscription,
zoomed: bool,
pending_serialization: Option<Task<Result<()>>>,
new_user_onboarding: Entity<AgentPanelOnboarding>,
new_user_onboarding_upsell_dismissed: AtomicBool,
agent_layout_onboarding: Entity<ai_onboarding::AgentLayoutOnboarding>,
agent_layout_onboarding_dismissed: AtomicBool,
selected_agent: Agent,
start_thread_in: StartThreadIn,
worktree_creation_status: Option<WorktreeCreationStatus>,
_thread_view_subscription: Option<Subscription>,
_active_thread_focus_subscription: Option<Subscription>,
_worktree_creation_task: Option<Task<()>>,
show_trust_workspace_message: bool,
_active_view_observation: Option<Subscription>,
}
impl AgentPanel {
fn serialize(&mut self, cx: &mut App) {
let Some(workspace_id) = self.workspace_id else {
return;
};
let selected_agent = self.selected_agent.clone();
let start_thread_in = Some(self.start_thread_in.clone());
let last_active_thread = self.active_agent_thread(cx).map(|thread| {
let thread = thread.read(cx);
let title = thread.title();
let work_dirs = thread.work_dirs().cloned();
SerializedActiveThread {
session_id: thread.session_id().0.to_string(),
agent_type: self.selected_agent.clone(),
title: title.map(|t| t.to_string()),
work_dirs: work_dirs.map(|dirs| dirs.serialize()),
}
});
let kvp = KeyValueStore::global(cx);
self.pending_serialization = Some(cx.background_spawn(async move {
save_serialized_panel(
workspace_id,
SerializedAgentPanel {
selected_agent: Some(selected_agent),
last_active_thread,
start_thread_in,
},
kvp,
)
.await?;
anyhow::Ok(())
}));
}
pub fn load(
workspace: WeakEntity<Workspace>,
mut cx: AsyncWindowContext,
) -> Task<Result<Entity<Self>>> {
let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)).ok();
cx.spawn(async move |cx| {
let prompt_store = match prompt_store {
Ok(prompt_store) => prompt_store.await.ok(),
Err(_) => None,
};
let workspace_id = workspace
.read_with(cx, |workspace, _| workspace.database_id())
.ok()
.flatten();
let (serialized_panel, global_last_used_agent) = cx
.background_spawn(async move {
match kvp {
Some(kvp) => {
let panel = workspace_id
.and_then(|id| read_serialized_panel(id, &kvp))
.or_else(|| read_legacy_serialized_panel(&kvp));
let global_agent = read_global_last_used_agent(&kvp);
(panel, global_agent)
}
None => (None, None),
}
})
.await;
let last_active_thread = if let Some(thread_info) = serialized_panel
.as_ref()
.and_then(|p| p.last_active_thread.as_ref())
{
let session_id = acp::SessionId::new(thread_info.session_id.clone());
let has_metadata = cx
.update(|_window, cx| {
let store = ThreadMetadataStore::global(cx);
store.read(cx).entry(&session_id).is_some()
})
.unwrap_or(false);
if has_metadata {
Some(thread_info)
} else {
log::warn!(
"last active thread {} has no metadata, skipping restoration",
thread_info.session_id
);
None
}
} else {
None
};
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel =
cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
panel.update(cx, |panel, cx| {
let is_via_collab = panel.project.read(cx).is_via_collab();
// Only apply a non-native global fallback to local projects.
// Collab workspaces only support NativeAgent, so inheriting a
// custom agent would cause set_active → new_agent_thread_inner
// to bypass the collab guard in external_thread.
let global_fallback = global_last_used_agent
.filter(|agent| !is_via_collab || agent.is_native());
if let Some(serialized_panel) = &serialized_panel {
if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
panel.selected_agent = selected_agent;
} else if let Some(agent) = global_fallback {
panel.selected_agent = agent;
}
if let Some(ref start_thread_in) = serialized_panel.start_thread_in {
let is_valid = match &start_thread_in {
StartThreadIn::LocalProject => true,
StartThreadIn::NewWorktree { .. } => {
let project = panel.project.read(cx);
!project.is_via_collab()
}
StartThreadIn::LinkedWorktree { path, .. } => {
path.exists()
}
};
if is_valid {
panel.start_thread_in = start_thread_in.clone();
} else {
log::info!(
"deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
start_thread_in,
);
}
}
} else if let Some(agent) = global_fallback {
panel.selected_agent = agent;
}
cx.notify();
});
if let Some(thread_info) = last_active_thread {
let agent = thread_info.agent_type.clone();
panel.update(cx, |panel, cx| {
panel.selected_agent = agent.clone();
panel.load_agent_thread(
agent,
thread_info.session_id.clone().into(),
thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)),
thread_info.title.as_ref().map(|t| t.clone().into()),
false,
window,
cx,
);
});
}
panel
})?;
Ok(panel)
})
}
pub(crate) fn new(
workspace: &Workspace,
prompt_store: Option<Entity<PromptStore>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let fs = workspace.app_state().fs.clone();
let user_store = workspace.app_state().user_store.clone();
let project = workspace.project();
let language_registry = project.read(cx).languages().clone();
let client = workspace.client().clone();
let workspace_id = workspace.database_id();
let workspace = workspace.weak_handle();
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
let thread_store = ThreadStore::global(cx);
let active_view = ActiveView::Uninitialized;
let weak_panel = cx.entity().downgrade();
window.defer(cx, move |window, cx| {
let panel = weak_panel.clone();
let agent_navigation_menu =
ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| {
if let Some(panel) = panel.upgrade() {
if let Some(history) = panel
.update(cx, |panel, cx| panel.history_for_selected_agent(window, cx))
{
menu = Self::populate_recently_updated_menu_section(
menu, panel, history, cx,
);
menu = menu.action("View All", Box::new(OpenHistory));
}
}
menu = menu
.fixed_width(px(320.).into())
.keep_open_on_confirm(false)
.key_context("NavigationMenu");
menu
});
weak_panel
.update(cx, |panel, cx| {
cx.subscribe_in(
&agent_navigation_menu,
window,
|_, menu, _: &DismissEvent, window, cx| {
menu.update(cx, |menu, _| {
menu.clear_selected();
});
cx.focus_self(window);
},
)
.detach();
panel.agent_navigation_menu = Some(agent_navigation_menu);
})
.ok();
});
let weak_panel = cx.entity().downgrade();
let onboarding = cx.new(|cx| {
AgentPanelOnboarding::new(
user_store.clone(),
client,
move |_window, cx| {
weak_panel
.update(cx, |panel, cx| {
panel.dismiss_ai_onboarding(cx);
})
.ok();
},
cx,
)
});
let weak_panel = cx.entity().downgrade();
let layout = AgentSettings::get_layout(cx);
let is_agent_layout = matches!(layout, WindowLayout::Agent(_));
let agent_layout_onboarding = cx.new(|_cx| ai_onboarding::AgentLayoutOnboarding {
use_agent_layout: Arc::new({
let fs = fs.clone();
let weak_panel = weak_panel.clone();
move |_window, cx| {
AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
weak_panel
.update(cx, |panel, cx| {
panel.dismiss_agent_layout_onboarding(cx);
})
.ok();
}
}),
revert_to_editor_layout: Arc::new({
let fs = fs.clone();
let weak_panel = weak_panel.clone();
move |_window, cx| {
AgentSettings::set_layout(WindowLayout::Editor(None), fs.clone(), cx);
weak_panel
.update(cx, |panel, cx| {
panel.dismiss_agent_layout_onboarding(cx);
})
.ok();
}
}),
dismissed: Arc::new(move |_window, cx| {
weak_panel
.update(cx, |panel, cx| {
panel.dismiss_agent_layout_onboarding(cx);
})
.ok();
}),
is_agent_layout,
});
// Subscribe to extension events to sync agent servers when extensions change
let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
{
Some(
cx.subscribe(&extension_events, |this, _source, event, cx| match event {
extension::Event::ExtensionInstalled(_)
| extension::Event::ExtensionUninstalled(_)
| extension::Event::ExtensionsInstalledChanged => {
this.sync_agent_servers_from_extensions(cx);
}
_ => {}
}),
)
} else {
None
};
let connection_store = cx.new(|cx| {
let mut store = AgentConnectionStore::new(project.clone(), cx);
// Register the native agent right away, so that it is available for
// the inline assistant etc.
store.request_connection(
Agent::NativeAgent,
Agent::NativeAgent.server(fs.clone(), thread_store.clone()),
cx,
);
store
});
let _project_subscription =
cx.subscribe(&project, |this, _project, event, cx| match event {
project::Event::WorktreeAdded(_)
| project::Event::WorktreeRemoved(_)
| project::Event::WorktreeOrderChanged => {
this.update_thread_work_dirs(cx);
}
_ => {}
});
let mut panel = Self {
workspace_id,
active_view,
workspace,
user_store,
project: project.clone(),
fs: fs.clone(),
language_registry,
prompt_store,
connection_store,
configuration: None,
configuration_subscription: None,
focus_handle: cx.focus_handle(),
context_server_registry,
previous_view: None,
background_threads: HashMap::default(),
new_thread_menu_handle: PopoverMenuHandle::default(),
start_thread_in_menu_handle: PopoverMenuHandle::default(),
thread_branch_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu: None,
_extension_subscription: extension_subscription,
_project_subscription,
zoomed: false,
pending_serialization: None,
new_user_onboarding: onboarding,
agent_layout_onboarding,
thread_store,
selected_agent: Agent::default(),
start_thread_in: StartThreadIn::default(),
worktree_creation_status: None,
_thread_view_subscription: None,
_active_thread_focus_subscription: None,
_worktree_creation_task: None,
show_trust_workspace_message: false,
new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
agent_layout_onboarding_dismissed: AtomicBool::new(AgentLayoutOnboarding::dismissed(
cx,
)),
_active_view_observation: None,
};
// Initial sync of agent servers from extensions
panel.sync_agent_servers_from_extensions(cx);
panel
}
pub fn toggle_focus(
workspace: &mut Workspace,
_: &ToggleFocus,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
if workspace
.panel::<Self>(cx)
.is_some_and(|panel| panel.read(cx).enabled(cx))
{
workspace.toggle_panel_focus::<Self>(window, cx);
}
}
pub fn toggle(
workspace: &mut Workspace,
_: &Toggle,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
if workspace
.panel::<Self>(cx)
.is_some_and(|panel| panel.read(cx).enabled(cx))
{
if !workspace.toggle_panel_focus::<Self>(window, cx) {
workspace.close_panel::<Self>(window, cx);
}
}
}
pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
&self.prompt_store
}
pub fn thread_store(&self) -> &Entity<ThreadStore> {
&self.thread_store
}
pub fn connection_store(&self) -> &Entity<AgentConnectionStore> {
&self.connection_store
}
pub fn open_thread(
&mut self,
session_id: acp::SessionId,
work_dirs: Option<PathList>,
title: Option<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.external_thread(
Some(crate::Agent::NativeAgent),
Some(session_id),
work_dirs,
title,
None,
true,
window,
cx,
);
}
pub(crate) fn context_server_registry(&self) -> &Entity<ContextServerRegistry> {
&self.context_server_registry
}
pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
let workspace_read = workspace.read(cx);
workspace_read
.panel::<AgentPanel>(cx)
.map(|panel| {
let panel_id = Entity::entity_id(&panel);
workspace_read.all_docks().iter().any(|dock| {
dock.read(cx)
.visible_panel()
.is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
})
})
.unwrap_or(false)
}
pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
self.reset_start_thread_in_to_default(cx);
self.external_thread(None, None, None, None, None, true, window, cx);
}
fn new_native_agent_thread_from_summary(
&mut self,
action: &NewNativeAgentThreadFromSummary,
window: &mut Window,
cx: &mut Context<Self>,
) {
let session_id = action.from_session_id.clone();
let Some(history) = self
.connection_store
.read(cx)
.entry(&Agent::NativeAgent)
.and_then(|e| e.read(cx).history().cloned())
else {
debug_panic!("Native agent is not registered");
return;
};
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
let thread = history
.read(cx)
.session_for_id(&session_id)
.context("Session not found")?;
this.external_thread(
Some(Agent::NativeAgent),
None,
None,
None,
Some(AgentInitialContent::ThreadSummary {
session_id: thread.session_id,
title: thread.title,
}),
true,
window,
cx,
);
anyhow::Ok(())
})
})
.detach_and_log_err(cx);
}
fn external_thread(
&mut self,
agent_choice: Option<crate::Agent>,
resume_session_id: Option<acp::SessionId>,
work_dirs: Option<PathList>,
title: Option<SharedString>,
initial_content: Option<AgentInitialContent>,
focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let workspace = self.workspace.clone();
let project = self.project.clone();
let fs = self.fs.clone();
let thread_store = self.thread_store.clone();
let agent = agent_choice.unwrap_or_else(|| {
if self.project.read(cx).is_via_collab() {
Agent::NativeAgent
} else {
self.selected_agent.clone()
}
});
let server = agent.server(fs, thread_store);
self.create_agent_thread(
server,
resume_session_id,
work_dirs,
title,
initial_content,
workspace,
project,
agent,
focus,
window,
cx,
);
}
fn deploy_rules_library(
&mut self,
action: &OpenRulesLibrary,
_window: &mut Window,
cx: &mut Context<Self>,
) {
open_rules_library(
self.language_registry.clone(),
Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
action
.prompt_to_select
.map(|uuid| UserPromptId(uuid).into()),
cx,
)
.detach_and_log_err(cx);
}
fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(conversation_view) = self.active_conversation_view() else {
return;
};
let Some(active_thread) = conversation_view.read(cx).active_thread().cloned() else {
return;
};
active_thread.update(cx, |active_thread, cx| {
active_thread.expand_message_editor(&ExpandMessageEditor, window, cx);
active_thread.focus_handle(cx).focus(window, cx);
})
}
fn has_history_for_selected_agent(&self, cx: &App) -> bool {
match &self.selected_agent {
Agent::NativeAgent => true,
Agent::Custom { .. } => self
.connection_store
.read(cx)
.entry(&self.selected_agent)
.map_or(false, |entry| entry.read(cx).history().is_some()),
}
}
fn history_for_selected_agent(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Entity<ThreadHistoryView>> {
let agent = self.selected_agent.clone();
let history = self
.connection_store
.read(cx)
.entry(&agent)?
.read(cx)
.history()?
.clone();
Some(self.create_thread_history_view(agent, history, window, cx))
}
fn create_thread_history_view(
&self,
agent: Agent,
history: Entity<ThreadHistory>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<ThreadHistoryView> {
let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx));
cx.subscribe_in(
&view,
window,
move |this, _, event, window, cx| match event {
ThreadHistoryViewEvent::Open(thread) => {
this.load_agent_thread(
agent.clone(),
thread.session_id.clone(),
thread.work_dirs.clone(),
thread.title.clone(),
true,
window,
cx,
);
}
},
)
.detach();
view
}
fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(view) = self.history_for_selected_agent(window, cx) else {
return;
};
if let ActiveView::History { view: active_view } = &self.active_view {
if active_view == &view {
if let Some(previous_view) = self.previous_view.take() {
self.set_active_view(previous_view, true, window, cx);
}
return;
}
}
self.set_active_view(ActiveView::History { view }, true, window, cx);
cx.notify();
}
pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
match self.active_view {
ActiveView::Configuration | ActiveView::History { .. } => {
if let Some(previous_view) = self.previous_view.take() {
self.set_active_view(previous_view, true, window, cx);
}
cx.notify();
}
_ => {}
}
}
pub fn toggle_navigation_menu(
&mut self,
_: &ToggleNavigationMenu,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.has_history_for_selected_agent(cx) {
return;
}
self.agent_navigation_menu_handle.toggle(window, cx);
}
pub fn toggle_options_menu(
&mut self,
_: &ToggleOptionsMenu,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.agent_panel_menu_handle.toggle(window, cx);
}
pub fn toggle_new_thread_menu(
&mut self,
_: &ToggleNewThreadMenu,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.new_thread_menu_handle.toggle(window, cx);
}
pub fn increase_font_size(
&mut self,
action: &IncreaseBufferFontSize,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.handle_font_size_action(action.persist, px(1.0), cx);
}
pub fn decrease_font_size(
&mut self,
action: &DecreaseBufferFontSize,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.handle_font_size_action(action.persist, px(-1.0), cx);
}
fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
match self.active_view.which_font_size_used() {
WhichFontSize::AgentFont => {
if persist {
update_settings_file(self.fs.clone(), cx, move |settings, cx| {
let agent_ui_font_size =
ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta;
let agent_buffer_font_size =
ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta;
let _ = settings.theme.agent_ui_font_size.insert(
f32::from(theme_settings::clamp_font_size(agent_ui_font_size)).into(),
);
let _ = settings.theme.agent_buffer_font_size.insert(
f32::from(theme_settings::clamp_font_size(agent_buffer_font_size))
.into(),
);
});
} else {
theme_settings::adjust_agent_ui_font_size(cx, |size| size + delta);
theme_settings::adjust_agent_buffer_font_size(cx, |size| size + delta);
}
}
WhichFontSize::None => {}
}
}
pub fn reset_font_size(
&mut self,
action: &ResetBufferFontSize,
_: &mut Window,
cx: &mut Context<Self>,
) {
if action.persist {
update_settings_file(self.fs.clone(), cx, move |settings, _| {
settings.theme.agent_ui_font_size = None;
settings.theme.agent_buffer_font_size = None;
});
} else {
theme_settings::reset_agent_ui_font_size(cx);
theme_settings::reset_agent_buffer_font_size(cx);
}
}
pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
theme_settings::reset_agent_ui_font_size(cx);
theme_settings::reset_agent_buffer_font_size(cx);
}
pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
if self.zoomed {
cx.emit(PanelEvent::ZoomOut);
} else {
if !self.focus_handle(cx).contains_focused(window, cx) {
cx.focus_self(window);
}
cx.emit(PanelEvent::ZoomIn);
}
}
pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let agent_server_store = self.project.read(cx).agent_server_store().clone();
let context_server_store = self.project.read(cx).context_server_store();
let fs = self.fs.clone();
self.set_active_view(ActiveView::Configuration, true, window, cx);
self.configuration = Some(cx.new(|cx| {
AgentConfiguration::new(
fs,
agent_server_store,
self.connection_store.clone(),
context_server_store,
self.context_server_registry.clone(),
self.language_registry.clone(),
self.workspace.clone(),
window,
cx,
)
}));
if let Some(configuration) = self.configuration.as_ref() {
self.configuration_subscription = Some(cx.subscribe_in(
configuration,
window,
Self::handle_agent_configuration_event,
));
configuration.focus_handle(cx).focus(window, cx);
}
}
pub(crate) fn open_active_thread_as_markdown(
&mut self,
_: &OpenActiveThreadAsMarkdown,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(workspace) = self.workspace.upgrade()
&& let Some(conversation_view) = self.active_conversation_view()
&& let Some(active_thread) = conversation_view.read(cx).active_thread().cloned()
{
active_thread.update(cx, |thread, cx| {
thread
.open_thread_as_markdown(workspace, window, cx)
.detach_and_log_err(cx);
});
}
}
fn copy_thread_to_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(thread) = self.active_native_agent_thread(cx) else {
Self::show_deferred_toast(&self.workspace, "No active native thread to copy", cx);
return;
};
let workspace = self.workspace.clone();
let load_task = thread.read(cx).to_db(cx);
cx.spawn_in(window, async move |_this, cx| {
let db_thread = load_task.await;
let shared_thread = SharedThread::from_db_thread(&db_thread);
let thread_data = shared_thread.to_bytes()?;
let encoded = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &thread_data);
cx.update(|_window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(encoded));
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
struct ThreadCopiedToast;
workspace.show_toast(
workspace::Toast::new(
workspace::notifications::NotificationId::unique::<ThreadCopiedToast>(),
"Thread copied to clipboard (base64 encoded)",
)
.autohide(),
cx,
);
});
}
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn show_deferred_toast(
workspace: &WeakEntity<workspace::Workspace>,
message: &'static str,
cx: &mut App,
) {
let workspace = workspace.clone();
cx.defer(move |cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
struct ClipboardToast;
workspace.show_toast(
workspace::Toast::new(
workspace::notifications::NotificationId::unique::<ClipboardToast>(),
message,
)
.autohide(),
cx,
);
});
}
});
}
fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(clipboard) = cx.read_from_clipboard() else {
Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx);
return;
};
let Some(encoded) = clipboard.text() else {
Self::show_deferred_toast(&self.workspace, "Clipboard does not contain text", cx);
return;
};
let thread_data = match base64::Engine::decode(&base64::prelude::BASE64_STANDARD, &encoded)
{
Ok(data) => data,
Err(_) => {
Self::show_deferred_toast(
&self.workspace,
"Failed to decode clipboard content (expected base64)",
cx,
);
return;
}
};
let shared_thread = match SharedThread::from_bytes(&thread_data) {
Ok(thread) => thread,
Err(_) => {
Self::show_deferred_toast(
&self.workspace,
"Failed to parse thread data from clipboard",
cx,
);
return;
}
};
let db_thread = shared_thread.to_db_thread();
let session_id = acp::SessionId::new(uuid::Uuid::new_v4().to_string());
let thread_store = self.thread_store.clone();
let title = db_thread.title.clone();
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |this, cx| {
thread_store
.update(&mut cx.clone(), |store, cx| {
store.save_thread(session_id.clone(), db_thread, Default::default(), cx)
})
.await?;
this.update_in(cx, |this, window, cx| {
this.open_thread(session_id, None, Some(title), window, cx);
})?;
this.update_in(cx, |_, _window, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
struct ThreadLoadedToast;
workspace.show_toast(
workspace::Toast::new(
workspace::notifications::NotificationId::unique::<ThreadLoadedToast>(),
"Thread loaded from clipboard",
)
.autohide(),
cx,
);
});
}
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn handle_agent_configuration_event(
&mut self,
_entity: &Entity<AgentConfiguration>,
event: &AssistantConfigurationEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
AssistantConfigurationEvent::NewThread(provider) => {
if LanguageModelRegistry::read_global(cx)
.default_model()
.is_none_or(|model| model.provider.id() != provider.id())
&& let Some(model) = provider.default_model(cx)
{
update_settings_file(self.fs.clone(), cx, move |settings, _| {
let provider = model.provider_id().0.to_string();
let enable_thinking = model.supports_thinking();
let effort = model
.default_effort_level()
.map(|effort| effort.value.to_string());
let model = model.id().0.to_string();
settings
.agent
.get_or_insert_default()
.set_model(LanguageModelSelection {
provider: LanguageModelProviderSetting(provider),
model,
enable_thinking,
effort,
})
});
}
self.new_thread(&NewThread, window, cx);
if let Some((thread, model)) = self
.active_native_agent_thread(cx)
.zip(provider.default_model(cx))
{
thread.update(cx, |thread, cx| {
thread.set_model(model, cx);
});
}
}
}
}
pub fn workspace_id(&self) -> Option<WorkspaceId> {
self.workspace_id
}
pub fn background_threads(&self) -> &HashMap<acp::SessionId, Entity<ConversationView>> {
&self.background_threads
}
pub fn active_conversation_view(&self) -> Option<&Entity<ConversationView>> {
match &self.active_view {
ActiveView::AgentThread { conversation_view } => Some(conversation_view),
_ => None,
}
}
pub fn active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
let server_view = self.active_conversation_view()?;
server_view.read(cx).active_thread().cloned()
}
pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
match &self.active_view {
ActiveView::AgentThread {
conversation_view, ..
} => conversation_view
.read(cx)
.active_thread()
.map(|r| r.read(cx).thread.clone()),
_ => None,
}
}
/// Returns the primary thread views for all retained connections: the
pub fn is_background_thread(&self, session_id: &acp::SessionId) -> bool {
self.background_threads.contains_key(session_id)
}
pub fn cancel_thread(&self, session_id: &acp::SessionId, cx: &mut Context<Self>) -> bool {
let conversation_views = self
.active_conversation_view()
.into_iter()
.chain(self.background_threads.values());
for conversation_view in conversation_views {
if let Some(thread_view) = conversation_view.read(cx).thread_view(session_id) {
thread_view.update(cx, |view, cx| view.cancel_generation(cx));
return true;
}
}
false
}
/// active thread plus any background threads that are still running or
/// completed but unseen.
pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
let mut views = Vec::new();
if let Some(server_view) = self.active_conversation_view() {
if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
views.push(thread_view);
}
}
for server_view in self.background_threads.values() {
if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
views.push(thread_view);
}
}
views
}
fn update_thread_work_dirs(&self, cx: &mut Context<Self>) {
let new_work_dirs = self.project.read(cx).default_path_list(cx);
if let Some(conversation_view) = self.active_conversation_view() {
conversation_view.update(cx, |conversation_view, cx| {
conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
});
}
for conversation_view in self.background_threads.values() {
conversation_view.update(cx, |conversation_view, cx| {
conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
});
}
}
fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context<Self>) {
let ActiveView::AgentThread { conversation_view } = old_view else {
return;
};
let Some(thread_view) = conversation_view.read(cx).root_thread(cx) else {
return;
};
if thread_view.read(cx).thread.read(cx).entries().is_empty() {
return;
}
self.background_threads
.insert(thread_view.read(cx).id.clone(), conversation_view);
self.cleanup_background_threads(cx);
}
/// We keep threads that are:
/// - Still running
/// - Do not support reloading the full session
/// - Have had the most recent events (up to 5 idle threads)
fn cleanup_background_threads(&mut self, cx: &App) {
let mut potential_removals = self
.background_threads
.iter()
.filter(|(_id, view)| {
let Some(thread_view) = view.read(cx).root_thread(cx) else {
return true;
};
let thread = thread_view.read(cx).thread.read(cx);
thread.connection().supports_load_session() && thread.status() == ThreadStatus::Idle
})
.collect::<Vec<_>>();
const MAX_IDLE_BACKGROUND_THREADS: usize = 5;
potential_removals.sort_unstable_by_key(|(_, view)| view.read(cx).updated_at(cx));
let n = potential_removals
.len()
.saturating_sub(MAX_IDLE_BACKGROUND_THREADS);
let to_remove = potential_removals
.into_iter()
.map(|(id, _)| id.clone())
.take(n)
.collect::<Vec<_>>();
for id in to_remove {
self.background_threads.remove(&id);
}
}
pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
match &self.active_view {
ActiveView::AgentThread {
conversation_view, ..
} => conversation_view.read(cx).as_native_thread(cx),
_ => None,
}
}
fn set_active_view(
&mut self,
new_view: ActiveView,
focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let was_in_agent_history = matches!(self.active_view, ActiveView::History { .. });
let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized);
let current_is_history = matches!(self.active_view, ActiveView::History { .. });
let new_is_history = matches!(new_view, ActiveView::History { .. });
let current_is_config = matches!(self.active_view, ActiveView::Configuration);
let new_is_config = matches!(new_view, ActiveView::Configuration);
let current_is_overlay = current_is_history || current_is_config;
let new_is_overlay = new_is_history || new_is_config;
if current_is_uninitialized || (current_is_overlay && !new_is_overlay) {
self.active_view = new_view;
} else if !current_is_overlay && new_is_overlay {
self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
} else {
let old_view = std::mem::replace(&mut self.active_view, new_view);
if !new_is_overlay {
if let Some(previous) = self.previous_view.take() {
self.retain_running_thread(previous, cx);
}
}
self.retain_running_thread(old_view, cx);
}
// Subscribe to the active ThreadView's events (e.g. FirstSendRequested)
// so the panel can intercept the first send for worktree creation.
// Re-subscribe whenever the ConnectionView changes, since the inner
// ThreadView may have been replaced (e.g. navigating between threads).
self._active_view_observation = match &self.active_view {
ActiveView::AgentThread { conversation_view } => {
self._thread_view_subscription =
Self::subscribe_to_active_thread_view(conversation_view, window, cx);
let focus_handle = conversation_view.focus_handle(cx);
self._active_thread_focus_subscription =
Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| {
cx.emit(AgentPanelEvent::ThreadFocused);
cx.notify();
}));
Some(cx.observe_in(
conversation_view,
window,
|this, server_view, window, cx| {
this._thread_view_subscription =
Self::subscribe_to_active_thread_view(&server_view, window, cx);
cx.emit(AgentPanelEvent::ActiveViewChanged);
this.serialize(cx);
cx.notify();
},
))
}
_ => {
self._thread_view_subscription = None;
self._active_thread_focus_subscription = None;
None
}
};
if let ActiveView::History { view } = &self.active_view {
if !was_in_agent_history {
view.update(cx, |view, cx| {
view.history()
.update(cx, |history, cx| history.refresh_full_history(cx))
});
}
}
if focus {
self.focus_handle(cx).focus(window, cx);
}
cx.emit(AgentPanelEvent::ActiveViewChanged);
}
fn populate_recently_updated_menu_section(
mut menu: ContextMenu,
panel: Entity<Self>,
view: Entity<ThreadHistoryView>,
cx: &mut Context<ContextMenu>,
) -> ContextMenu {
let entries = view
.read(cx)
.history()
.read(cx)
.sessions()
.iter()
.take(RECENTLY_UPDATED_MENU_LIMIT)
.cloned()
.collect::<Vec<_>>();
if entries.is_empty() {
return menu;
}
menu = menu.header("Recently Updated");
for entry in entries {
let title = entry
.title
.as_ref()
.filter(|title| !title.is_empty())
.cloned()
.unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
menu = menu.entry(title, None, {
let panel = panel.downgrade();
let entry = entry.clone();
move |window, cx| {
let entry = entry.clone();
panel
.update(cx, move |this, cx| {
if let Some(agent) = this.selected_agent() {
this.load_agent_thread(
agent,
entry.session_id.clone(),
entry.work_dirs.clone(),
entry.title.clone(),
true,
window,
cx,
);
}
})
.ok();
}
});
}
menu.separator()
}
fn subscribe_to_active_thread_view(
server_view: &Entity<ConversationView>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Subscription> {
server_view.read(cx).active_thread().cloned().map(|tv| {
cx.subscribe_in(
&tv,
window,
|this, view, event: &AcpThreadViewEvent, window, cx| match event {
AcpThreadViewEvent::FirstSendRequested { content } => {
this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
}
AcpThreadViewEvent::MessageSentOrQueued => {
let session_id = view.read(cx).thread.read(cx).session_id().clone();
cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id });
}
},
)
})
}
pub fn start_thread_in(&self) -> &StartThreadIn {
&self.start_thread_in
}
fn set_start_thread_in(
&mut self,
action: &StartThreadIn,
window: &mut Window,
cx: &mut Context<Self>,
) {
let new_target = match action {
StartThreadIn::LocalProject => StartThreadIn::LocalProject,
StartThreadIn::NewWorktree { .. } => {
if !self.project_has_git_repository(cx) {
log::error!(
"set_start_thread_in: cannot use worktree mode without a git repository"
);
return;
}
if self.project.read(cx).is_via_collab() {
log::error!(
"set_start_thread_in: cannot use worktree mode in a collab project"
);
return;
}
action.clone()
}
StartThreadIn::LinkedWorktree { .. } => {
if !self.project_has_git_repository(cx) {
log::error!(
"set_start_thread_in: cannot use LinkedWorktree without a git repository"
);
return;
}
if self.project.read(cx).is_via_collab() {
log::error!(
"set_start_thread_in: cannot use LinkedWorktree in a collab project"
);
return;
}
action.clone()
}
};
self.start_thread_in = new_target;
if let Some(thread) = self.active_thread_view(cx) {
thread.update(cx, |thread, cx| thread.focus_handle(cx).focus(window, cx));
}
self.serialize(cx);
cx.notify();
}
fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let next = match &self.start_thread_in {
StartThreadIn::LocalProject => StartThreadIn::NewWorktree {
worktree_name: None,
branch_target: NewWorktreeBranchTarget::default(),
},
StartThreadIn::NewWorktree { .. } | StartThreadIn::LinkedWorktree { .. } => {
StartThreadIn::LocalProject
}
};
self.set_start_thread_in(&next, window, cx);
}
fn reset_start_thread_in_to_default(&mut self, cx: &mut Context<Self>) {
use settings::{NewThreadLocation, Settings};
let default = AgentSettings::get_global(cx).new_thread_location;
let start_thread_in = match default {
NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
NewThreadLocation::NewWorktree => {
if self.project_has_git_repository(cx) {
StartThreadIn::NewWorktree {
worktree_name: None,
branch_target: NewWorktreeBranchTarget::default(),
}
} else {
StartThreadIn::LocalProject
}
}
};
if self.start_thread_in != start_thread_in {
self.start_thread_in = start_thread_in;
self.serialize(cx);
cx.notify();
}
}
pub(crate) fn selected_agent(&self) -> Option<Agent> {
Some(self.selected_agent.clone())
}
fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
if let Some(extension_store) = ExtensionStore::try_global(cx) {
let (manifests, extensions_dir) = {
let store = extension_store.read(cx);
let installed = store.installed_extensions();
let manifests: Vec<_> = installed
.iter()
.map(|(id, entry)| (id.clone(), entry.manifest.clone()))
.collect();
let extensions_dir = paths::extensions_dir().join("installed");
(manifests, extensions_dir)
};
self.project.update(cx, |project, cx| {
project.agent_server_store().update(cx, |store, cx| {
let manifest_refs: Vec<_> = manifests
.iter()
.map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
.collect();
store.sync_extension_agents(manifest_refs, extensions_dir, cx);
});
});
}
}
pub fn new_agent_thread_with_external_source_prompt(
&mut self,
external_source_prompt: Option<ExternalSourcePrompt>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.external_thread(
None,
None,
None,
None,
external_source_prompt.map(AgentInitialContent::from),
true,
window,
cx,
);
}
pub fn new_agent_thread(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
self.reset_start_thread_in_to_default(cx);
self.new_agent_thread_inner(agent, true, window, cx);
}
fn new_agent_thread_inner(
&mut self,
agent: Agent,
focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.external_thread(Some(agent), None, None, None, None, focus, window, cx);
}
pub fn load_agent_thread(
&mut self,
agent: Agent,
session_id: acp::SessionId,
work_dirs: Option<PathList>,
title: Option<SharedString>,
focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(store) = ThreadMetadataStore::try_global(cx) {
store.update(cx, |store, cx| store.unarchive(&session_id, cx));
}
if let Some(conversation_view) = self.background_threads.remove(&session_id) {
self.set_active_view(
ActiveView::AgentThread { conversation_view },
focus,
window,
cx,
);
return;
}
if let ActiveView::AgentThread { conversation_view } = &self.active_view {
if conversation_view
.read(cx)
.active_thread()
.map(|t| t.read(cx).id.clone())
== Some(session_id.clone())
{
cx.emit(AgentPanelEvent::ActiveViewChanged);
return;
}
}
if let Some(ActiveView::AgentThread { conversation_view }) = &self.previous_view {
if conversation_view
.read(cx)
.active_thread()
.map(|t| t.read(cx).id.clone())
== Some(session_id.clone())
{
let view = self.previous_view.take().unwrap();
self.set_active_view(view, focus, window, cx);
return;
}
}
self.external_thread(
Some(agent),
Some(session_id),
work_dirs,
title,
None,
focus,
window,
cx,
);
}
pub(crate) fn create_agent_thread(
&mut self,
server: Rc<dyn AgentServer>,
resume_session_id: Option<acp::SessionId>,
work_dirs: Option<PathList>,
title: Option<SharedString>,
initial_content: Option<AgentInitialContent>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
agent: Agent,
focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.selected_agent != agent {
self.selected_agent = agent.clone();
self.serialize(cx);
}
cx.background_spawn({
let kvp = KeyValueStore::global(cx);
let agent = agent.clone();
async move {
write_global_last_used_agent(kvp, agent).await;
}
})
.detach();
let thread_store = server
.clone()
.downcast::<agent::NativeAgentServer>()
.is_some()
.then(|| self.thread_store.clone());
let connection_store = self.connection_store.clone();
let conversation_view = cx.new(|cx| {
crate::ConversationView::new(
server,
connection_store,
agent,
resume_session_id,
work_dirs,
title,
initial_content,
workspace.clone(),
project,
thread_store,
self.prompt_store.clone(),
window,
cx,
)
});
cx.observe(&conversation_view, |this, server_view, cx| {
let is_active = this
.active_conversation_view()
.is_some_and(|active| active.entity_id() == server_view.entity_id());
if is_active {
cx.emit(AgentPanelEvent::ActiveViewChanged);
this.serialize(cx);
} else {
cx.emit(AgentPanelEvent::BackgroundThreadChanged);
}
cx.notify();
})
.detach();
self.set_active_view(
ActiveView::AgentThread { conversation_view },
focus,
window,
cx,
);
}
fn active_thread_has_messages(&self, cx: &App) -> bool {
self.active_agent_thread(cx)
.is_some_and(|thread| !thread.read(cx).entries().is_empty())
}
pub fn active_thread_is_draft(&self, cx: &App) -> bool {
self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
}
fn handle_first_send_requested(
&mut self,
thread_view: Entity<ThreadView>,
content: Vec<acp::ContentBlock>,
window: &mut Window,
cx: &mut Context<Self>,
) {
match &self.start_thread_in {
StartThreadIn::NewWorktree {
worktree_name,
branch_target,
} => {
self.handle_worktree_requested(
content,
WorktreeCreationArgs::New {
worktree_name: worktree_name.clone(),
branch_target: branch_target.clone(),
},
window,
cx,
);
}
StartThreadIn::LinkedWorktree { path, .. } => {
self.handle_worktree_requested(
content,
WorktreeCreationArgs::Linked {
worktree_path: path.clone(),
},
window,
cx,
);
}
StartThreadIn::LocalProject => {
cx.defer_in(window, move |_this, window, cx| {
thread_view.update(cx, |thread_view, cx| {
let editor = thread_view.message_editor.clone();
thread_view.send_impl(editor, window, cx);
});
});
}
}
}
// TODO: The mapping from workspace root paths to git repositories needs a
// unified approach across the codebase: this method, `sidebar::is_root_repo`,
// thread persistence (which PathList is saved to the database), and thread
// querying (which PathList is used to read threads back). All of these need
// to agree on how repos are resolved for a given workspace, especially in
// multi-root and nested-repo configurations.
/// Partitions the project's visible worktrees into git-backed repositories
/// and plain (non-git) paths. Git repos will have worktrees created for
/// them; non-git paths are carried over to the new workspace as-is.
///
/// When multiple worktrees map to the same repository, the most specific
/// match wins (deepest work directory path), with a deterministic
/// tie-break on entity id. Each repository appears at most once.
fn classify_worktrees(
&self,
cx: &App,
) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
let project = &self.project;
let repositories = project.read(cx).repositories(cx).clone();
let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
let mut non_git_paths: Vec<PathBuf> = Vec::new();
let mut seen_repo_ids = std::collections::HashSet::new();
for worktree in project.read(cx).visible_worktrees(cx) {
let wt_path = worktree.read(cx).abs_path();
let matching_repo = repositories
.iter()
.filter_map(|(id, repo)| {
let work_dir = repo.read(cx).work_directory_abs_path.clone();
if wt_path.starts_with(work_dir.as_ref())
|| work_dir.starts_with(wt_path.as_ref())
{
Some((*id, repo.clone(), work_dir.as_ref().components().count()))
} else {
None
}
})
.max_by(
|(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
left_depth
.cmp(right_depth)
.then_with(|| left_id.cmp(right_id))
},
);
if let Some((id, repo, _)) = matching_repo {
if seen_repo_ids.insert(id) {
git_repos.push(repo);
}
} else {
non_git_paths.push(wt_path.to_path_buf());
}
}
(git_repos, non_git_paths)
}
fn resolve_worktree_branch_target(
branch_target: &NewWorktreeBranchTarget,
existing_branches: &HashSet<String>,
occupied_branches: &HashSet<String>,
) -> Result<(String, bool, Option<String>)> {
let generate_branch_name = || -> Result<String> {
let refs: Vec<&str> = existing_branches.iter().map(|s| s.as_str()).collect();
let mut rng = rand::rng();
crate::branch_names::generate_branch_name(&refs, &mut rng)
.ok_or_else(|| anyhow!("Failed to generate a unique branch name"))
};
match branch_target {
NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
Ok((name.clone(), false, from_ref.clone()))
}
NewWorktreeBranchTarget::ExistingBranch { name } => {
if occupied_branches.contains(name) {
Ok((generate_branch_name()?, false, Some(name.clone())))
} else {
Ok((name.clone(), true, None))
}
}
NewWorktreeBranchTarget::CurrentBranch => Ok((generate_branch_name()?, false, None)),
}
}
/// Kicks off an async git-worktree creation for each repository. Returns:
///
/// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
/// receiver resolves once the git worktree command finishes.
/// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
/// later to remap open editor tabs into the new workspace.
fn start_worktree_creations(
git_repos: &[Entity<project::git_store::Repository>],
worktree_name: Option<String>,
branch_name: &str,
use_existing_branch: bool,
start_point: Option<String>,
worktree_directory_setting: &str,
cx: &mut Context<Self>,
) -> Result<(
Vec<(
Entity<project::git_store::Repository>,
PathBuf,
futures::channel::oneshot::Receiver<Result<()>>,
)>,
Vec<(PathBuf, PathBuf)>,
)> {
let mut creation_infos = Vec::new();
let mut path_remapping = Vec::new();
let worktree_name = worktree_name.unwrap_or_else(|| branch_name.to_string());
for repo in git_repos {
let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
let new_path =
repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
let target = if use_existing_branch {
debug_assert!(
git_repos.len() == 1,
"use_existing_branch should only be true for a single repo"
);
git::repository::CreateWorktreeTarget::ExistingBranch {
branch_name: branch_name.to_string(),
}
} else {
git::repository::CreateWorktreeTarget::NewBranch {
branch_name: branch_name.to_string(),
base_sha: start_point.clone(),
}
};
let receiver = repo.create_worktree(target, new_path.clone());
let work_dir = repo.work_directory_abs_path.clone();
anyhow::Ok((work_dir, new_path, receiver))
})?;
path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
creation_infos.push((repo.clone(), new_path, receiver));
}
Ok((creation_infos, path_remapping))
}
/// Waits for every in-flight worktree creation to complete. If any
/// creation fails, all successfully-created worktrees are rolled back
/// (removed) so the project isn't left in a half-migrated state.
async fn await_and_rollback_on_failure(
creation_infos: Vec<(
Entity<project::git_store::Repository>,
PathBuf,
futures::channel::oneshot::Receiver<Result<()>>,
)>,
cx: &mut AsyncWindowContext,
) -> Result<Vec<PathBuf>> {
let mut created_paths: Vec<PathBuf> = Vec::new();
let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
Vec::new();
let mut first_error: Option<anyhow::Error> = None;
for (repo, new_path, receiver) in creation_infos {
match receiver.await {
Ok(Ok(())) => {
created_paths.push(new_path.clone());
repos_and_paths.push((repo, new_path));
}
Ok(Err(err)) => {
if first_error.is_none() {
first_error = Some(err);
}
}
Err(_canceled) => {
if first_error.is_none() {
first_error = Some(anyhow!("Worktree creation was canceled"));
}
}
}
}
let Some(err) = first_error else {
return Ok(created_paths);
};
// Rollback all successfully created worktrees
let mut rollback_receivers = Vec::new();
for (rollback_repo, rollback_path) in &repos_and_paths {
if let Ok(receiver) = cx.update(|_, cx| {
rollback_repo.update(cx, |repo, _cx| {
repo.remove_worktree(rollback_path.clone(), true)
})
}) {
rollback_receivers.push((rollback_path.clone(), receiver));
}
}
let mut rollback_failures: Vec<String> = Vec::new();
for (path, receiver) in rollback_receivers {
match receiver.await {
Ok(Ok(())) => {}
Ok(Err(rollback_err)) => {
log::error!(
"failed to rollback worktree at {}: {rollback_err}",
path.display()
);
rollback_failures.push(format!("{}: {rollback_err}", path.display()));
}
Err(rollback_err) => {
log::error!(
"failed to rollback worktree at {}: {rollback_err}",
path.display()
);
rollback_failures.push(format!("{}: {rollback_err}", path.display()));
}
}
}
let mut error_message = format!("Failed to create worktree: {err}");
if !rollback_failures.is_empty() {
error_message.push_str("\n\nFailed to clean up: ");
error_message.push_str(&rollback_failures.join(", "));
}
Err(anyhow!(error_message))
}
fn set_worktree_creation_error(
&mut self,
message: SharedString,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message));
if matches!(self.active_view, ActiveView::Uninitialized) {
let selected_agent = self.selected_agent.clone();
self.new_agent_thread(selected_agent, window, cx);
}
cx.notify();
}
fn handle_worktree_requested(
&mut self,
content: Vec<acp::ContentBlock>,
args: WorktreeCreationArgs,
window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(
self.worktree_creation_status,
Some(WorktreeCreationStatus::Creating)
) {
return;
}
self.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
cx.notify();
let (git_repos, non_git_paths) = self.classify_worktrees(cx);
if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() {
self.set_worktree_creation_error(
"No git repositories found in the project".into(),
window,
cx,
);
return;
}
let (branch_receivers, worktree_receivers, worktree_directory_setting) =
if matches!(args, WorktreeCreationArgs::New { .. }) {
(
Some(
git_repos
.iter()
.map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
.collect::<Vec<_>>(),
),
Some(
git_repos
.iter()
.map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
.collect::<Vec<_>>(),
),
Some(
ProjectSettings::get_global(cx)
.git
.worktree_directory
.clone(),
),
)
} else {
(None, None, None)
};
let active_file_path = self.workspace.upgrade().and_then(|workspace| {
let workspace = workspace.read(cx);
let active_item = workspace.active_item(cx)?;
let project_path = active_item.project_path(cx)?;
workspace
.project()
.read(cx)
.absolute_path(&project_path, cx)
});
let workspace = self.workspace.clone();
let window_handle = window
.window_handle()
.downcast::<workspace::MultiWorkspace>();
let selected_agent = self.selected_agent();
let task = cx.spawn_in(window, async move |this, cx| {
let (all_paths, path_remapping, has_non_git) = match args {
WorktreeCreationArgs::New {
worktree_name,
branch_target,
} => {
let branch_receivers = branch_receivers
.expect("branch receivers must be prepared for new worktree creation");
let worktree_receivers = worktree_receivers
.expect("worktree receivers must be prepared for new worktree creation");
let worktree_directory_setting = worktree_directory_setting
.expect("worktree directory must be prepared for new worktree creation");
let mut existing_branches = HashSet::default();
for result in futures::future::join_all(branch_receivers).await {
match result {
Ok(Ok(branches)) => {
for branch in branches {
existing_branches.insert(branch.name().to_string());
}
}
Ok(Err(err)) => {
Err::<(), _>(err).log_err();
}
Err(_) => {}
}
}
let mut occupied_branches = HashSet::default();
for result in futures::future::join_all(worktree_receivers).await {
match result {
Ok(Ok(worktrees)) => {
for worktree in worktrees {
if let Some(branch_name) = worktree.branch_name() {
occupied_branches.insert(branch_name.to_string());
}
}
}
Ok(Err(err)) => {
Err::<(), _>(err).log_err();
}
Err(_) => {}
}
}
let (branch_name, use_existing_branch, start_point) =
match Self::resolve_worktree_branch_target(
&branch_target,
&existing_branches,
&occupied_branches,
) {
Ok(target) => target,
Err(err) => {
this.update_in(cx, |this, window, cx| {
this.set_worktree_creation_error(
err.to_string().into(),
window,
cx,
);
})?;
return anyhow::Ok(());
}
};
let (creation_infos, path_remapping) =
match this.update_in(cx, |_this, _window, cx| {
Self::start_worktree_creations(
&git_repos,
worktree_name,
&branch_name,
use_existing_branch,
start_point,
&worktree_directory_setting,
cx,
)
}) {
Ok(Ok(result)) => result,
Ok(Err(err)) | Err(err) => {
this.update_in(cx, |this, window, cx| {
this.set_worktree_creation_error(
format!("Failed to validate worktree directory: {err}")
.into(),
window,
cx,
);
})
.log_err();
return anyhow::Ok(());
}
};
let created_paths =
match Self::await_and_rollback_on_failure(creation_infos, cx).await {
Ok(paths) => paths,
Err(err) => {
this.update_in(cx, |this, window, cx| {
this.set_worktree_creation_error(
format!("{err}").into(),
window,
cx,
);
})?;
return anyhow::Ok(());
}
};
let mut all_paths = created_paths;
let has_non_git = !non_git_paths.is_empty();
all_paths.extend(non_git_paths.iter().cloned());
(all_paths, path_remapping, has_non_git)
}
WorktreeCreationArgs::Linked { worktree_path } => {
let mut all_paths = vec![worktree_path];
let has_non_git = !non_git_paths.is_empty();
all_paths.extend(non_git_paths.iter().cloned());
(all_paths, Vec::new(), has_non_git)
}
};
let app_state = match workspace.upgrade() {
Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?,
None => {
this.update_in(cx, |this, window, cx| {
this.set_worktree_creation_error(
"Workspace no longer available".into(),
window,
cx,
);
})?;
return anyhow::Ok(());
}
};
let this_for_error = this.clone();
if let Err(err) = Self::open_worktree_workspace_and_start_thread(
this,
all_paths,
app_state,
window_handle,
active_file_path,
path_remapping,
non_git_paths,
has_non_git,
content,
selected_agent,
cx,
)
.await
{
this_for_error
.update_in(cx, |this, window, cx| {
this.set_worktree_creation_error(
format!("Failed to set up workspace: {err}").into(),
window,
cx,
);
})
.log_err();
}
anyhow::Ok(())
});
self._worktree_creation_task = Some(cx.background_spawn(async move {
task.await.log_err();
}));
}
async fn open_worktree_workspace_and_start_thread(
this: WeakEntity<Self>,
all_paths: Vec<PathBuf>,
app_state: Arc<workspace::AppState>,
window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
active_file_path: Option<PathBuf>,
path_remapping: Vec<(PathBuf, PathBuf)>,
non_git_paths: Vec<PathBuf>,
has_non_git: bool,
content: Vec<acp::ContentBlock>,
selected_agent: Option<Agent>,
cx: &mut AsyncWindowContext,
) -> Result<()> {
let OpenResult {
window: new_window_handle,
workspace: new_workspace,
..
} = cx
.update(|_window, cx| {
Workspace::new_local(
all_paths,
app_state,
window_handle,
None,
None,
OpenMode::Add,
cx,
)
})?
.await?;
let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
if let Some(task) = panels_task {
task.await.log_err();
}
new_workspace
.update(cx, |workspace, cx| {
workspace.project().read(cx).wait_for_initial_scan(cx)
})
.await;
new_workspace
.update(cx, |workspace, cx| {
let repos = workspace
.project()
.read(cx)
.repositories(cx)
.values()
.cloned()
.collect::<Vec<_>>();
let tasks = repos
.into_iter()
.map(|repo| repo.update(cx, |repo, _| repo.barrier()));
futures::future::join_all(tasks)
})
.await;
let initial_content = AgentInitialContent::ContentBlock {
blocks: content,
auto_submit: true,
};
new_window_handle.update(cx, |_multi_workspace, window, cx| {
new_workspace.update(cx, |workspace, cx| {
if has_non_git {
let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
workspace.show_toast(
workspace::Toast::new(
toast_id,
"Some project folders are not git repositories. \
They were included as-is without creating a worktree.",
),
cx,
);
}
// If we had an active buffer, remap its path and reopen it.
let had_active_file = active_file_path.is_some();
let remapped_active_path = active_file_path.and_then(|original_path| {
let best_match = path_remapping
.iter()
.filter_map(|(old_root, new_root)| {
original_path.strip_prefix(old_root).ok().map(|relative| {
(old_root.components().count(), new_root.join(relative))
})
})
.max_by_key(|(depth, _)| *depth);
if let Some((_, remapped_path)) = best_match {
return Some(remapped_path);
}
for non_git in &non_git_paths {
if original_path.starts_with(non_git) {
return Some(original_path);
}
}
None
});
if had_active_file && remapped_active_path.is_none() {
log::warn!(
"Active file could not be remapped to the new worktree; it will not be reopened"
);
}
if let Some(path) = remapped_active_path {
let open_task = workspace.open_paths(
vec![path],
workspace::OpenOptions::default(),
None,
window,
cx,
);
cx.spawn(async move |_, _| -> anyhow::Result<()> {
for item in open_task.await.into_iter().flatten() {
item?;
}
Ok(())
})
.detach_and_log_err(cx);
}
workspace.focus_panel::<AgentPanel>(window, cx);
// If no active buffer was open, zoom the agent panel
// (equivalent to cmd-esc fullscreen behavior).
// This must happen after focus_panel, which activates
// and opens the panel in the dock.
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.external_thread(
selected_agent,
None,
None,
None,
Some(initial_content),
true,
window,
cx,
);
});
}
});
})?;
new_window_handle.update(cx, |multi_workspace, window, cx| {
multi_workspace.activate(new_workspace.clone(), window, cx);
new_workspace.update(cx, |workspace, cx| {
workspace.run_create_worktree_tasks(window, cx);
})
})?;
this.update_in(cx, |this, window, cx| {
this.worktree_creation_status = None;
if let Some(thread_view) = this.active_thread_view(cx) {
thread_view.update(cx, |thread_view, cx| {
thread_view
.message_editor
.update(cx, |editor, cx| editor.clear(window, cx));
});
}
cx.notify();
})?;
anyhow::Ok(())
}
}
impl Focusable for AgentPanel {
fn focus_handle(&self, cx: &App) -> FocusHandle {
match &self.active_view {
ActiveView::Uninitialized => self.focus_handle.clone(),
ActiveView::AgentThread {
conversation_view, ..
} => conversation_view.focus_handle(cx),
ActiveView::History { view } => view.read(cx).focus_handle(cx),
ActiveView::Configuration => {
if let Some(configuration) = self.configuration.as_ref() {
configuration.focus_handle(cx)
} else {
self.focus_handle.clone()
}
}
}
}
}
fn agent_panel_dock_position(cx: &App) -> DockPosition {
AgentSettings::get_global(cx).dock.into()
}
pub enum AgentPanelEvent {
ActiveViewChanged,
ThreadFocused,
BackgroundThreadChanged,
MessageSentOrQueued { session_id: acp::SessionId },
}
impl EventEmitter<PanelEvent> for AgentPanel {}
impl EventEmitter<AgentPanelEvent> for AgentPanel {}
impl Panel for AgentPanel {
fn persistent_name() -> &'static str {
"AgentPanel"
}
fn panel_key() -> &'static str {
AGENT_PANEL_KEY
}
fn position(&self, _window: &Window, cx: &App) -> DockPosition {
agent_panel_dock_position(cx)
}
fn position_is_valid(&self, position: DockPosition) -> bool {
position != DockPosition::Bottom
}
fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
settings
.agent
.get_or_insert_default()
.set_dock(position.into());
});
}
fn default_size(&self, window: &Window, cx: &App) -> Pixels {
let settings = AgentSettings::get_global(cx);
match self.position(window, cx) {
DockPosition::Left | DockPosition::Right => settings.default_width,
DockPosition::Bottom => settings.default_height,
}
}
fn supports_flexible_size(&self) -> bool {
true
}
fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
AgentSettings::get_global(cx).flexible
}
fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
settings
.agent
.get_or_insert_default()
.set_flexible_size(flexible);
});
}
fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
if active
&& matches!(self.active_view, ActiveView::Uninitialized)
&& !matches!(
self.worktree_creation_status,
Some(WorktreeCreationStatus::Creating)
)
{
let selected_agent = self.selected_agent.clone();
self.new_agent_thread_inner(selected_agent, false, window, cx);
}
}
fn remote_id() -> Option<proto::PanelId> {
Some(proto::PanelId::AssistantPanel)
}
fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
(self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
}
fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
Some("Agent Panel")
}
fn toggle_action(&self) -> Box<dyn Action> {
Box::new(ToggleFocus)
}
fn activation_priority(&self) -> u32 {
0
}
fn enabled(&self, cx: &App) -> bool {
AgentSettings::get_global(cx).enabled(cx)
}
fn is_agent_panel(&self) -> bool {
true
}
fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
self.zoomed
}
fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
self.zoomed = zoomed;
cx.notify();
}
}
impl AgentPanel {
fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
let content = match &self.active_view {
ActiveView::AgentThread { conversation_view } => {
let server_view_ref = conversation_view.read(cx);
let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
&& server_view_ref.root_thread(cx).map_or(false, |tv| {
tv.read(cx).thread.read(cx).has_provisional_title()
});
if let Some(title_editor) = server_view_ref
.root_thread(cx)
.map(|r| r.read(cx).title_editor.clone())
{
if is_generating_title {
Label::new(DEFAULT_THREAD_TITLE)
.color(Color::Muted)
.truncate()
.with_animation(
"generating_title",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|label, delta| label.alpha(delta),
)
.into_any_element()
} else {
div()
.w_full()
.on_action({
let conversation_view = conversation_view.downgrade();
move |_: &menu::Confirm, window, cx| {
if let Some(conversation_view) = conversation_view.upgrade() {
conversation_view.focus_handle(cx).focus(window, cx);
}
}
})
.on_action({
let conversation_view = conversation_view.downgrade();
move |_: &editor::actions::Cancel, window, cx| {
if let Some(conversation_view) = conversation_view.upgrade() {
conversation_view.focus_handle(cx).focus(window, cx);
}
}
})
.child(title_editor)
.into_any_element()
}
} else {
Label::new(conversation_view.read(cx).title(cx))
.color(Color::Muted)
.truncate()
.into_any_element()
}
}
ActiveView::History { .. } => Label::new("History").truncate().into_any_element(),
ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
};
h_flex()
.key_context("TitleEditor")
.id("TitleEditor")
.flex_grow()
.w_full()
.max_w_full()
.overflow_x_scroll()
.child(content)
.into_any()
}
fn handle_regenerate_thread_title(conversation_view: Entity<ConversationView>, cx: &mut App) {
conversation_view.update(cx, |conversation_view, cx| {
if let Some(thread) = conversation_view.as_native_thread(cx) {
thread.update(cx, |thread, cx| {
thread.generate_title(cx);
});
}
});
}
fn render_panel_options_menu(
&self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
let conversation_view = match &self.active_view {
ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
_ => None,
};
let thread_with_messages = match &self.active_view {
ActiveView::AgentThread { conversation_view } => {
conversation_view.read(cx).has_user_submitted_prompt(cx)
}
_ => false,
};
let has_auth_methods = match &self.active_view {
ActiveView::AgentThread { conversation_view } => {
conversation_view.read(cx).has_auth_methods()
}
_ => false,
};
PopoverMenu::new("agent-options-menu")
.trigger_with_tooltip(
IconButton::new("agent-options-menu", IconName::Ellipsis)
.icon_size(IconSize::Small),
{
let focus_handle = focus_handle.clone();
move |_window, cx| {
Tooltip::for_action_in(
"Toggle Agent Menu",
&ToggleOptionsMenu,
&focus_handle,
cx,
)
}
},
)
.anchor(Corner::TopRight)
.with_handle(self.agent_panel_menu_handle.clone())
.menu({
move |window, cx| {
Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
menu = menu.context(focus_handle.clone());
if thread_with_messages {
menu = menu.header("Current Thread");
if let Some(conversation_view) = conversation_view.as_ref() {
menu = menu
.entry("Regenerate Thread Title", None, {
let conversation_view = conversation_view.clone();
move |_, cx| {
Self::handle_regenerate_thread_title(
conversation_view.clone(),
cx,
);
}
})
.separator();
}
}
menu = menu
.header("MCP Servers")
.action(
"View Server Extensions",
Box::new(zed_actions::Extensions {
category_filter: Some(
zed_actions::ExtensionCategoryFilter::ContextServers,
),
id: None,
}),
)
.action("Add Custom Server…", Box::new(AddContextServer))
.separator()
.action("Rules", Box::new(OpenRulesLibrary::default()))
.action("Profiles", Box::new(ManageProfiles::default()))
.action("Settings", Box::new(OpenSettings))
.separator()
.action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar));
if has_auth_methods {
menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
}
menu
}))
}
})
}
fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
IconButton::new("go-back", IconName::ArrowLeft)
.icon_size(IconSize::Small)
.on_click(cx.listener(|this, _, window, cx| {
this.go_back(&workspace::GoBack, window, cx);
}))
.tooltip({
move |_window, cx| {
Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
}
})
}
fn project_has_git_repository(&self, cx: &App) -> bool {
!self.project.read(cx).repositories(cx).is_empty()
}
fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
let is_creating = matches!(
self.worktree_creation_status,
Some(WorktreeCreationStatus::Creating)
);
let trigger_parts = self
.start_thread_in
.trigger_label(self.project.read(cx), cx);
let icon = if self.start_thread_in_menu_handle.is_deployed() {
IconName::ChevronUp
} else {
IconName::ChevronDown
};
let trigger_button = ButtonLike::new("thread-target-trigger")
.disabled(is_creating)
.when_some(trigger_parts.prefix, |this, prefix| {
this.child(Label::new(prefix).color(Color::Muted))
})
.child(Label::new(trigger_parts.label))
.when_some(trigger_parts.suffix, |this, suffix| {
this.child(Label::new(suffix).color(Color::Muted))
})
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
let project = self.project.clone();
let current_target = self.start_thread_in.clone();
let fs = self.fs.clone();
PopoverMenu::new("thread-target-selector")
.trigger_with_tooltip(trigger_button, {
move |_window, cx| {
Tooltip::for_action_in(
"Start Thread In…",
&CycleStartThreadIn,
&focus_handle,
cx,
)
}
})
.menu(move |window, cx| {
let fs = fs.clone();
Some(cx.new(|cx| {
ThreadWorktreePicker::new(project.clone(), &current_target, fs, window, cx)
}))
})
.with_handle(self.start_thread_in_menu_handle.clone())
.anchor(Corner::TopLeft)
.offset(gpui::Point {
x: px(1.0),
y: px(1.0),
})
}
fn render_new_worktree_branch_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
let is_creating = matches!(
self.worktree_creation_status,
Some(WorktreeCreationStatus::Creating)
);
let project_ref = self.project.read(cx);
let trigger_parts = self
.start_thread_in
.branch_trigger_label(project_ref, cx)
.unwrap_or_else(|| StartThreadInLabel {
prefix: Some("From:".into()),
label: "HEAD".into(),
suffix: None,
});
let icon = if self.thread_branch_menu_handle.is_deployed() {
IconName::ChevronUp
} else {
IconName::ChevronDown
};
let trigger_button = ButtonLike::new("thread-branch-trigger")
.disabled(is_creating)
.when_some(trigger_parts.prefix, |this, prefix| {
this.child(Label::new(prefix).color(Color::Muted))
})
.child(Label::new(trigger_parts.label))
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
let project = self.project.clone();
let current_target = self.start_thread_in.clone();
PopoverMenu::new("thread-branch-selector")
.trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…"))
.menu(move |window, cx| {
Some(cx.new(|cx| {
ThreadBranchPicker::new(project.clone(), &current_target, window, cx)
}))
})
.with_handle(self.thread_branch_menu_handle.clone())
.anchor(Corner::TopLeft)
.offset(gpui::Point {
x: px(1.0),
y: px(1.0),
})
}
fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let agent_server_store = self.project.read(cx).agent_server_store().clone();
let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some();
let focus_handle = self.focus_handle(cx);
let (selected_agent_custom_icon, selected_agent_label) =
if let Agent::Custom { id, .. } = &self.selected_agent {
let store = agent_server_store.read(cx);
let icon = store.agent_icon(&id);
let label = store
.agent_display_name(&id)
.unwrap_or_else(|| self.selected_agent.label());
(icon, label)
} else {
(None, self.selected_agent.label())
};
let active_thread = match &self.active_view {
ActiveView::AgentThread { conversation_view } => {
conversation_view.read(cx).as_native_thread(cx)
}
ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
None
}
};
let new_thread_menu_builder: Rc<
dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
> = {
let selected_agent = self.selected_agent.clone();
let is_agent_selected = move |agent: Agent| selected_agent == agent;
let workspace = self.workspace.clone();
let is_via_collab = workspace
.update(cx, |workspace, cx| {
workspace.project().read(cx).is_via_collab()
})
.unwrap_or_default();
let focus_handle = focus_handle.clone();
let agent_server_store = agent_server_store;
Rc::new(move |window, cx| {
telemetry::event!("New Thread Clicked");
let active_thread = active_thread.clone();
Some(ContextMenu::build(window, cx, |menu, _window, cx| {
menu.context(focus_handle.clone())
.when_some(active_thread, |this, active_thread| {
let thread = active_thread.read(cx);
if !thread.is_empty() {
let session_id = thread.id().clone();
this.item(
ContextMenuEntry::new("New From Summary")
.icon(IconName::ThreadFromSummary)
.icon_color(Color::Muted)
.handler(move |window, cx| {
window.dispatch_action(
Box::new(NewNativeAgentThreadFromSummary {
from_session_id: session_id.clone(),
}),
cx,
);
}),
)
} else {
this
}
})
.item(
ContextMenuEntry::new("Zed Agent")
.when(is_agent_selected(Agent::NativeAgent), |this| {
this.action(Box::new(NewExternalAgentThread { agent: None }))
})
.icon(IconName::ZedAgent)
.icon_color(Color::Muted)
.handler({
let workspace = workspace.clone();
move |window, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
if let Some(panel) =
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
panel.new_agent_thread(
Agent::NativeAgent,
window,
cx,
);
});
}
});
}
}
}),
)
.map(|mut menu| {
let agent_server_store = agent_server_store.read(cx);
let registry_store = project::AgentRegistryStore::try_global(cx);
let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
struct AgentMenuItem {
id: AgentId,
display_name: SharedString,
}
let agent_items = agent_server_store
.external_agents()
.map(|agent_id| {
let display_name = agent_server_store
.agent_display_name(agent_id)
.or_else(|| {
registry_store_ref
.as_ref()
.and_then(|store| store.agent(agent_id))
.map(|a| a.name().clone())
})
.unwrap_or_else(|| agent_id.0.clone());
AgentMenuItem {
id: agent_id.clone(),
display_name,
}
})
.sorted_unstable_by_key(|e| e.display_name.to_lowercase())
.collect::<Vec<_>>();
if !agent_items.is_empty() {
menu = menu.separator().header("External Agents");
}
for item in &agent_items {
let mut entry = ContextMenuEntry::new(item.display_name.clone());
let icon_path =
agent_server_store.agent_icon(&item.id).or_else(|| {
registry_store_ref
.as_ref()
.and_then(|store| store.agent(&item.id))
.and_then(|a| a.icon_path().cloned())
});
if let Some(icon_path) = icon_path {
entry = entry.custom_icon_svg(icon_path);
} else {
entry = entry.icon(IconName::Sparkle);
}
entry = entry
.when(
is_agent_selected(Agent::Custom {
id: item.id.clone(),
}),
|this| {
this.action(Box::new(NewExternalAgentThread {
agent: None,
}))
},
)
.icon_color(Color::Muted)
.disabled(is_via_collab)
.handler({
let workspace = workspace.clone();
let agent_id = item.id.clone();
move |window, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
if let Some(panel) =
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
panel.new_agent_thread(
Agent::Custom {
id: agent_id.clone(),
},
window,
cx,
);
});
}
});
}
}
});
menu = menu.item(entry);
}
menu
})
.separator()
.item(
ContextMenuEntry::new("Add More Agents")
.icon(IconName::Plus)
.icon_color(Color::Muted)
.handler({
move |window, cx| {
window
.dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
}
}),
)
}))
})
};
let is_thread_loading = self
.active_conversation_view()
.map(|thread| thread.read(cx).is_loading())
.unwrap_or(false);
let has_custom_icon = selected_agent_custom_icon.is_some();
let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
let selected_agent_builtin_icon = self.selected_agent.icon();
let selected_agent_label_for_tooltip = selected_agent_label.clone();
let selected_agent = div()
.id("selected_agent_icon")
.when_some(selected_agent_custom_icon, |this, icon_path| {
this.px_1()
.child(Icon::from_external_svg(icon_path).color(Color::Muted))
})
.when(!has_custom_icon, |this| {
this.when_some(selected_agent_builtin_icon, |this, icon| {
this.px_1().child(Icon::new(icon).color(Color::Muted))
})
})
.tooltip(move |_, cx| {
Tooltip::with_meta(
selected_agent_label_for_tooltip.clone(),
None,
"Selected Agent",
cx,
)
});
let selected_agent = if is_thread_loading {
selected_agent
.with_animation(
"pulsating-icon",
Animation::new(Duration::from_secs(1))
.repeat()
.with_easing(pulsating_between(0.2, 0.6)),
|icon, delta| icon.opacity(delta),
)
.into_any_element()
} else {
selected_agent.into_any_element()
};
let is_empty_state = !self.active_thread_has_messages(cx);
let is_in_history_or_config = matches!(
&self.active_view,
ActiveView::History { .. } | ActiveView::Configuration
);
let is_full_screen = self.is_zoomed(window, cx);
let full_screen_button = if is_full_screen {
IconButton::new("disable-full-screen", IconName::Minimize)
.icon_size(IconSize::Small)
.tooltip(move |_, cx| Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx))
.on_click(cx.listener(move |this, _, window, cx| {
this.toggle_zoom(&ToggleZoom, window, cx);
}))
} else {
IconButton::new("enable-full-screen", IconName::Maximize)
.icon_size(IconSize::Small)
.tooltip(move |_, cx| Tooltip::for_action("Enable Full Screen", &ToggleZoom, cx))
.on_click(cx.listener(move |this, _, window, cx| {
this.toggle_zoom(&ToggleZoom, window, cx);
}))
};
let use_v2_empty_toolbar = is_empty_state && !is_in_history_or_config;
let max_content_width = AgentSettings::get_global(cx).max_content_width;
let base_container = h_flex()
.size_full()
// TODO: This is only until we remove Agent settings from the panel.
.when(!is_in_history_or_config, |this| {
this.max_w(max_content_width).mx_auto()
})
.flex_none()
.justify_between()
.gap_2();
let toolbar_content = if use_v2_empty_toolbar {
let (chevron_icon, icon_color, label_color) =
if self.new_thread_menu_handle.is_deployed() {
(IconName::ChevronUp, Color::Accent, Color::Accent)
} else {
(IconName::ChevronDown, Color::Muted, Color::Default)
};
let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
Icon::from_external_svg(icon_path)
.size(IconSize::Small)
.color(icon_color)
} else {
let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
Icon::new(icon_name).size(IconSize::Small).color(icon_color)
};
let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
.start_icon(agent_icon)
.color(label_color)
.end_icon(
Icon::new(chevron_icon)
.color(icon_color)
.size(IconSize::XSmall),
);
let agent_selector_menu = PopoverMenu::new("new_thread_menu")
.trigger_with_tooltip(agent_selector_button, {
move |_window, cx| {
Tooltip::for_action_in(
"New Thread…",
&ToggleNewThreadMenu,
&focus_handle,
cx,
)
}
})
.menu({
let builder = new_thread_menu_builder.clone();
move |window, cx| builder(window, cx)
})
.with_handle(self.new_thread_menu_handle.clone())
.anchor(Corner::TopLeft)
.offset(gpui::Point {
x: px(1.0),
y: px(1.0),
});
base_container
.child(
h_flex()
.size_full()
.gap(DynamicSpacing::Base04.rems(cx))
.pl(DynamicSpacing::Base04.rems(cx))
.child(agent_selector_menu)
.when(
has_visible_worktrees && self.project_has_git_repository(cx),
|this| this.child(self.render_start_thread_in_selector(cx)),
)
.when(
matches!(self.start_thread_in, StartThreadIn::NewWorktree { .. }),
|this| this.child(self.render_new_worktree_branch_selector(cx)),
),
)
.child(
h_flex()
.h_full()
.flex_none()
.gap_1()
.pl_1()
.pr_1()
.child(full_screen_button)
.child(self.render_panel_options_menu(window, cx)),
)
.into_any_element()
} else {
let new_thread_menu = PopoverMenu::new("new_thread_menu")
.trigger_with_tooltip(
IconButton::new("new_thread_menu_btn", IconName::Plus)
.icon_size(IconSize::Small),
{
move |_window, cx| {
Tooltip::for_action_in(
"New Thread\u{2026}",
&ToggleNewThreadMenu,
&focus_handle,
cx,
)
}
},
)
.anchor(Corner::TopRight)
.with_handle(self.new_thread_menu_handle.clone())
.menu(move |window, cx| new_thread_menu_builder(window, cx));
base_container
.child(
h_flex()
.size_full()
.gap(DynamicSpacing::Base04.rems(cx))
.pl(DynamicSpacing::Base04.rems(cx))
.child(match &self.active_view {
ActiveView::History { .. } | ActiveView::Configuration => {
self.render_toolbar_back_button(cx).into_any_element()
}
_ => selected_agent.into_any_element(),
})
.child(self.render_title_view(window, cx)),
)
.child(
h_flex()
.h_full()
.flex_none()
.gap_1()
.pl_1()
.pr_1()
.child(new_thread_menu)
.child(full_screen_button)
.child(self.render_panel_options_menu(window, cx)),
)
.into_any_element()
};
h_flex()
.id("agent-panel-toolbar")
.h(Tab::container_height(cx))
.flex_shrink_0()
.max_w_full()
.bg(cx.theme().colors().tab_bar_background)
.border_b_1()
.border_color(cx.theme().colors().border)
.child(toolbar_content)
}
fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let status = self.worktree_creation_status.as_ref()?;
match status {
WorktreeCreationStatus::Creating => Some(
h_flex()
.absolute()
.bottom_12()
.w_full()
.p_2()
.gap_1()
.justify_center()
.bg(cx.theme().colors().editor_background)
.child(
Icon::new(IconName::LoadCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_rotate_animation(3),
)
.child(
Label::new("Creating Worktree…")
.color(Color::Muted)
.size(LabelSize::Small),
)
.into_any_element(),
),
WorktreeCreationStatus::Error(message) => Some(
Callout::new()
.icon(IconName::Warning)
.severity(Severity::Warning)
.title(message.clone())
.into_any_element(),
),
}
}
fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
if TrialEndUpsell::dismissed(cx) {
return false;
}
match &self.active_view {
ActiveView::AgentThread { .. } => {
if LanguageModelRegistry::global(cx)
.read(cx)
.default_model()
.is_some_and(|model| {
model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
})
{
return false;
}
}
ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
return false;
}
}
let plan = self.user_store.read(cx).plan();
let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
}
fn should_render_agent_layout_onboarding(&self, cx: &mut Context<Self>) -> bool {
// We only want to show this for existing users: those who
// have used the agent panel before the sidebar was introduced.
// We can infer that state by users having seen the onboarding
// at one point, but not the agent layout onboarding.
let has_messages = self.active_thread_has_messages(cx);
let is_dismissed = self
.agent_layout_onboarding_dismissed
.load(Ordering::Acquire);
if is_dismissed || has_messages {
return false;
}
match &self.active_view {
ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
false
}
ActiveView::AgentThread { .. } => {
let existing_user = self
.new_user_onboarding_upsell_dismissed
.load(Ordering::Acquire);
existing_user
}
}
}
fn render_agent_layout_onboarding(
&self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
if !self.should_render_agent_layout_onboarding(cx) {
return None;
}
Some(div().child(self.agent_layout_onboarding.clone()))
}
fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context<Self>) {
self.agent_layout_onboarding_dismissed
.store(true, Ordering::Release);
AgentLayoutOnboarding::set_dismissed(true, cx);
cx.notify();
}
fn dismiss_ai_onboarding(&mut self, cx: &mut Context<Self>) {
self.new_user_onboarding_upsell_dismissed
.store(true, Ordering::Release);
OnboardingUpsell::set_dismissed(true, cx);
self.dismiss_agent_layout_onboarding(cx);
cx.notify();
}
fn should_render_new_user_onboarding(&mut self, cx: &mut Context<Self>) -> bool {
if self
.new_user_onboarding_upsell_dismissed
.load(Ordering::Acquire)
{
return false;
}
let user_store = self.user_store.read(cx);
if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
&& user_store
.subscription_period()
.and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
.is_some_and(|date| date < chrono::Utc::now())
{
if !self
.new_user_onboarding_upsell_dismissed
.load(Ordering::Acquire)
{
self.dismiss_ai_onboarding(cx);
}
return false;
}
let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
.visible_providers()
.iter()
.any(|provider| {
provider.is_authenticated(cx)
&& provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
});
match &self.active_view {
ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
false
}
ActiveView::AgentThread {
conversation_view, ..
} if conversation_view.read(cx).as_native_thread(cx).is_none() => false,
ActiveView::AgentThread { conversation_view } => {
let history_is_empty = conversation_view
.read(cx)
.history()
.is_none_or(|h| h.read(cx).is_empty());
history_is_empty || !has_configured_non_zed_providers
}
}
}
fn render_new_user_onboarding(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
if !self.should_render_new_user_onboarding(cx) {
return None;
}
Some(
div()
.bg(cx.theme().colors().editor_background)
.child(self.new_user_onboarding.clone()),
)
}
fn render_trial_end_upsell(
&self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
if !self.should_render_trial_end_upsell(cx) {
return None;
}
Some(
v_flex()
.absolute()
.inset_0()
.size_full()
.bg(cx.theme().colors().panel_background)
.opacity(0.85)
.block_mouse_except_scroll()
.child(EndTrialUpsell::new(Arc::new({
let this = cx.entity();
move |_, cx| {
this.update(cx, |_this, cx| {
TrialEndUpsell::set_dismissed(true, cx);
cx.notify();
});
}
}))),
)
}
fn render_drag_target(&self, cx: &Context<Self>) -> Div {
let is_local = self.project.read(cx).is_local();
div()
.invisible()
.absolute()
.top_0()
.right_0()
.bottom_0()
.left_0()
.bg(cx.theme().colors().drop_target_background)
.drag_over::<DraggedTab>(|this, _, _, _| this.visible())
.drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
.when(is_local, |this| {
this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
})
.on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
let item = tab.pane.read(cx).item_for_index(tab.ix);
let project_paths = item
.and_then(|item| item.project_path(cx))
.into_iter()
.collect::<Vec<_>>();
this.handle_drop(project_paths, vec![], window, cx);
}))
.on_drop(
cx.listener(move |this, selection: &DraggedSelection, window, cx| {
let project_paths = selection
.items()
.filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
.collect::<Vec<_>>();
this.handle_drop(project_paths, vec![], window, cx);
}),
)
.on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
let tasks = paths
.paths()
.iter()
.map(|path| {
Workspace::project_path_for_path(this.project.clone(), path, false, cx)
})
.collect::<Vec<_>>();
cx.spawn_in(window, async move |this, cx| {
let mut paths = vec![];
let mut added_worktrees = vec![];
let opened_paths = futures::future::join_all(tasks).await;
for entry in opened_paths {
if let Some((worktree, project_path)) = entry.log_err() {
added_worktrees.push(worktree);
paths.push(project_path);
}
}
this.update_in(cx, |this, window, cx| {
this.handle_drop(paths, added_worktrees, window, cx);
})
.ok();
})
.detach();
}))
}
fn handle_drop(
&mut self,
paths: Vec<ProjectPath>,
added_worktrees: Vec<Entity<Worktree>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
match &self.active_view {
ActiveView::AgentThread { conversation_view } => {
conversation_view.update(cx, |conversation_view, cx| {
conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
});
}
ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
}
}
fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
if !self.show_trust_workspace_message {
return None;
}
let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
Some(
Callout::new()
.icon(IconName::Warning)
.severity(Severity::Warning)
.border_position(ui::BorderPosition::Bottom)
.title("You're in Restricted Mode")
.description(description)
.actions_slot(
Button::new("open-trust-modal", "Configure Project Trust")
.label_size(LabelSize::Small)
.style(ButtonStyle::Outlined)
.on_click({
cx.listener(move |this, _, window, cx| {
this.workspace
.update(cx, |workspace, cx| {
workspace
.show_worktree_trust_security_modal(true, window, cx)
})
.log_err();
})
}),
),
)
}
fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("AgentPanel");
match &self.active_view {
ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {}
}
key_context
}
}
impl Render for AgentPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// WARNING: Changes to this element hierarchy can have
// non-obvious implications to the layout of children.
//
// If you need to change it, please confirm:
// - The message editor expands (cmd-option-esc) correctly
// - When expanded, the buttons at the bottom of the panel are displayed correctly
// - Font size works as expected and can be changed with cmd-+/cmd-
// - Scrolling in all views works as expected
// - Files can be dropped into the panel
let content = v_flex()
.relative()
.size_full()
.justify_between()
.key_context(self.key_context())
.on_action(cx.listener(|this, action: &NewThread, window, cx| {
this.new_thread(action, window, cx);
}))
.on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
this.open_history(window, cx);
}))
.on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
this.open_configuration(window, cx);
}))
.on_action(cx.listener(Self::open_active_thread_as_markdown))
.on_action(cx.listener(Self::deploy_rules_library))
.on_action(cx.listener(Self::go_back))
.on_action(cx.listener(Self::toggle_navigation_menu))
.on_action(cx.listener(Self::toggle_options_menu))
.on_action(cx.listener(Self::increase_font_size))
.on_action(cx.listener(Self::decrease_font_size))
.on_action(cx.listener(Self::reset_font_size))
.on_action(cx.listener(Self::toggle_zoom))
.on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
if let Some(conversation_view) = this.active_conversation_view() {
conversation_view.update(cx, |conversation_view, cx| {
conversation_view.reauthenticate(window, cx)
})
}
}))
.child(self.render_toolbar(window, cx))
.children(self.render_workspace_trust_message(cx))
.children(self.render_new_user_onboarding(window, cx))
.children(self.render_agent_layout_onboarding(window, cx))
.map(|parent| match &self.active_view {
ActiveView::Uninitialized => parent,
ActiveView::AgentThread {
conversation_view, ..
} => parent
.child(conversation_view.clone())
.child(self.render_drag_target(cx)),
ActiveView::History { view } => parent.child(view.clone()),
ActiveView::Configuration => parent.children(self.configuration.clone()),
})
.children(self.render_worktree_creation_status(cx))
.children(self.render_trial_end_upsell(window, cx));
match self.active_view.which_font_size_used() {
WhichFontSize::AgentFont => {
WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
.size_full()
.child(content)
.into_any()
}
_ => content.into_any(),
}
}
}
struct PromptLibraryInlineAssist {
workspace: WeakEntity<Workspace>,
}
impl PromptLibraryInlineAssist {
pub fn new(workspace: WeakEntity<Workspace>) -> Self {
Self { workspace }
}
}
impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
fn assist(
&self,
prompt_editor: &Entity<Editor>,
initial_prompt: Option<String>,
window: &mut Window,
cx: &mut Context<RulesLibrary>,
) {
InlineAssistant::update_global(cx, |assistant, cx| {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
return;
};
let history = panel
.read(cx)
.connection_store()
.read(cx)
.entry(&crate::Agent::NativeAgent)
.and_then(|s| s.read(cx).history())
.map(|h| h.downgrade());
let project = workspace.read(cx).project().downgrade();
let panel = panel.read(cx);
let thread_store = panel.thread_store().clone();
assistant.assist(
prompt_editor,
self.workspace.clone(),
project,
thread_store,
None,
history,
initial_prompt,
window,
cx,
);
})
}
fn focus_agent_panel(
&self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> bool {
workspace.focus_panel::<AgentPanel>(window, cx).is_some()
}
}
struct OnboardingUpsell;
impl Dismissable for OnboardingUpsell {
const KEY: &'static str = "dismissed-trial-upsell";
}
struct AgentLayoutOnboarding;
impl Dismissable for AgentLayoutOnboarding {
const KEY: &'static str = "dismissed-agent-layout-onboarding";
}
struct TrialEndUpsell;
impl Dismissable for TrialEndUpsell {
const KEY: &'static str = "dismissed-trial-end-upsell";
}
/// Test-only helper methods
#[cfg(any(test, feature = "test-support"))]
impl AgentPanel {
pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new(workspace, None, window, cx)
}
/// Opens an external thread using an arbitrary AgentServer.
///
/// This is a test-only helper that allows visual tests and integration tests
/// to inject a stub server without modifying production code paths.
/// Not compiled into production builds.
pub fn open_external_thread_with_server(
&mut self,
server: Rc<dyn AgentServer>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let workspace = self.workspace.clone();
let project = self.project.clone();
let ext_agent = Agent::Custom {
id: server.agent_id(),
};
self.create_agent_thread(
server, None, None, None, None, workspace, project, ext_agent, true, window, cx,
);
}
/// Returns the currently active thread view, if any.
///
/// This is a test-only accessor that exposes the private `active_thread_view()`
/// method for test assertions. Not compiled into production builds.
pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConversationView>> {
self.active_conversation_view()
}
/// Sets the start_thread_in value directly, bypassing validation.
///
/// This is a test-only helper for visual tests that need to show specific
/// start_thread_in states without requiring a real git repository.
pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
self.start_thread_in = target;
cx.notify();
}
/// Returns the current worktree creation status.
///
/// This is a test-only helper for visual tests.
pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
self.worktree_creation_status.as_ref()
}
/// Sets the worktree creation status directly.
///
/// This is a test-only helper for visual tests that need to show the
/// "Creating worktree…" spinner or error banners.
pub fn set_worktree_creation_status_for_tests(
&mut self,
status: Option<WorktreeCreationStatus>,
cx: &mut Context<Self>,
) {
self.worktree_creation_status = status;
cx.notify();
}
/// Opens the history view.
///
/// This is a test-only helper that exposes the private `open_history()`
/// method for visual tests.
pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.open_history(window, cx);
}
/// Opens the start_thread_in selector popover menu.
///
/// This is a test-only helper for visual tests.
pub fn open_start_thread_in_menu_for_tests(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.start_thread_in_menu_handle.show(window, cx);
}
/// Dismisses the start_thread_in dropdown menu.
///
/// This is a test-only helper for visual tests.
pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
self.start_thread_in_menu_handle.hide(cx);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation_view::tests::{StubAgentServer, init_test};
use crate::test_support::{
active_session_id, open_thread_with_connection, open_thread_with_custom_connection,
send_message,
};
use acp_thread::{StubAgentConnection, ThreadStatus};
use agent_servers::CODEX_ID;
use fs::FakeFs;
use gpui::{TestAppContext, VisualTestContext};
use project::Project;
use serde_json::json;
use std::path::Path;
use std::time::Instant;
use workspace::MultiWorkspace;
#[gpui::test]
async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
// --- Create a MultiWorkspace window with two workspaces ---
let fs = FakeFs::new(cx.executor());
let project_a = Project::test(fs.clone(), [], cx).await;
let project_b = Project::test(fs, [], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let workspace_a = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
let workspace_b = multi_workspace
.update(cx, |multi_workspace, window, cx| {
multi_workspace.test_add_workspace(project_b.clone(), window, cx)
})
.unwrap();
workspace_a.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
workspace_b.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
// --- Set up workspace A: with an active thread ---
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
});
panel_a.update_in(cx, |panel, window, cx| {
panel.open_external_thread_with_server(
Rc::new(StubAgentServer::default_response()),
window,
cx,
);
});
cx.run_until_parked();
panel_a.read_with(cx, |panel, cx| {
assert!(
panel.active_agent_thread(cx).is_some(),
"workspace A should have an active thread after connection"
);
});
send_message(&panel_a, cx);
let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
// --- Set up workspace B: ClaudeCode, no active thread ---
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
});
panel_b.update(cx, |panel, _cx| {
panel.selected_agent = Agent::Custom {
id: "claude-acp".into(),
};
});
// --- Serialize both panels ---
panel_a.update(cx, |panel, cx| panel.serialize(cx));
panel_b.update(cx, |panel, cx| panel.serialize(cx));
cx.run_until_parked();
// --- Load fresh panels for each workspace and verify independent state ---
let async_cx = cx.update(|window, cx| window.to_async(cx));
let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
.await
.expect("panel A load should succeed");
cx.run_until_parked();
let async_cx = cx.update(|window, cx| window.to_async(cx));
let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
.await
.expect("panel B load should succeed");
cx.run_until_parked();
// Workspace A should restore its thread and agent type
loaded_a.read_with(cx, |panel, _cx| {
assert_eq!(
panel.selected_agent, agent_type_a,
"workspace A agent type should be restored"
);
assert!(
panel.active_conversation_view().is_some(),
"workspace A should have its active thread restored"
);
});
// Workspace B should restore its own agent type, with no thread
loaded_b.read_with(cx, |panel, _cx| {
assert_eq!(
panel.selected_agent,
Agent::Custom {
id: "claude-acp".into()
},
"workspace B agent type should be restored"
);
assert!(
panel.active_conversation_view().is_none(),
"workspace B should have no active thread"
);
});
}
#[gpui::test]
async fn test_non_native_thread_without_metadata_is_not_restored(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
workspace.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
});
panel.update_in(cx, |panel, window, cx| {
panel.open_external_thread_with_server(
Rc::new(StubAgentServer::default_response()),
window,
cx,
);
});
cx.run_until_parked();
panel.read_with(cx, |panel, cx| {
assert!(
panel.active_agent_thread(cx).is_some(),
"should have an active thread after connection"
);
});
// Serialize without ever sending a message, so no thread metadata exists.
panel.update(cx, |panel, cx| panel.serialize(cx));
cx.run_until_parked();
let async_cx = cx.update(|window, cx| window.to_async(cx));
let loaded = AgentPanel::load(workspace.downgrade(), async_cx)
.await
.expect("panel load should succeed");
cx.run_until_parked();
loaded.read_with(cx, |panel, _cx| {
assert!(
panel.active_conversation_view().is_none(),
"thread without metadata should not be restored"
);
});
}
/// Extracts the text from a Text content block, panicking if it's not Text.
fn expect_text_block(block: &acp::ContentBlock) -> &str {
match block {
acp::ContentBlock::Text(t) => t.text.as_str(),
other => panic!("expected Text block, got {:?}", other),
}
}
/// Extracts the (text_content, uri) from a Resource content block, panicking
/// if it's not a TextResourceContents resource.
fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
match block {
acp::ContentBlock::Resource(r) => match &r.resource {
acp::EmbeddedResourceResource::TextResourceContents(t) => {
(t.text.as_str(), t.uri.as_str())
}
other => panic!("expected TextResourceContents, got {:?}", other),
},
other => panic!("expected Resource block, got {:?}", other),
}
}
#[test]
fn test_build_conflict_resolution_prompt_single_conflict() {
let conflicts = vec![ConflictContent {
file_path: "src/main.rs".to_string(),
conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
.to_string(),
ours_branch_name: "HEAD".to_string(),
theirs_branch_name: "feature".to_string(),
}];
let blocks = build_conflict_resolution_prompt(&conflicts);
// 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
assert_eq!(
blocks.len(),
4,
"expected 2 text + 1 resource link + 1 resource block"
);
let intro_text = expect_text_block(&blocks[0]);
assert!(
intro_text.contains("Please resolve the following merge conflict in"),
"prompt should include single-conflict intro text"
);
match &blocks[1] {
acp::ContentBlock::ResourceLink(link) => {
assert!(
link.uri.contains("file://"),
"resource link URI should use file scheme"
);
assert!(
link.uri.contains("main.rs"),
"resource link URI should reference file path"
);
}
other => panic!("expected ResourceLink block, got {:?}", other),
}
let body_text = expect_text_block(&blocks[2]);
assert!(
body_text.contains("`HEAD` (ours)"),
"prompt should mention ours branch"
);
assert!(
body_text.contains("`feature` (theirs)"),
"prompt should mention theirs branch"
);
assert!(
body_text.contains("editing the file directly"),
"prompt should instruct the agent to edit the file"
);
let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
assert!(
resource_text.contains("<<<<<<< HEAD"),
"resource should contain the conflict text"
);
assert!(
resource_uri.contains("merge-conflict"),
"resource URI should use the merge-conflict scheme"
);
assert!(
resource_uri.contains("main.rs"),
"resource URI should reference the file path"
);
}
#[test]
fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
let conflicts = vec![
ConflictContent {
file_path: "src/lib.rs".to_string(),
conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
.to_string(),
ours_branch_name: "main".to_string(),
theirs_branch_name: "dev".to_string(),
},
ConflictContent {
file_path: "src/lib.rs".to_string(),
conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
.to_string(),
ours_branch_name: "main".to_string(),
theirs_branch_name: "dev".to_string(),
},
];
let blocks = build_conflict_resolution_prompt(&conflicts);
// 1 Text instruction + 2 Resource blocks
assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
let text = expect_text_block(&blocks[0]);
assert!(
text.contains("all 2 merge conflicts"),
"prompt should mention the total count"
);
assert!(
text.contains("`main` (ours)"),
"prompt should mention ours branch"
);
assert!(
text.contains("`dev` (theirs)"),
"prompt should mention theirs branch"
);
// Single file, so "file" not "files"
assert!(
text.contains("file directly"),
"single file should use singular 'file'"
);
let (resource_a, _) = expect_resource_block(&blocks[1]);
let (resource_b, _) = expect_resource_block(&blocks[2]);
assert!(
resource_a.contains("fn a()"),
"first resource should contain first conflict"
);
assert!(
resource_b.contains("fn b()"),
"second resource should contain second conflict"
);
}
#[test]
fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
let conflicts = vec![
ConflictContent {
file_path: "src/a.rs".to_string(),
conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
ours_branch_name: "main".to_string(),
theirs_branch_name: "dev".to_string(),
},
ConflictContent {
file_path: "src/b.rs".to_string(),
conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
ours_branch_name: "main".to_string(),
theirs_branch_name: "dev".to_string(),
},
];
let blocks = build_conflict_resolution_prompt(&conflicts);
// 1 Text instruction + 2 Resource blocks
assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
let text = expect_text_block(&blocks[0]);
assert!(
text.contains("files directly"),
"multiple files should use plural 'files'"
);
let (_, uri_a) = expect_resource_block(&blocks[1]);
let (_, uri_b) = expect_resource_block(&blocks[2]);
assert!(
uri_a.contains("a.rs"),
"first resource URI should reference a.rs"
);
assert!(
uri_b.contains("b.rs"),
"second resource URI should reference b.rs"
);
}
#[test]
fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
let file_paths = vec![
"src/main.rs".to_string(),
"src/lib.rs".to_string(),
"tests/integration.rs".to_string(),
];
let blocks = build_conflicted_files_resolution_prompt(&file_paths);
// 1 instruction Text block + (ResourceLink + newline Text) per file
assert_eq!(
blocks.len(),
1 + (file_paths.len() * 2),
"expected instruction text plus resource links and separators"
);
let text = expect_text_block(&blocks[0]);
assert!(
text.contains("unresolved merge conflicts"),
"prompt should describe the task"
);
assert!(
text.contains("conflict markers"),
"prompt should mention conflict markers"
);
for (index, path) in file_paths.iter().enumerate() {
let link_index = 1 + (index * 2);
let newline_index = link_index + 1;
match &blocks[link_index] {
acp::ContentBlock::ResourceLink(link) => {
assert!(
link.uri.contains("file://"),
"resource link URI should use file scheme"
);
assert!(
link.uri.contains(path),
"resource link URI should reference file path: {path}"
);
}
other => panic!(
"expected ResourceLink block at index {}, got {:?}",
link_index, other
),
}
let separator = expect_text_block(&blocks[newline_index]);
assert_eq!(
separator, "\n",
"expected newline separator after each file"
);
}
}
#[test]
fn test_build_conflict_resolution_prompt_empty_conflicts() {
let blocks = build_conflict_resolution_prompt(&[]);
assert!(
blocks.is_empty(),
"empty conflicts should produce no blocks, got {} blocks",
blocks.len()
);
}
#[test]
fn test_build_conflicted_files_resolution_prompt_empty_paths() {
let blocks = build_conflicted_files_resolution_prompt(&[]);
assert!(
blocks.is_empty(),
"empty paths should produce no blocks, got {} blocks",
blocks.len()
);
}
#[test]
fn test_conflict_resource_block_structure() {
let conflict = ConflictContent {
file_path: "src/utils.rs".to_string(),
conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
ours_branch_name: "HEAD".to_string(),
theirs_branch_name: "branch".to_string(),
};
let block = conflict_resource_block(&conflict);
let (text, uri) = expect_resource_block(&block);
assert_eq!(
text, conflict.conflict_text,
"resource text should be the raw conflict"
);
assert!(
uri.starts_with("zed:///agent/merge-conflict"),
"URI should use the zed merge-conflict scheme, got: {uri}"
);
assert!(uri.contains("utils.rs"), "URI should encode the file path");
}
fn open_generating_thread_with_loadable_connection(
panel: &Entity<AgentPanel>,
connection: &StubAgentConnection,
cx: &mut VisualTestContext,
) -> acp::SessionId {
open_thread_with_custom_connection(panel, connection.clone(), cx);
let session_id = active_session_id(panel, cx);
send_message(panel, cx);
cx.update(|_, cx| {
connection.send_update(
session_id.clone(),
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
cx,
);
});
cx.run_until_parked();
session_id
}
fn open_idle_thread_with_non_loadable_connection(
panel: &Entity<AgentPanel>,
connection: &StubAgentConnection,
cx: &mut VisualTestContext,
) -> acp::SessionId {
open_thread_with_custom_connection(panel, connection.clone(), cx);
let session_id = active_session_id(panel, cx);
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("done".into()),
)]);
send_message(panel, cx);
session_id
}
async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), [], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |mw, _cx| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
});
(panel, cx)
}
#[gpui::test]
async fn test_empty_draft_thread_not_retained_when_navigating_away(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
let connection_a = StubAgentConnection::new();
open_thread_with_connection(&panel, connection_a, &mut cx);
let session_id_a = active_session_id(&panel, &cx);
panel.read_with(&cx, |panel, cx| {
let thread = panel.active_agent_thread(cx).unwrap();
assert!(
thread.read(cx).entries().is_empty(),
"newly opened draft thread should have no entries"
);
assert!(panel.background_threads.is_empty());
});
let connection_b = StubAgentConnection::new();
open_thread_with_connection(&panel, connection_b, &mut cx);
panel.read_with(&cx, |panel, _cx| {
assert!(
panel.background_threads.is_empty(),
"empty draft thread should not be retained in background_threads"
);
assert!(
!panel.background_threads.contains_key(&session_id_a),
"empty draft thread should not be keyed in background_threads"
);
});
}
#[gpui::test]
async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
let connection_a = StubAgentConnection::new();
open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
send_message(&panel, &mut cx);
let session_id_a = active_session_id(&panel, &cx);
// Send a chunk to keep thread A generating (don't end the turn).
cx.update(|_, cx| {
connection_a.send_update(
session_id_a.clone(),
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
cx,
);
});
cx.run_until_parked();
// Verify thread A is generating.
panel.read_with(&cx, |panel, cx| {
let thread = panel.active_agent_thread(cx).unwrap();
assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
assert!(panel.background_threads.is_empty());
});
// Open a new thread B — thread A should be retained in background.
let connection_b = StubAgentConnection::new();
open_thread_with_connection(&panel, connection_b, &mut cx);
panel.read_with(&cx, |panel, _cx| {
assert_eq!(
panel.background_threads.len(),
1,
"Running thread A should be retained in background_views"
);
assert!(
panel.background_threads.contains_key(&session_id_a),
"Background view should be keyed by thread A's session ID"
);
});
}
#[gpui::test]
async fn test_idle_non_loadable_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
let connection_a = StubAgentConnection::new();
connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Response".into()),
)]);
open_thread_with_connection(&panel, connection_a, &mut cx);
send_message(&panel, &mut cx);
let weak_view_a = panel.read_with(&cx, |panel, _cx| {
panel.active_conversation_view().unwrap().downgrade()
});
let session_id_a = active_session_id(&panel, &cx);
// Thread A should be idle (auto-completed via set_next_prompt_updates).
panel.read_with(&cx, |panel, cx| {
let thread = panel.active_agent_thread(cx).unwrap();
assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
});
// Open a new thread B — thread A should be retained because it is not loadable.
let connection_b = StubAgentConnection::new();
open_thread_with_connection(&panel, connection_b, &mut cx);
panel.read_with(&cx, |panel, _cx| {
assert_eq!(
panel.background_threads.len(),
1,
"Idle non-loadable thread A should be retained in background_views"
);
assert!(
panel.background_threads.contains_key(&session_id_a),
"Background view should be keyed by thread A's session ID"
);
});
assert!(
weak_view_a.upgrade().is_some(),
"Idle non-loadable ConnectionView should still be retained"
);
}
#[gpui::test]
async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
let connection_a = StubAgentConnection::new();
open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
send_message(&panel, &mut cx);
let session_id_a = active_session_id(&panel, &cx);
// Keep thread A generating.
cx.update(|_, cx| {
connection_a.send_update(
session_id_a.clone(),
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
cx,
);
});
cx.run_until_parked();
// Open thread B — thread A goes to background.
let connection_b = StubAgentConnection::new();
open_thread_with_connection(&panel, connection_b, &mut cx);
send_message(&panel, &mut cx);
let session_id_b = active_session_id(&panel, &cx);
panel.read_with(&cx, |panel, _cx| {
assert_eq!(panel.background_threads.len(), 1);
assert!(panel.background_threads.contains_key(&session_id_a));
});
// Load thread A back via load_agent_thread — should promote from background.
panel.update_in(&mut cx, |panel, window, cx| {
panel.load_agent_thread(
panel.selected_agent().expect("selected agent must be set"),
session_id_a.clone(),
None,
None,
true,
window,
cx,
);
});
// Thread A should now be the active view, promoted from background.
let active_session = active_session_id(&panel, &cx);
assert_eq!(
active_session, session_id_a,
"Thread A should be the active thread after promotion"
);
panel.read_with(&cx, |panel, _cx| {
assert!(
!panel.background_threads.contains_key(&session_id_a),
"Promoted thread A should no longer be in background_views"
);
assert!(
panel.background_threads.contains_key(&session_id_b),
"Thread B (idle, non-loadable) should remain retained in background_views"
);
});
}
#[gpui::test]
async fn test_cleanup_background_threads_keeps_five_most_recent_idle_loadable_threads(
cx: &mut TestAppContext,
) {
let (panel, mut cx) = setup_panel(cx).await;
let connection = StubAgentConnection::new()
.with_supports_load_session(true)
.with_agent_id("loadable-stub".into())
.with_telemetry_id("loadable-stub".into());
let mut session_ids = Vec::new();
for _ in 0..7 {
session_ids.push(open_generating_thread_with_loadable_connection(
&panel,
&connection,
&mut cx,
));
}
let base_time = Instant::now();
for session_id in session_ids.iter().take(6) {
connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
}
cx.run_until_parked();
panel.update(&mut cx, |panel, cx| {
for (index, session_id) in session_ids.iter().take(6).enumerate() {
let conversation_view = panel
.background_threads
.get(session_id)
.expect("background thread should exist")
.clone();
conversation_view.update(cx, |view, cx| {
view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
});
}
panel.cleanup_background_threads(cx);
});
panel.read_with(&cx, |panel, _cx| {
assert_eq!(
panel.background_threads.len(),
5,
"cleanup should keep at most five idle loadable background threads"
);
assert!(
!panel.background_threads.contains_key(&session_ids[0]),
"oldest idle loadable background thread should be removed"
);
for session_id in &session_ids[1..6] {
assert!(
panel.background_threads.contains_key(session_id),
"more recent idle loadable background threads should be retained"
);
}
assert!(
!panel.background_threads.contains_key(&session_ids[6]),
"the active thread should not also be stored as a background thread"
);
});
}
#[gpui::test]
async fn test_cleanup_background_threads_preserves_idle_non_loadable_threads(
cx: &mut TestAppContext,
) {
let (panel, mut cx) = setup_panel(cx).await;
let non_loadable_connection = StubAgentConnection::new();
let non_loadable_session_id = open_idle_thread_with_non_loadable_connection(
&panel,
&non_loadable_connection,
&mut cx,
);
let loadable_connection = StubAgentConnection::new()
.with_supports_load_session(true)
.with_agent_id("loadable-stub".into())
.with_telemetry_id("loadable-stub".into());
let mut loadable_session_ids = Vec::new();
for _ in 0..7 {
loadable_session_ids.push(open_generating_thread_with_loadable_connection(
&panel,
&loadable_connection,
&mut cx,
));
}
let base_time = Instant::now();
for session_id in loadable_session_ids.iter().take(6) {
loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
}
cx.run_until_parked();
panel.update(&mut cx, |panel, cx| {
for (index, session_id) in loadable_session_ids.iter().take(6).enumerate() {
let conversation_view = panel
.background_threads
.get(session_id)
.expect("background thread should exist")
.clone();
conversation_view.update(cx, |view, cx| {
view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
});
}
panel.cleanup_background_threads(cx);
});
panel.read_with(&cx, |panel, _cx| {
assert_eq!(
panel.background_threads.len(),
6,
"cleanup should keep the non-loadable idle thread in addition to five loadable ones"
);
assert!(
panel
.background_threads
.contains_key(&non_loadable_session_id),
"idle non-loadable background threads should not be cleanup candidates"
);
assert!(
!panel
.background_threads
.contains_key(&loadable_session_ids[0]),
"oldest idle loadable background thread should still be removed"
);
for session_id in &loadable_session_ids[1..6] {
assert!(
panel.background_threads.contains_key(session_id),
"more recent idle loadable background threads should be retained"
);
}
assert!(
!panel
.background_threads
.contains_key(&loadable_session_ids[6]),
"the active loadable thread should not also be stored as a background thread"
);
});
}
#[gpui::test]
async fn test_thread_target_local_project(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/project",
json!({
".git": {},
"src": {
"main.rs": "fn main() {}"
}
}),
)
.await;
fs.set_branch_name(Path::new("/project/.git"), Some("main"));
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
workspace.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
// Wait for the project to discover the git repository.
cx.run_until_parked();
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
cx.run_until_parked();
// Default thread target should be LocalProject.
panel.read_with(cx, |panel, _cx| {
assert_eq!(
*panel.start_thread_in(),
StartThreadIn::LocalProject,
"default thread target should be LocalProject"
);
});
// Start a new thread with the default LocalProject target.
// Use StubAgentServer so the thread connects immediately in tests.
panel.update_in(cx, |panel, window, cx| {
panel.open_external_thread_with_server(
Rc::new(StubAgentServer::default_response()),
window,
cx,
);
});
cx.run_until_parked();
// MultiWorkspace should still have exactly one workspace (no worktree created).
multi_workspace
.read_with(cx, |multi_workspace, _cx| {
assert_eq!(
multi_workspace.workspaces().count(),
1,
"LocalProject should not create a new workspace"
);
})
.unwrap();
// The thread should be active in the panel.
panel.read_with(cx, |panel, cx| {
assert!(
panel.active_agent_thread(cx).is_some(),
"a thread should be running in the current workspace"
);
});
// The thread target should still be LocalProject (unchanged).
panel.read_with(cx, |panel, _cx| {
assert_eq!(
*panel.start_thread_in(),
StartThreadIn::LocalProject,
"thread target should remain LocalProject"
);
});
// No worktree creation status should be set.
panel.read_with(cx, |panel, _cx| {
assert!(
panel.worktree_creation_status.is_none(),
"no worktree creation should have occurred"
);
});
}
#[gpui::test]
async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/project",
json!({
".git": {},
"src": {
"main.rs": "fn main() {}"
}
}),
)
.await;
fs.set_branch_name(Path::new("/project/.git"), Some("main"));
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
workspace.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
// Wait for the project to discover the git repository.
cx.run_until_parked();
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
cx.run_until_parked();
// Default should be LocalProject.
panel.read_with(cx, |panel, _cx| {
assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
});
// Change thread target to NewWorktree.
panel.update_in(cx, |panel, window, cx| {
panel.set_start_thread_in(
&StartThreadIn::NewWorktree {
worktree_name: None,
branch_target: NewWorktreeBranchTarget::default(),
},
window,
cx,
);
});
panel.read_with(cx, |panel, _cx| {
assert_eq!(
*panel.start_thread_in(),
StartThreadIn::NewWorktree {
worktree_name: None,
branch_target: NewWorktreeBranchTarget::default(),
},
"thread target should be NewWorktree after set_thread_target"
);
});
// Let serialization complete.
cx.run_until_parked();
// Load a fresh panel from the serialized data.
let async_cx = cx.update(|window, cx| window.to_async(cx));
let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
.await
.expect("panel load should succeed");
cx.run_until_parked();
loaded_panel.read_with(cx, |panel, _cx| {
assert_eq!(
*panel.start_thread_in(),
StartThreadIn::NewWorktree {
worktree_name: None,
branch_target: NewWorktreeBranchTarget::default(),
},
"thread target should survive serialization round-trip"
);
});
}
#[gpui::test]
async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
<dyn fs::Fs>::set_global(fs.clone(), cx);
});
fs.insert_tree(
"/project",
json!({
".git": {},
"src": {
"main.rs": "fn main() {}"
}
}),
)
.await;
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
cx.run_until_parked();
// Simulate worktree creation in progress and reset to Uninitialized
panel.update_in(cx, |panel, window, cx| {
panel.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
panel.active_view = ActiveView::Uninitialized;
Panel::set_active(panel, true, window, cx);
assert!(
matches!(panel.active_view, ActiveView::Uninitialized),
"set_active should not create a thread while worktree is being created"
);
});
// Clear the creation status and use open_external_thread_with_server
// (which bypasses new_agent_thread) to verify the panel can transition
// out of Uninitialized. We can't call set_active directly because
// new_agent_thread requires full agent server infrastructure.
panel.update_in(cx, |panel, window, cx| {
panel.worktree_creation_status = None;
panel.active_view = ActiveView::Uninitialized;
panel.open_external_thread_with_server(
Rc::new(StubAgentServer::default_response()),
window,
cx,
);
});
cx.run_until_parked();
panel.read_with(cx, |panel, _cx| {
assert!(
!matches!(panel.active_view, ActiveView::Uninitialized),
"panel should transition out of Uninitialized once worktree creation is cleared"
);
});
}
#[test]
fn test_deserialize_agent_variants() {
// PascalCase (legacy AgentType format, persisted in panel state)
assert_eq!(
serde_json::from_str::<Agent>(r#""NativeAgent""#).unwrap(),
Agent::NativeAgent,
);
assert_eq!(
serde_json::from_str::<Agent>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
Agent::Custom {
id: "my-agent".into(),
},
);
// Legacy TextThread variant deserializes to NativeAgent
assert_eq!(
serde_json::from_str::<Agent>(r#""TextThread""#).unwrap(),
Agent::NativeAgent,
);
// snake_case (canonical format)
assert_eq!(
serde_json::from_str::<Agent>(r#""native_agent""#).unwrap(),
Agent::NativeAgent,
);
assert_eq!(
serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
Agent::Custom {
id: "my-agent".into(),
},
);
// Serialization uses snake_case
assert_eq!(
serde_json::to_string(&Agent::NativeAgent).unwrap(),
r#""native_agent""#,
);
assert_eq!(
serde_json::to_string(&Agent::Custom {
id: "my-agent".into()
})
.unwrap(),
r#"{"custom":{"name":"my-agent"}}"#,
);
}
#[test]
fn test_resolve_worktree_branch_target() {
let existing_branches = HashSet::from_iter([
"main".to_string(),
"feature".to_string(),
"origin/main".to_string(),
]);
let resolved = AgentPanel::resolve_worktree_branch_target(
&NewWorktreeBranchTarget::CreateBranch {
name: "new-branch".to_string(),
from_ref: Some("main".to_string()),
},
&existing_branches,
&HashSet::from_iter(["main".to_string()]),
)
.unwrap();
assert_eq!(
resolved,
("new-branch".to_string(), false, Some("main".to_string()))
);
let resolved = AgentPanel::resolve_worktree_branch_target(
&NewWorktreeBranchTarget::ExistingBranch {
name: "feature".to_string(),
},
&existing_branches,
&HashSet::default(),
)
.unwrap();
assert_eq!(resolved, ("feature".to_string(), true, None));
let resolved = AgentPanel::resolve_worktree_branch_target(
&NewWorktreeBranchTarget::ExistingBranch {
name: "main".to_string(),
},
&existing_branches,
&HashSet::from_iter(["main".to_string()]),
)
.unwrap();
assert_eq!(resolved.1, false);
assert_eq!(resolved.2, Some("main".to_string()));
assert_ne!(resolved.0, "main");
assert!(existing_branches.contains("main"));
assert!(!existing_branches.contains(&resolved.0));
}
#[gpui::test]
async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
let app_state = workspace::AppState::test(cx);
workspace::init(app_state.clone(), cx);
app_state
});
let fs = app_state.fs.as_fake();
fs.insert_tree(
"/project",
json!({
".git": {},
"src": {
"main.rs": "fn main() {}"
}
}),
)
.await;
fs.set_branch_name(Path::new("/project/.git"), Some("main"));
let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
multi_workspace
.update(cx, |multi_workspace, _, cx| {
multi_workspace.open_sidebar(cx);
})
.unwrap();
let workspace = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
workspace.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
// Register a callback so new workspaces also get an AgentPanel.
cx.update(|cx| {
cx.observe_new(
|workspace: &mut Workspace,
window: Option<&mut Window>,
cx: &mut Context<Workspace>| {
if let Some(window) = window {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel, window, cx);
}
},
)
.detach();
});
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
// Wait for the project to discover the git repository.
cx.run_until_parked();
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
cx.run_until_parked();
// Open a thread (needed so there's an active thread view).
panel.update_in(cx, |panel, window, cx| {
panel.open_external_thread_with_server(
Rc::new(StubAgentServer::default_response()),
window,
cx,
);
});
cx.run_until_parked();
// Set the selected agent to Codex (a custom agent) and start_thread_in
// to NewWorktree. We do this AFTER opening the thread because
// open_external_thread_with_server overrides selected_agent.
panel.update_in(cx, |panel, window, cx| {
panel.selected_agent = Agent::Custom {
id: CODEX_ID.into(),
};
panel.set_start_thread_in(
&StartThreadIn::NewWorktree {
worktree_name: None,
branch_target: NewWorktreeBranchTarget::default(),
},
window,
cx,
);
});
// Verify the panel has the Codex agent selected.
panel.read_with(cx, |panel, _cx| {
assert_eq!(
panel.selected_agent,
Agent::Custom {
id: CODEX_ID.into()
},
);
});
// Directly call handle_worktree_creation_requested, which is what
// handle_first_send_requested does when start_thread_in == NewWorktree.
let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
"Hello from test",
))];
panel.update_in(cx, |panel, window, cx| {
panel.handle_worktree_requested(
content,
WorktreeCreationArgs::New {
worktree_name: None,
branch_target: NewWorktreeBranchTarget::default(),
},
window,
cx,
);
});
// Let the async worktree creation + workspace setup complete.
cx.run_until_parked();
// Find the new workspace's AgentPanel and verify it used the Codex agent.
let found_codex = multi_workspace
.read_with(cx, |multi_workspace, cx| {
// There should be more than one workspace now (the original + the new worktree).
assert!(
multi_workspace.workspaces().count() > 1,
"expected a new workspace to have been created, found {}",
multi_workspace.workspaces().count(),
);
// Check the newest workspace's panel for the correct agent.
let new_workspace = multi_workspace
.workspaces()
.find(|ws| ws.entity_id() != workspace.entity_id())
.expect("should find the new workspace");
let new_panel = new_workspace
.read(cx)
.panel::<AgentPanel>(cx)
.expect("new workspace should have an AgentPanel");
new_panel.read(cx).selected_agent.clone()
})
.unwrap();
assert_eq!(
found_codex,
Agent::Custom {
id: CODEX_ID.into()
},
"the new worktree workspace should use the same agent (Codex) that was selected in the original panel",
);
}
#[gpui::test]
async fn test_work_dirs_update_when_worktrees_change(cx: &mut TestAppContext) {
use crate::thread_metadata_store::ThreadMetadataStore;
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
// Set up a project with one worktree.
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project_a", json!({ "file.txt": "" }))
.await;
let project = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |mw, _cx| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
});
// Open thread A and send a message. With empty next_prompt_updates it
// stays generating, so opening B will move A to background_threads.
let connection_a = StubAgentConnection::new().with_agent_id("agent-a".into());
open_thread_with_custom_connection(&panel, connection_a.clone(), &mut cx);
send_message(&panel, &mut cx);
let session_id_a = active_session_id(&panel, &cx);
// Open thread C — thread A (generating) moves to background.
// Thread C completes immediately (idle), then opening B moves C to background too.
let connection_c = StubAgentConnection::new().with_agent_id("agent-c".into());
connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("done".into()),
)]);
open_thread_with_custom_connection(&panel, connection_c.clone(), &mut cx);
send_message(&panel, &mut cx);
let session_id_c = active_session_id(&panel, &cx);
// Open thread B — thread C (idle, non-loadable) is retained in background.
let connection_b = StubAgentConnection::new().with_agent_id("agent-b".into());
open_thread_with_custom_connection(&panel, connection_b.clone(), &mut cx);
send_message(&panel, &mut cx);
let session_id_b = active_session_id(&panel, &cx);
let metadata_store = cx.update(|_, cx| ThreadMetadataStore::global(cx));
panel.read_with(&cx, |panel, _cx| {
assert!(
panel.background_threads.contains_key(&session_id_a),
"Thread A should be in background_threads"
);
assert!(
panel.background_threads.contains_key(&session_id_c),
"Thread C should be in background_threads"
);
});
// Verify initial work_dirs for thread B contain only /project_a.
let initial_b_paths = panel.read_with(&cx, |panel, cx| {
let thread = panel.active_agent_thread(cx).unwrap();
thread.read(cx).work_dirs().cloned().unwrap()
});
assert_eq!(
initial_b_paths.ordered_paths().collect::<Vec<_>>(),
vec![&PathBuf::from("/project_a")],
"Thread B should initially have only /project_a"
);
// Now add a second worktree to the project.
fs.insert_tree("/project_b", json!({ "other.txt": "" }))
.await;
let (new_tree, _) = project
.update(&mut cx, |project, cx| {
project.find_or_create_worktree("/project_b", true, cx)
})
.await
.unwrap();
cx.read(|cx| new_tree.read(cx).as_local().unwrap().scan_complete())
.await;
cx.run_until_parked();
// Verify thread B's (active) work_dirs now include both worktrees.
let updated_b_paths = panel.read_with(&cx, |panel, cx| {
let thread = panel.active_agent_thread(cx).unwrap();
thread.read(cx).work_dirs().cloned().unwrap()
});
let mut b_paths_sorted = updated_b_paths.ordered_paths().cloned().collect::<Vec<_>>();
b_paths_sorted.sort();
assert_eq!(
b_paths_sorted,
vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
"Thread B work_dirs should include both worktrees after adding /project_b"
);
// Verify thread A's (background) work_dirs are also updated.
let updated_a_paths = panel.read_with(&cx, |panel, cx| {
let bg_view = panel.background_threads.get(&session_id_a).unwrap();
let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
root_thread
.read(cx)
.thread
.read(cx)
.work_dirs()
.cloned()
.unwrap()
});
let mut a_paths_sorted = updated_a_paths.ordered_paths().cloned().collect::<Vec<_>>();
a_paths_sorted.sort();
assert_eq!(
a_paths_sorted,
vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
"Thread A work_dirs should include both worktrees after adding /project_b"
);
// Verify thread idle C was also updated.
let updated_c_paths = panel.read_with(&cx, |panel, cx| {
let bg_view = panel.background_threads.get(&session_id_c).unwrap();
let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
root_thread
.read(cx)
.thread
.read(cx)
.work_dirs()
.cloned()
.unwrap()
});
let mut c_paths_sorted = updated_c_paths.ordered_paths().cloned().collect::<Vec<_>>();
c_paths_sorted.sort();
assert_eq!(
c_paths_sorted,
vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
"Thread C (idle background) work_dirs should include both worktrees after adding /project_b"
);
// Verify the metadata store reflects the new paths for running threads only.
cx.run_until_parked();
for (label, session_id) in [("thread B", &session_id_b), ("thread A", &session_id_a)] {
let metadata_paths = metadata_store.read_with(&cx, |store, _cx| {
let metadata = store
.entry(session_id)
.unwrap_or_else(|| panic!("{label} thread metadata should exist"));
metadata.folder_paths.clone()
});
let mut sorted = metadata_paths.ordered_paths().cloned().collect::<Vec<_>>();
sorted.sort();
assert_eq!(
sorted,
vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
"{label} thread metadata folder_paths should include both worktrees"
);
}
// Now remove a worktree and verify work_dirs shrink.
let worktree_b_id = new_tree.read_with(&cx, |tree, _| tree.id());
project.update(&mut cx, |project, cx| {
project.remove_worktree(worktree_b_id, cx);
});
cx.run_until_parked();
let after_remove_b = panel.read_with(&cx, |panel, cx| {
let thread = panel.active_agent_thread(cx).unwrap();
thread.read(cx).work_dirs().cloned().unwrap()
});
assert_eq!(
after_remove_b.ordered_paths().collect::<Vec<_>>(),
vec![&PathBuf::from("/project_a")],
"Thread B work_dirs should revert to only /project_a after removing /project_b"
);
let after_remove_a = panel.read_with(&cx, |panel, cx| {
let bg_view = panel.background_threads.get(&session_id_a).unwrap();
let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
root_thread
.read(cx)
.thread
.read(cx)
.work_dirs()
.cloned()
.unwrap()
});
assert_eq!(
after_remove_a.ordered_paths().collect::<Vec<_>>(),
vec![&PathBuf::from("/project_a")],
"Thread A work_dirs should revert to only /project_a after removing /project_b"
);
}
#[gpui::test]
async fn test_new_workspace_inherits_global_last_used_agent(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
// Use an isolated DB so parallel tests can't overwrite our global key.
cx.set_global(db::AppDatabase::test_new());
});
let custom_agent = Agent::Custom {
id: "my-preferred-agent".into(),
};
// Write a known agent to the global KVP to simulate a user who has
// previously used this agent in another workspace.
let kvp = cx.update(|cx| KeyValueStore::global(cx));
write_global_last_used_agent(kvp, custom_agent.clone()).await;
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), [], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
workspace.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
// Load the panel via `load()`, which reads the global fallback
// asynchronously when no per-workspace state exists.
let async_cx = cx.update(|window, cx| window.to_async(cx));
let panel = AgentPanel::load(workspace.downgrade(), async_cx)
.await
.expect("panel load should succeed");
cx.run_until_parked();
panel.read_with(cx, |panel, _cx| {
assert_eq!(
panel.selected_agent, custom_agent,
"new workspace should inherit the global last-used agent"
);
});
}
#[gpui::test]
async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
let project_a = Project::test(fs.clone(), [], cx).await;
let project_b = Project::test(fs, [], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let workspace_a = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
let workspace_b = multi_workspace
.update(cx, |multi_workspace, window, cx| {
multi_workspace.test_add_workspace(project_b.clone(), window, cx)
})
.unwrap();
workspace_a.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
workspace_b.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let agent_a = Agent::Custom {
id: "agent-alpha".into(),
};
let agent_b = Agent::Custom {
id: "agent-beta".into(),
};
// Set up workspace A with agent_a
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
});
panel_a.update(cx, |panel, _cx| {
panel.selected_agent = agent_a.clone();
});
// Set up workspace B with agent_b
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
});
panel_b.update(cx, |panel, _cx| {
panel.selected_agent = agent_b.clone();
});
// Serialize both panels
panel_a.update(cx, |panel, cx| panel.serialize(cx));
panel_b.update(cx, |panel, cx| panel.serialize(cx));
cx.run_until_parked();
// Load fresh panels from serialized state and verify independence
let async_cx = cx.update(|window, cx| window.to_async(cx));
let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
.await
.expect("panel A load should succeed");
cx.run_until_parked();
let async_cx = cx.update(|window, cx| window.to_async(cx));
let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
.await
.expect("panel B load should succeed");
cx.run_until_parked();
loaded_a.read_with(cx, |panel, _cx| {
assert_eq!(
panel.selected_agent, agent_a,
"workspace A should restore agent-alpha, not agent-beta"
);
});
loaded_b.read_with(cx, |panel, _cx| {
assert_eq!(
panel.selected_agent, agent_b,
"workspace B should restore agent-beta, not agent-alpha"
);
});
}
#[gpui::test]
async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), [], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
workspace.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let custom_agent = Agent::Custom {
id: "my-custom-agent".into(),
};
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
// Set selected_agent to a custom agent
panel.update(cx, |panel, _cx| {
panel.selected_agent = custom_agent.clone();
});
// Call new_thread, which internally calls external_thread(None, ...)
// This resolves the agent from self.selected_agent
panel.update_in(cx, |panel, window, cx| {
panel.new_thread(&NewThread, window, cx);
});
panel.read_with(cx, |panel, _cx| {
assert_eq!(
panel.selected_agent, custom_agent,
"selected_agent should remain the custom agent after new_thread"
);
assert!(
panel.active_conversation_view().is_some(),
"a thread should have been created"
);
});
}
}