sidebar: Add terminals to thread switcher (#56388)
Some checks are pending
Congratsbot / congrats (push) Blocked by required conditions
Congratsbot / check-author (push) Waiting to run
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions

Allow confirming a terminal entry to activate it, track terminal
access for ordering, and close selected terminals via the archive
action.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Ben Brandt 2026-05-11 14:21:29 +02:00 committed by GitHub
parent 27fe33cead
commit 7d3989602d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 595 additions and 238 deletions

View file

@ -225,24 +225,9 @@
"bindings": {
"ctrl-n": "agent::NewThread",
"ctrl-alt-c": "agent::OpenSettings",
"ctrl-alt-p": "agent::ManageProfiles",
"ctrl-alt-l": "agent::OpenRulesLibrary",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"ctrl-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
// `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Linux under the `AgentPanel` context
"alt-l": "agent::CycleFavoriteModels",
"shift-alt-i": "agent::ToggleOptionsMenu",
"ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl->": "agent::AddSelectionToThread",
"ctrl-shift-e": "project_panel::ToggleFocus",
"ctrl-shift-enter": "agent::ContinueThread",
"shift-alt-q": "agent::AllowAlways",
"shift-alt-a": "agent::AllowOnce",
"ctrl-alt-a": "agent::OpenPermissionDropdown",
"shift-alt-x": "agent::RejectOnce",
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
@ -255,14 +240,6 @@
"ctrl-c": "markdown::CopyAsMarkdown",
},
},
{
"context": "AgentPanel && acp_thread",
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "agent::NewExternalAgentThread",
"ctrl-alt-t": "agent::NewThread",
},
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"bindings": {
@ -279,8 +256,24 @@
},
{
"context": "AcpThread",
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "agent::NewThread",
"ctrl--": "pane::GoBack",
"ctrl-alt-p": "agent::ManageProfiles",
"ctrl-alt-l": "agent::OpenRulesLibrary",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"ctrl-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
// `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Linux under the `AcpThread` context
"alt-l": "agent::CycleFavoriteModels",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl->": "agent::AddSelectionToThread",
"shift-alt-q": "agent::AllowAlways",
"shift-alt-a": "agent::AllowOnce",
"ctrl-alt-a": "agent::OpenPermissionDropdown",
"shift-alt-x": "agent::RejectOnce",
"pageup": "agent::ScrollOutputPageUp",
"pagedown": "agent::ScrollOutputPageDown",
"home": "agent::ScrollOutputToTop",
@ -1248,7 +1241,7 @@
},
},
{
"context": "AgentPanel && Terminal",
"context": "AgentPanel > Terminal",
"bindings": {
"ctrl-n": "agent::NewThread",
},

View file

@ -265,21 +265,9 @@
"bindings": {
"cmd-n": "agent::NewThread",
"cmd-alt-c": "agent::OpenSettings",
"cmd-alt-l": "agent::OpenRulesLibrary",
"cmd-alt-p": "agent::ManageProfiles",
"cmd-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"cmd-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
"cmd-alt-m": "agent::ToggleOptionsMenu",
"cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
"cmd->": "agent::AddSelectionToThread",
"cmd-shift-e": "project_panel::ToggleFocus",
"cmd-shift-enter": "agent::ContinueThread",
"cmd-y": "agent::AllowOnce",
"cmd-alt-a": "agent::OpenPermissionDropdown",
"cmd-alt-z": "agent::RejectOnce",
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
@ -291,14 +279,6 @@
"cmd-c": "markdown::CopyAsMarkdown",
},
},
{
"context": "AgentPanel && acp_thread",
"use_key_equivalents": true,
"bindings": {
"cmd-n": "agent::NewExternalAgentThread",
"cmd-alt-t": "agent::NewThread",
},
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"use_key_equivalents": true,
@ -322,8 +302,21 @@
},
{
"context": "AcpThread",
"use_key_equivalents": true,
"bindings": {
"cmd-n": "agent::NewThread",
"ctrl--": "pane::GoBack",
"cmd-alt-l": "agent::OpenRulesLibrary",
"cmd-alt-p": "agent::ManageProfiles",
"cmd-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"cmd-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
"shift-alt-escape": "agent::ExpandMessageEditor",
"cmd->": "agent::AddSelectionToThread",
"cmd-y": "agent::AllowOnce",
"cmd-alt-a": "agent::OpenPermissionDropdown",
"cmd-alt-z": "agent::RejectOnce",
"pageup": "agent::ScrollOutputPageUp",
"pagedown": "agent::ScrollOutputPageDown",
"home": "agent::ScrollOutputToTop",

View file

@ -226,24 +226,9 @@
"bindings": {
"ctrl-n": "agent::NewThread",
"shift-alt-c": "agent::OpenSettings",
"shift-alt-l": "agent::OpenRulesLibrary",
"shift-alt-p": "agent::ManageProfiles",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
// `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Windows under the `AgentPanel` context
"alt-l": "agent::CycleFavoriteModels",
"shift-alt-/": "agent::ToggleModelSelector",
"shift-alt-i": "agent::ToggleOptionsMenu",
"ctrl-shift-alt-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl-shift-.": "agent::AddSelectionToThread",
"ctrl-shift-e": "project_panel::ToggleFocus",
"ctrl-shift-enter": "agent::ContinueThread",
"shift-alt-q": "agent::AllowAlways",
"shift-alt-a": "agent::AllowOnce",
"ctrl-alt-a": "agent::OpenPermissionDropdown",
"shift-alt-x": "agent::RejectOnce",
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
@ -255,14 +240,6 @@
"ctrl-c": "markdown::CopyAsMarkdown",
},
},
{
"context": "AgentPanel && acp_thread",
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "agent::NewExternalAgentThread",
"ctrl-alt-t": "agent::NewThread",
},
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"use_key_equivalents": true,
@ -280,8 +257,24 @@
},
{
"context": "AcpThread",
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "agent::NewThread",
"ctrl--": "pane::GoBack",
"shift-alt-l": "agent::OpenRulesLibrary",
"shift-alt-p": "agent::ManageProfiles",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"shift-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
// `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Windows under the `AcpThread` context
"alt-l": "agent::CycleFavoriteModels",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl-shift-.": "agent::AddSelectionToThread",
"shift-alt-q": "agent::AllowAlways",
"shift-alt-a": "agent::AllowOnce",
"ctrl-alt-a": "agent::OpenPermissionDropdown",
"shift-alt-x": "agent::RejectOnce",
"pageup": "agent::ScrollOutputPageUp",
"pagedown": "agent::ScrollOutputPageDown",
"home": "agent::ScrollOutputToTop",

View file

@ -1310,6 +1310,11 @@ impl AgentPanel {
self.new_entry(None, window, cx);
}
fn new_agent_thread(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
self.selected_agent = agent;
self.activate_new_thread(true, "agent_panel", window, cx);
}
pub fn activate_new_thread(
&mut self,
focus: bool,
@ -1327,10 +1332,7 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(agent) = action.agent.clone() {
self.selected_agent = agent;
}
self.activate_new_thread(true, "agent_panel", window, cx);
self.new_agent_thread(action.agent.clone().into(), window, cx);
}
pub fn new_terminal(
@ -3366,6 +3368,7 @@ impl AgentPanel {
cx: &mut Context<Self>,
) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
let showing_terminal = matches!(self.visible_surface(), VisibleSurface::Terminal(_));
let conversation_view = match &self.base_view {
BaseView::AgentThread { conversation_view } => Some(conversation_view.clone()),
@ -3427,21 +3430,26 @@ impl AgentPanel {
}
}
if !showing_terminal {
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()))
.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));
@ -3544,7 +3552,7 @@ impl AgentPanel {
.item(
ContextMenuEntry::new("Zed Agent")
.when(is_agent_selected(Agent::NativeAgent), |this| {
this.action(Box::new(NewExternalAgentThread { agent: None }))
this.action(Box::new(NewThread))
})
.icon(IconName::ZedAgent)
.icon_color(Color::Muted)
@ -3557,10 +3565,8 @@ impl AgentPanel {
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
panel.new_external_agent_thread(
&NewExternalAgentThread {
agent: Some(Agent::NativeAgent),
},
panel.new_agent_thread(
Agent::NativeAgent,
window,
cx,
);
@ -3574,6 +3580,7 @@ impl AgentPanel {
.when(supports_terminal, |menu| {
menu.item(
ContextMenuEntry::new("Terminal")
.when(showing_terminal, |this| this.action(Box::new(NewThread)))
.icon(IconName::Terminal)
.icon_color(Color::Muted)
.handler({
@ -3653,11 +3660,7 @@ impl AgentPanel {
is_agent_selected(Agent::Custom {
id: item.id.clone(),
}),
|this| {
this.action(Box::new(NewExternalAgentThread {
agent: None,
}))
},
|this| this.action(Box::new(NewThread)),
)
.icon_color(Color::Muted)
.disabled(is_via_collab)
@ -3673,9 +3676,7 @@ impl AgentPanel {
panel.update(cx, |panel, cx| {
panel.new_external_agent_thread(
&NewExternalAgentThread {
agent: Some(Agent::Custom {
id: agent_id.clone(),
}),
agent: agent_id.clone(),
},
window,
cx,
@ -4124,12 +4125,6 @@ impl AgentPanel {
fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("AgentPanel");
match self.visible_surface() {
VisibleSurface::AgentThread(_) => key_context.add("acp_thread"),
VisibleSurface::Terminal(_)
| VisibleSurface::Configuration(_)
| VisibleSurface::Uninitialized => {}
}
key_context
}
}
@ -6601,7 +6596,7 @@ mod tests {
});
// Press cmd-n (activate_draft again with the same agent).
cx.dispatch_action(NewExternalAgentThread { agent: None });
cx.dispatch_action(NewThread);
cx.run_until_parked();
// The draft entity should not have changed.
@ -6680,7 +6675,7 @@ mod tests {
// Switch to a different agent. ensure_draft should extract the typed
// content from the old draft and pre-fill the new one.
cx.dispatch_action(NewExternalAgentThread {
agent: Some(Agent::Stub),
agent: Agent::Stub.id(),
});
cx.run_until_parked();

View file

@ -149,8 +149,6 @@ actions!(
ResetTrialEndUpsell,
/// Opens the "Add Context" menu in the message editor.
OpenAddContextMenu,
/// Continues the current thread.
ContinueThread,
/// Interrupts the current generation and sends the message immediately.
SendImmediately,
/// Sends the next queued message immediately.
@ -246,12 +244,33 @@ pub struct ToggleCommandPattern {
pub struct NewThread;
/// Creates a new external agent conversation thread.
#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)]
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
#[serde(deny_unknown_fields)]
pub struct NewExternalAgentThread {
/// Which agent to use for the conversation.
agent: Option<Agent>,
/// The agent id to use for the conversation.
#[serde(deserialize_with = "deserialize_external_agent_id")]
agent: AgentId,
}
fn deserialize_external_agent_id<'de, D>(deserializer: D) -> Result<AgentId, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum AgentIdOrLegacyAgent {
LegacyAgent(Agent),
AgentId(AgentId),
}
match AgentIdOrLegacyAgent::deserialize(deserializer)? {
AgentIdOrLegacyAgent::AgentId(agent_id) => Ok(agent_id),
AgentIdOrLegacyAgent::LegacyAgent(Agent::Custom { id }) => Ok(id),
AgentIdOrLegacyAgent::LegacyAgent(Agent::NativeAgent) => Ok(Agent::NativeAgent.id()),
#[cfg(any(test, feature = "test-support"))]
AgentIdOrLegacyAgent::LegacyAgent(Agent::Stub) => Ok(Agent::Stub.id()),
}
}
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
@ -280,10 +299,13 @@ pub enum Agent {
impl From<AgentId> for Agent {
fn from(id: AgentId) -> Self {
if id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
Self::NativeAgent
} else {
Self::Custom { id }
return Self::NativeAgent;
}
#[cfg(any(test, feature = "test-support"))]
if id.as_ref() == "stub" {
return Self::Stub;
}
Self::Custom { id }
}
}
@ -910,4 +932,23 @@ mod tests {
},
);
}
#[test]
fn test_deserialize_new_external_agent_thread() {
let action = serde_json::from_str::<NewExternalAgentThread>(r#"{"agent":"gemini"}"#)
.expect("should deserialize agent id");
assert_eq!(action.agent, AgentId::from("gemini"));
let action = serde_json::from_str::<NewExternalAgentThread>(
r#"{"agent":{"custom":{"name":"gemini"}}}"#,
)
.expect("should deserialize legacy custom agent payload");
assert_eq!(action.agent, AgentId::from("gemini"));
let action = serde_json::from_str::<NewExternalAgentThread>(r#"{"agent":"NativeAgent"}"#)
.expect("should deserialize legacy native agent payload");
assert_eq!(action.agent, Agent::NativeAgent.id());
assert!(serde_json::from_str::<NewExternalAgentThread>(r#"{}"#).is_err());
}
}

View file

@ -66,7 +66,10 @@ use zed_actions::editor::{MoveDown, MoveUp};
use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher};
use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent};
use crate::thread_switcher::{
ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent, ThreadSwitcherSelection,
ThreadSwitcherTerminalEntry, ThreadSwitcherThreadEntry,
};
#[cfg(test)]
mod sidebar_tests;
@ -376,6 +379,10 @@ impl SidebarContents {
fn is_thread_notified(&self, thread_id: &agent_ui::ThreadId) -> bool {
self.notified_threads.contains(thread_id)
}
fn is_terminal_notified(&self, terminal_id: TerminalId) -> bool {
self.notified_terminals.contains(&terminal_id)
}
}
// TODO: The mapping from workspace root paths to git repositories needs a
@ -572,6 +579,7 @@ pub struct Sidebar {
/// thread, confirming in the thread switcher, etc.) — never from
/// background data changes. Used to sort the thread switcher popup.
thread_last_accessed: HashMap<ThreadId, DateTime<Utc>>,
terminal_last_accessed: HashMap<TerminalId, DateTime<Utc>>,
thread_switcher: Option<Entity<ThreadSwitcher>>,
_thread_switcher_subscriptions: Vec<gpui::Subscription>,
pending_thread_activation: Option<agent_ui::ThreadId>,
@ -678,6 +686,7 @@ impl Sidebar {
hovered_thread_index: None,
thread_last_accessed: HashMap::new(),
terminal_last_accessed: HashMap::new(),
thread_switcher: None,
_thread_switcher_subscriptions: Vec::new(),
pending_thread_activation: None,
@ -1123,6 +1132,7 @@ impl Sidebar {
let mut notified_terminals: HashSet<TerminalId> = HashSet::new();
let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
let mut current_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new();
let mut current_terminal_ids: HashSet<TerminalId> = HashSet::new();
let mut project_header_indices: Vec<usize> = Vec::new();
let mut seen_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new();
@ -1188,6 +1198,7 @@ impl Sidebar {
.iter()
.flat_map(|workspace| terminal_entries_for_workspace(workspace, cx))
.collect();
current_terminal_ids.extend(terminals.iter().map(|terminal| terminal.id));
notified_terminals.extend(
terminals
.iter()
@ -1530,6 +1541,8 @@ impl Sidebar {
self.thread_last_accessed
.retain(|id, _| current_thread_ids.contains(id));
self.terminal_last_accessed
.retain(|id, _| current_terminal_ids.contains(id));
self.contents = SidebarContents {
entries,
@ -3275,6 +3288,7 @@ impl Sidebar {
return;
};
self.record_terminal_access(terminal_id);
self.active_entry = Some(ActiveEntry::Terminal {
terminal_id,
workspace: workspace.clone(),
@ -3873,6 +3887,11 @@ impl Sidebar {
self.archive_thread(&session_id, window, cx);
}
}
Some(ListEntry::Terminal(terminal)) => {
let workspace = terminal.workspace.clone();
let terminal_id = terminal.id;
self.close_terminal(&workspace, terminal_id, window, cx);
}
_ => {}
}
}
@ -3881,6 +3900,10 @@ impl Sidebar {
self.thread_last_accessed.insert(*id, Utc::now());
}
fn record_terminal_access(&mut self, id: TerminalId) {
self.terminal_last_accessed.insert(id, Utc::now());
}
fn record_thread_interacted(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut App) {
let store = ThreadMetadataStore::global(cx);
store.update(cx, |store, cx| {
@ -3925,20 +3948,30 @@ impl Sidebar {
}
/// The sort order used by the ctrl-tab switcher
fn thread_cmp_for_switcher(&self, left: &ThreadMetadata, right: &ThreadMetadata) -> Ordering {
let sort_time = |x: &ThreadMetadata| {
self.thread_last_accessed
.get(&x.thread_id)
fn switcher_entry_cmp(
&self,
left: &ThreadSwitcherEntry,
right: &ThreadSwitcherEntry,
) -> Ordering {
let sort_time = |entry: &ThreadSwitcherEntry| match entry {
ThreadSwitcherEntry::Thread(entry) => self
.thread_last_accessed
.get(&entry.metadata.thread_id)
.copied()
.or(x.interacted_at)
.unwrap_or(x.updated_at)
.or(entry.metadata.interacted_at)
.unwrap_or(entry.metadata.updated_at),
ThreadSwitcherEntry::Terminal(entry) => self
.terminal_last_accessed
.get(&entry.terminal_id)
.copied()
.unwrap_or(entry.created_at),
};
// .reverse() = most recent first
sort_time(left).cmp(&sort_time(right)).reverse()
}
fn mru_threads_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
fn mru_entries_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
let mut current_header_label: Option<SharedString> = None;
let mut current_header_key: Option<ProjectGroupKey> = None;
let mut entries: Vec<ThreadSwitcherEntry> = self
@ -3971,7 +4004,7 @@ impl Sidebar {
let timestamp: SharedString =
format_history_entry_timestamp(Self::thread_display_time(&thread.metadata))
.into();
Some(ThreadSwitcherEntry {
Some(ThreadSwitcherEntry::Thread(ThreadSwitcherThreadEntry {
session_id,
title: thread.metadata.display_title(),
icon: thread.icon,
@ -3993,13 +4026,25 @@ impl Sidebar {
is_title_generating: thread.is_title_generating,
notified,
timestamp,
})
}))
}
ListEntry::Terminal(terminal) => {
let timestamp: SharedString =
format_history_entry_timestamp(terminal.created_at).into();
Some(ThreadSwitcherEntry::Terminal(ThreadSwitcherTerminalEntry {
terminal_id: terminal.id,
title: terminal.title.clone(),
workspace: terminal.workspace.clone(),
project_name: current_header_label.clone(),
created_at: terminal.created_at,
notified: self.contents.is_terminal_notified(terminal.id),
timestamp,
}))
}
ListEntry::Terminal(_) => None,
})
.collect();
entries.sort_by(|a, b| self.thread_cmp_for_switcher(&a.metadata, &b.metadata));
entries.sort_by(|a, b| self.switcher_entry_cmp(a, b));
entries
}
@ -4023,6 +4068,96 @@ impl Sidebar {
self.toggle_thread_switcher_impl(action.select_last, window, cx);
}
fn preview_switcher_selection(
&mut self,
selection: &ThreadSwitcherSelection,
window: &mut Window,
cx: &mut Context<Self>,
) {
match selection {
ThreadSwitcherSelection::Thread {
metadata,
workspace,
} => {
if let Some(multi_workspace) = self.multi_workspace.upgrade() {
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.activate(workspace.clone(), None, window, cx);
});
}
self.active_entry = Some(ActiveEntry::Thread {
thread_id: metadata.thread_id,
session_id: metadata.session_id.clone(),
workspace: workspace.clone(),
});
self.update_entries(cx);
Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
}
ThreadSwitcherSelection::Terminal {
terminal_id,
workspace,
} => {
if !cx.has_flag::<AgentPanelTerminalFeatureFlag>() {
return;
}
if let Some(multi_workspace) = self.multi_workspace.upgrade() {
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.activate(workspace.clone(), None, window, cx);
});
}
self.active_entry = Some(ActiveEntry::Terminal {
terminal_id: *terminal_id,
workspace: workspace.clone(),
});
self.update_entries(cx);
workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.activate_terminal(*terminal_id, false, window, cx);
});
}
workspace.reveal_panel::<AgentPanel>(window, cx);
});
}
}
}
fn confirm_switcher_selection(
&mut self,
selection: &ThreadSwitcherSelection,
window: &mut Window,
cx: &mut Context<Self>,
) {
match selection {
ThreadSwitcherSelection::Thread {
metadata,
workspace,
} => {
if let Some(multi_workspace) = self.multi_workspace.upgrade() {
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.activate(workspace.clone(), None, window, cx);
multi_workspace.retain_active_workspace(cx);
});
}
self.record_thread_access(&metadata.thread_id);
self.active_entry = Some(ActiveEntry::Thread {
thread_id: metadata.thread_id,
session_id: metadata.session_id.clone(),
workspace: workspace.clone(),
});
self.update_entries(cx);
self.dismiss_thread_switcher(cx);
Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
}
ThreadSwitcherSelection::Terminal {
terminal_id,
workspace,
} => {
self.dismiss_thread_switcher(cx);
self.activate_terminal(workspace, *terminal_id, true, window, cx);
}
}
}
fn toggle_thread_switcher_impl(
&mut self,
select_last: bool,
@ -4040,21 +4175,27 @@ impl Sidebar {
return;
}
let entries = self.mru_threads_for_switcher(cx);
let entries = self.mru_entries_for_switcher(cx);
if entries.len() < 2 {
return;
}
let weak_multi_workspace = self.multi_workspace.clone();
// Capture the full active entry so dismissal can restore terminal
// entries too, not just threads.
// Snapshot the active entry (thread or terminal) so dismissal can
// restore it.
let original_active_entry = self.active_entry.clone();
let original_metadata = match &original_active_entry {
Some(ActiveEntry::Thread { thread_id, .. }) => entries
.iter()
.find(|e| *thread_id == e.metadata.thread_id)
.map(|e| e.metadata.clone()),
Some(ActiveEntry::Thread { thread_id, .. }) => {
entries.iter().find_map(|entry| match entry {
ThreadSwitcherEntry::Thread(entry)
if *thread_id == entry.metadata.thread_id =>
{
Some(entry.metadata.clone())
}
_ => None,
})
}
_ => None,
};
let original_workspace = self
@ -4069,44 +4210,13 @@ impl Sidebar {
subscriptions.push(cx.subscribe_in(&thread_switcher, window, {
let thread_switcher = thread_switcher.clone();
move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event {
ThreadSwitcherEvent::Preview {
metadata,
workspace,
} => {
if let Some(mw) = weak_multi_workspace.upgrade() {
mw.update(cx, |mw, cx| {
mw.activate(workspace.clone(), None, window, cx);
});
}
this.active_entry = Some(ActiveEntry::Thread {
thread_id: metadata.thread_id,
session_id: metadata.session_id.clone(),
workspace: workspace.clone(),
});
this.update_entries(cx);
Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
ThreadSwitcherEvent::Preview(selection) => {
this.preview_switcher_selection(selection, window, cx);
let focus = thread_switcher.focus_handle(cx);
window.focus(&focus, cx);
}
ThreadSwitcherEvent::Confirmed {
metadata,
workspace,
} => {
if let Some(mw) = weak_multi_workspace.upgrade() {
mw.update(cx, |mw, cx| {
mw.activate(workspace.clone(), None, window, cx);
mw.retain_active_workspace(cx);
});
}
this.record_thread_access(&metadata.thread_id);
this.active_entry = Some(ActiveEntry::Thread {
thread_id: metadata.thread_id,
session_id: metadata.session_id.clone(),
workspace: workspace.clone(),
});
this.update_entries(cx);
this.dismiss_thread_switcher(cx);
Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
ThreadSwitcherEvent::Confirmed(selection) => {
this.confirm_switcher_selection(selection, window, cx);
}
ThreadSwitcherEvent::Dismissed => {
if let Some(mw) = weak_multi_workspace.upgrade() {
@ -4178,7 +4288,7 @@ impl Sidebar {
let initial_preview = thread_switcher
.read(cx)
.selected_entry()
.map(|entry| (entry.metadata.clone(), entry.workspace.clone()));
.map(ThreadSwitcherEntry::selection);
self.thread_switcher = Some(thread_switcher);
self._thread_switcher_subscriptions = subscriptions;
@ -4188,19 +4298,8 @@ impl Sidebar {
});
}
if let Some((metadata, workspace)) = initial_preview {
if let Some(mw) = self.multi_workspace.upgrade() {
mw.update(cx, |mw, cx| {
mw.activate(workspace.clone(), None, window, cx);
});
}
self.active_entry = Some(ActiveEntry::Thread {
thread_id: metadata.thread_id,
session_id: metadata.session_id.clone(),
workspace: workspace.clone(),
});
self.update_entries(cx);
Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx);
if let Some(selection) = initial_preview {
self.preview_switcher_selection(&selection, window, cx);
}
window.focus(&focus, cx);
@ -4359,6 +4458,7 @@ impl Sidebar {
.blend(color.panel_background.opacity(0.25));
let terminal_id = terminal.id;
let workspace = terminal.workspace.clone();
let focus_handle = self.focus_handle.clone();
ThreadItem::new(id, terminal.title.clone())
.base_bg(sidebar_bg)
@ -4382,7 +4482,17 @@ impl Sidebar {
IconButton::new("close-terminal", IconName::Close)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Close Terminal"))
.tooltip({
let focus_handle = focus_handle.clone();
move |_window, cx| {
Tooltip::for_action_in(
"Close Terminal",
&ArchiveSelectedThread,
&focus_handle,
cx,
)
}
})
.on_click(cx.listener(move |this, _, window, cx| {
this.close_terminal(&workspace, terminal_id, window, cx);
})),

View file

@ -1522,6 +1522,123 @@ async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestApp
});
}
#[gpui::test]
async fn test_thread_switcher_can_activate_agent_panel_terminal(cx: &mut TestAppContext) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
enable_agent_panel_terminal(cx);
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
let build_terminal_id = panel
.update_in(cx, |panel, window, cx| {
panel.insert_test_terminal("Build", true, window, cx)
})
.expect("build test terminal should be inserted");
let server_terminal_id = panel
.update_in(cx, |panel, window, cx| {
panel.insert_test_terminal("Server", true, window, cx)
})
.expect("server test terminal should be inserted");
cx.run_until_parked();
focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
});
cx.run_until_parked();
let (entry_terminal_ids, selected_terminal_id) = sidebar.read_with(cx, |sidebar, cx| {
let switcher = sidebar
.thread_switcher
.as_ref()
.expect("switcher should be open");
let switcher = switcher.read(cx);
let entry_terminal_ids = switcher
.entries()
.iter()
.map(|entry| {
entry
.terminal_id()
.expect("expected terminal switcher entry")
})
.collect::<Vec<_>>();
let selected_terminal_id = switcher
.selected_entry()
.expect("switcher should have selected entry")
.terminal_id()
.expect("expected selected terminal switcher entry");
(entry_terminal_ids, selected_terminal_id)
});
assert_eq!(entry_terminal_ids.len(), 2);
assert!(entry_terminal_ids.contains(&build_terminal_id));
assert!(entry_terminal_ids.contains(&server_terminal_id));
sidebar.update_in(cx, |sidebar, window, cx| {
let switcher = sidebar
.thread_switcher
.as_ref()
.expect("switcher should be open");
let focus = switcher.focus_handle(cx);
focus.dispatch_action(&menu::Confirm, window, cx);
});
cx.run_until_parked();
panel.read_with(cx, |panel, _cx| {
assert_eq!(panel.active_terminal_id(), Some(selected_terminal_id));
});
sidebar.read_with(cx, |sidebar, _cx| {
assert!(
matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id, .. }) if *terminal_id == selected_terminal_id),
"expected selected terminal to become active, got {:?}",
sidebar.active_entry,
);
});
}
#[gpui::test]
async fn test_archive_selected_thread_closes_selected_agent_panel_terminal(
cx: &mut TestAppContext,
) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
enable_agent_panel_terminal(cx);
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
let terminal_id = panel
.update_in(cx, |panel, window, cx| {
panel.insert_test_terminal("Dev Server", true, window, cx)
})
.expect("test terminal should be inserted");
cx.run_until_parked();
focus_sidebar(&sidebar, cx);
let terminal_index = sidebar.read_with(cx, |sidebar, _cx| {
sidebar
.contents
.entries
.iter()
.position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id))
.expect("terminal should be visible in sidebar")
});
sidebar.update_in(cx, |sidebar, _window, _cx| {
sidebar.selection = Some(terminal_index);
});
cx.dispatch_action(ArchiveSelectedThread);
cx.run_until_parked();
panel.read_with(cx, |panel, _cx| {
assert!(!panel.has_terminal(terminal_id));
});
sidebar.read_with(cx, |sidebar, _cx| {
assert!(sidebar.contents.entries.iter().all(|entry| {
!matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id)
}));
});
}
#[gpui::test]
async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut TestAppContext) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
@ -5944,7 +6061,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
.read(cx)
.entries()
.iter()
.map(|e| e.metadata.thread_id)
.map(|entry| entry.thread_id().expect("expected thread switcher entry"))
.collect()
})
};
@ -5959,8 +6076,8 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
let s = switcher.read(cx);
s.selected_entry()
.expect("should have selection")
.metadata
.thread_id
.thread_id()
.expect("expected selected thread entry")
})
};

View file

@ -1,6 +1,6 @@
use action_log::DiffStats;
use agent_client_protocol::schema as acp;
use agent_ui::thread_metadata_store::ThreadMetadata;
use agent_ui::{TerminalId, thread_metadata_store::ThreadMetadata};
use gpui::{
Action as _, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers,
ModifiersChangedEvent, Render, ScrollHandle, SharedString, prelude::*,
@ -9,7 +9,8 @@ use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, WithScrollbar, p
use workspace::{ModalView, Workspace};
use zed_actions::agents_sidebar::ToggleThreadSwitcher;
pub(crate) struct ThreadSwitcherEntry {
#[derive(Clone)]
pub(crate) struct ThreadSwitcherThreadEntry {
pub session_id: acp::SessionId,
pub title: SharedString,
pub icon: IconName,
@ -25,15 +26,150 @@ pub(crate) struct ThreadSwitcherEntry {
pub timestamp: SharedString,
}
#[derive(Clone)]
pub(crate) struct ThreadSwitcherTerminalEntry {
pub terminal_id: TerminalId,
pub title: SharedString,
pub workspace: Entity<Workspace>,
pub project_name: Option<SharedString>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub notified: bool,
pub timestamp: SharedString,
}
#[derive(Clone)]
pub(crate) enum ThreadSwitcherEntry {
Thread(ThreadSwitcherThreadEntry),
Terminal(ThreadSwitcherTerminalEntry),
}
#[derive(Clone)]
pub(crate) enum ThreadSwitcherSelection {
Thread {
metadata: ThreadMetadata,
workspace: Entity<Workspace>,
},
Terminal {
terminal_id: TerminalId,
workspace: Entity<Workspace>,
},
}
impl ThreadSwitcherEntry {
pub(crate) fn selection(&self) -> ThreadSwitcherSelection {
match self {
Self::Thread(entry) => ThreadSwitcherSelection::Thread {
metadata: entry.metadata.clone(),
workspace: entry.workspace.clone(),
},
Self::Terminal(entry) => ThreadSwitcherSelection::Terminal {
terminal_id: entry.terminal_id,
workspace: entry.workspace.clone(),
},
}
}
fn element_id(&self) -> SharedString {
match self {
Self::Thread(entry) => {
SharedString::from(format!("thread-switcher-thread-{}", entry.session_id))
}
Self::Terminal(entry) => {
SharedString::from(format!("thread-switcher-terminal-{}", entry.terminal_id))
}
}
}
fn title(&self) -> SharedString {
match self {
Self::Thread(entry) => entry.title.clone(),
Self::Terminal(entry) => entry.title.clone(),
}
}
fn icon(&self) -> IconName {
match self {
Self::Thread(entry) => entry.icon,
Self::Terminal(_) => IconName::Terminal,
}
}
fn icon_from_external_svg(&self) -> Option<SharedString> {
match self {
Self::Thread(entry) => entry.icon_from_external_svg.clone(),
Self::Terminal(_) => None,
}
}
fn status(&self) -> AgentThreadStatus {
match self {
Self::Thread(entry) => entry.status,
Self::Terminal(_) => AgentThreadStatus::default(),
}
}
fn project_name(&self) -> Option<SharedString> {
match self {
Self::Thread(entry) => entry.project_name.clone(),
Self::Terminal(entry) => entry.project_name.clone(),
}
}
fn worktrees(&self) -> Vec<ThreadItemWorktreeInfo> {
match self {
Self::Thread(entry) => entry.worktrees.clone(),
Self::Terminal(_) => Vec::new(),
}
}
fn timestamp(&self) -> SharedString {
match self {
Self::Thread(entry) => entry.timestamp.clone(),
Self::Terminal(entry) => entry.timestamp.clone(),
}
}
fn is_title_generating(&self) -> bool {
match self {
Self::Thread(entry) => entry.is_title_generating,
Self::Terminal(_) => false,
}
}
fn notified(&self) -> bool {
match self {
Self::Thread(entry) => entry.notified,
Self::Terminal(entry) => entry.notified,
}
}
fn diff_stats(&self) -> DiffStats {
match self {
Self::Thread(entry) => entry.diff_stats,
Self::Terminal(_) => DiffStats::default(),
}
}
#[cfg(test)]
pub fn thread_id(&self) -> Option<agent_ui::ThreadId> {
match self {
Self::Thread(entry) => Some(entry.metadata.thread_id),
Self::Terminal(_) => None,
}
}
#[cfg(test)]
pub fn terminal_id(&self) -> Option<TerminalId> {
match self {
Self::Thread(_) => None,
Self::Terminal(entry) => Some(entry.terminal_id),
}
}
}
pub(crate) enum ThreadSwitcherEvent {
Preview {
metadata: ThreadMetadata,
workspace: Entity<Workspace>,
},
Confirmed {
metadata: ThreadMetadata,
workspace: Entity<Workspace>,
},
Preview(ThreadSwitcherSelection),
Confirmed(ThreadSwitcherSelection),
Dismissed,
}
@ -62,10 +198,7 @@ impl ThreadSwitcher {
};
if let Some(entry) = entries.get(selected_index) {
cx.emit(ThreadSwitcherEvent::Preview {
metadata: entry.metadata.clone(),
workspace: entry.workspace.clone(),
});
cx.emit(ThreadSwitcherEvent::Preview(entry.selection()));
}
let focus_handle = cx.focus_handle();
@ -124,10 +257,7 @@ impl ThreadSwitcher {
fn emit_preview(&mut self, cx: &mut Context<Self>) {
self.scroll_handle.scroll_to_item(self.selected_index);
if let Some(entry) = self.entries.get(self.selected_index) {
cx.emit(ThreadSwitcherEvent::Preview {
metadata: entry.metadata.clone(),
workspace: entry.workspace.clone(),
});
cx.emit(ThreadSwitcherEvent::Preview(entry.selection()));
}
}
@ -137,10 +267,7 @@ impl ThreadSwitcher {
fn confirm_selected(&mut self, cx: &mut Context<Self>) {
if let Some(entry) = self.entries.get(self.selected_index) {
cx.emit(ThreadSwitcherEvent::Confirmed {
metadata: entry.metadata.clone(),
workspace: entry.workspace.clone(),
});
cx.emit(ThreadSwitcherEvent::Confirmed(entry.selection()));
}
cx.emit(DismissEvent);
}
@ -233,28 +360,25 @@ impl Render for ThreadSwitcher {
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
.children(self.entries.iter().enumerate().map(|(ix, entry)| {
let id =
SharedString::from(format!("thread-switcher-{}", entry.session_id));
let diff_stats = entry.diff_stats();
ThreadItem::new(id, entry.title.clone())
ThreadItem::new(entry.element_id(), entry.title())
.rounded(true)
.icon(entry.icon)
.status(entry.status)
.when_some(entry.icon_from_external_svg.clone(), |this, svg| {
.icon(entry.icon())
.status(entry.status())
.when_some(entry.icon_from_external_svg(), |this, svg| {
this.custom_icon_from_external_svg(svg)
})
.when_some(entry.project_name.clone(), |this, name| {
this.project_name(name)
.when_some(entry.project_name(), |this, name| this.project_name(name))
.worktrees(entry.worktrees())
.timestamp(entry.timestamp())
.title_generating(entry.is_title_generating())
.notified(entry.notified())
.when(diff_stats.lines_added > 0, |this| {
this.added(diff_stats.lines_added as usize)
})
.worktrees(entry.worktrees.clone())
.timestamp(entry.timestamp.clone())
.title_generating(entry.is_title_generating)
.notified(entry.notified)
.when(entry.diff_stats.lines_added > 0, |this| {
this.added(entry.diff_stats.lines_added as usize)
})
.when(entry.diff_stats.lines_removed > 0, |this| {
this.removed(entry.diff_stats.lines_removed as usize)
.when(diff_stats.lines_removed > 0, |this| {
this.removed(diff_stats.lines_removed as usize)
})
.selected(ix == selected_index)
.base_bg(cx.theme().colors().elevated_surface_background)

View file

@ -41,7 +41,7 @@ From the "New Thread…" menu you can:
- Pick **Zed Agent** or any installed [external agent](./external-agents.md) to start a new thread with that agent.
- Choose **New From Summary** to start a fresh Zed Agent thread seeded with a summary of the current conversation — useful for compacting long threads as you approach the context window limit.
{#action agent::NewExternalAgentThread} creates another thread with the currently selected agent.
{#action agent::NewExternalAgentThread} creates a new thread with the specified external agent id.
You can also start a new thread from the [Threads Sidebar](./parallel-agents.md#threads-sidebar), scoped to a specific project — see [Running Multiple Threads](./parallel-agents.md#running-multiple-threads).

View file

@ -29,10 +29,7 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your
[
{
"bindings": {
"cmd-alt-g": [
"agent::NewExternalAgentThread",
{ "agent": { "custom": { "name": "gemini" } } }
]
"cmd-alt-g": ["agent::NewExternalAgentThread", { "agent": "gemini" }]
}
}
]
@ -75,10 +72,7 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your
[
{
"bindings": {
"cmd-alt-c": [
"agent::NewExternalAgentThread",
{ "agent": { "custom": { "name": "claude-acp" } } }
]
"cmd-alt-c": ["agent::NewExternalAgentThread", { "agent": "claude-acp" }]
}
}
]
@ -150,10 +144,7 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your
[
{
"bindings": {
"cmd-alt-c": [
"agent::NewExternalAgentThread",
{ "agent": { "custom": { "name": "codex-acp" } } }
]
"cmd-alt-c": ["agent::NewExternalAgentThread", { "agent": "codex-acp" }]
}
}
]