Detailed changes
@@ -265,6 +265,8 @@
"ctrl-y": "agent::AllowOnce",
"ctrl-alt-a": "agent::OpenPermissionDropdown",
"ctrl-alt-z": "agent::RejectOnce",
+ "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
},
{
@@ -701,6 +703,8 @@
"ctrl-f": "agents_sidebar::FocusSidebarFilter",
"ctrl-g": "agents_sidebar::ToggleArchive",
"shift-backspace": "agent::RemoveSelectedThread",
+ "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
},
{
@@ -710,6 +714,13 @@
"space": "menu::Confirm",
},
},
+ {
+ "context": "ThreadSwitcher",
+ "bindings": {
+ "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+ }
+ },
{
"context": "Workspace && debugger_running",
"bindings": {
@@ -304,6 +304,8 @@
"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 }],
},
},
{
@@ -767,6 +769,8 @@
"cmd-f": "agents_sidebar::FocusSidebarFilter",
"cmd-g": "agents_sidebar::ToggleArchive",
"shift-backspace": "agent::RemoveSelectedThread",
+ "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
},
{
@@ -776,6 +780,13 @@
"space": "menu::Confirm",
},
},
+ {
+ "context": "ThreadSwitcher",
+ "bindings": {
+ "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+ }
+ },
{
"context": "Workspace && debugger_running",
"use_key_equivalents": true,
@@ -266,6 +266,8 @@
"shift-alt-a": "agent::AllowOnce",
"ctrl-alt-a": "agent::OpenPermissionDropdown",
"shift-alt-z": "agent::RejectOnce",
+ "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
},
{
@@ -703,6 +705,8 @@
"ctrl-f": "agents_sidebar::FocusSidebarFilter",
"ctrl-g": "agents_sidebar::ToggleArchive",
"shift-backspace": "agent::RemoveSelectedThread",
+ "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
},
{
@@ -712,6 +716,13 @@
"space": "menu::Confirm",
},
},
+ {
+ "context": "ThreadSwitcher",
+ "bindings": {
+ "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+ }
+ },
{
"context": "ApplicationMenu",
"use_key_equivalents": true,
@@ -2234,6 +2234,10 @@ impl AgentPanel {
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 });
+ }
},
)
})
@@ -3113,6 +3117,7 @@ pub enum AgentPanelEvent {
ActiveViewChanged,
ThreadFocused,
BackgroundThreadChanged,
+ MessageSentOrQueued { session_id: acp::SessionId },
}
impl EventEmitter<PanelEvent> for AgentPanel {}
@@ -166,6 +166,7 @@ impl ThreadFeedbackState {
pub enum AcpThreadViewEvent {
FirstSendRequested { content: Vec<acp::ContentBlock> },
+ MessageSentOrQueued,
}
impl EventEmitter<AcpThreadViewEvent> for ThreadView {}
@@ -907,6 +908,7 @@ impl ThreadView {
});
if intercept_first_send {
+ cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
let content_task = self.resolve_message_contents(&message_editor, cx);
cx.spawn(async move |this, cx| match content_task.await {
@@ -938,6 +940,7 @@ impl ThreadView {
let has_queued = self.has_queued_messages();
if is_editor_empty && self.can_fast_track_queue && has_queued {
self.can_fast_track_queue = false;
+ cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
self.send_queued_message_at_index(0, true, window, cx);
return;
}
@@ -947,6 +950,7 @@ impl ThreadView {
}
if is_generating {
+ cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
self.queue_message(message_editor, window, cx);
return;
}
@@ -988,6 +992,7 @@ impl ThreadView {
}
}
+ cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
self.send_impl(message_editor, window, cx)
}
@@ -1,3 +1,5 @@
+mod thread_switcher;
+
use acp_thread::ThreadStatus;
use action_log::DiffStats;
use agent_client_protocol::{self as acp};
@@ -9,7 +11,7 @@ use agent_ui::threads_archive_view::{
use agent_ui::{
Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,
};
-use chrono::Utc;
+use chrono::{DateTime, Utc};
use editor::Editor;
use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
use gpui::{
@@ -44,7 +46,9 @@ use workspace::{
use zed_actions::OpenRecent;
use zed_actions::editor::{MoveDown, MoveUp};
-use zed_actions::agents_sidebar::FocusSidebarFilter;
+use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher};
+
+use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent};
use crate::project_group_builder::ProjectGroupBuilder;
@@ -312,6 +316,16 @@ pub struct Sidebar {
hovered_thread_index: Option<usize>,
collapsed_groups: HashSet<PathList>,
expanded_groups: HashMap<PathList, usize>,
+ /// Updated only in response to explicit user actions (clicking a
+ /// thread, confirming in the thread switcher, etc.) — never from
+ /// background data changes. Used to sort the thread switcher popup.
+ thread_last_accessed: HashMap<acp::SessionId, DateTime<Utc>>,
+ /// Updated when the user presses a key to send or queue a message.
+ /// Used for sorting threads in the sidebar and as a secondary sort
+ /// key in the thread switcher.
+ thread_last_message_sent_or_queued: HashMap<acp::SessionId, DateTime<Utc>>,
+ thread_switcher: Option<Entity<ThreadSwitcher>>,
+ _thread_switcher_subscriptions: Vec<gpui::Subscription>,
view: SidebarView,
recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
project_header_menu_ix: Option<usize>,
@@ -404,6 +418,10 @@ impl Sidebar {
hovered_thread_index: None,
collapsed_groups: HashSet::new(),
expanded_groups: HashMap::new(),
+ thread_last_accessed: HashMap::new(),
+ thread_last_message_sent_or_queued: HashMap::new(),
+ thread_switcher: None,
+ _thread_switcher_subscriptions: Vec::new(),
view: SidebarView::default(),
recent_projects_popover_handle: PopoverMenuHandle::default(),
project_header_menu_ix: None,
@@ -506,6 +524,10 @@ impl Sidebar {
AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => {
this.update_entries(cx);
}
+ AgentPanelEvent::MessageSentOrQueued { session_id } => {
+ this.record_thread_message_sent(session_id);
+ this.update_entries(cx);
+ }
},
)
.detach();
@@ -779,7 +801,7 @@ impl Sidebar {
}
}
- // Load threads from linked git worktrees whose
+ // Load threads from linked git worktrees
// canonical paths belong to this group.
let linked_worktree_queries = group
.workspaces
@@ -872,8 +894,18 @@ impl Sidebar {
}
threads.sort_by(|a, b| {
- let a_time = a.session_info.created_at.or(a.session_info.updated_at);
- let b_time = b.session_info.created_at.or(b.session_info.updated_at);
+ let a_time = self
+ .thread_last_message_sent_or_queued
+ .get(&a.session_info.session_id)
+ .copied()
+ .or(a.session_info.created_at)
+ .or(a.session_info.updated_at);
+ let b_time = self
+ .thread_last_message_sent_or_queued
+ .get(&b.session_info.session_id)
+ .copied()
+ .or(b.session_info.created_at)
+ .or(b.session_info.updated_at);
b_time.cmp(&a_time)
});
} else {
@@ -1022,6 +1054,11 @@ impl Sidebar {
// the build pass (no extra scan needed).
notified_threads.retain(|id| current_session_ids.contains(id));
+ self.thread_last_accessed
+ .retain(|id, _| current_session_ids.contains(id));
+ self.thread_last_message_sent_or_queued
+ .retain(|id, _| current_session_ids.contains(id));
+
self.contents = SidebarContents {
entries,
notified_threads,
@@ -1881,6 +1918,7 @@ impl Sidebar {
workspace: &Entity<Workspace>,
agent: Agent,
session_info: acp_thread::AgentSessionInfo,
+ focus: bool,
window: &mut Window,
cx: &mut App,
) {
@@ -1895,7 +1933,7 @@ impl Sidebar {
session_info.session_id,
session_info.work_dirs,
session_info.title,
- true,
+ focus,
window,
cx,
);
@@ -1919,12 +1957,13 @@ impl Sidebar {
// immediately, rather than waiting for a deferred AgentPanel
// event which can race with ActiveWorkspaceChanged clearing it.
self.focused_thread = Some(session_info.session_id.clone());
+ self.record_thread_access(&session_info.session_id);
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.activate(workspace.clone(), cx);
});
- Self::load_agent_thread_in_workspace(workspace, agent, session_info, window, cx);
+ Self::load_agent_thread_in_workspace(workspace, agent, session_info, true, window, cx);
self.update_entries(cx);
}
@@ -1943,7 +1982,14 @@ impl Sidebar {
.update(cx, |multi_workspace, window, cx| {
window.activate_window();
multi_workspace.activate(workspace.clone(), cx);
- Self::load_agent_thread_in_workspace(&workspace, agent, session_info, window, cx);
+ Self::load_agent_thread_in_workspace(
+ &workspace,
+ agent,
+ session_info,
+ true,
+ window,
+ cx,
+ );
})
.log_err()
.is_some();
@@ -1958,7 +2004,8 @@ impl Sidebar {
.and_then(|sidebar| sidebar.downcast::<Self>().ok())
{
target_sidebar.update(cx, |sidebar, cx| {
- sidebar.focused_thread = Some(target_session_id);
+ sidebar.focused_thread = Some(target_session_id.clone());
+ sidebar.record_thread_access(&target_session_id);
sidebar.update_entries(cx);
});
}
@@ -2287,8 +2334,10 @@ impl Sidebar {
});
if let Some(next) = next_thread {
- self.focused_thread = Some(next.session_info.session_id.clone());
-
+ let next_session_id = next.session_info.session_id.clone();
+ let next_agent = next.agent.clone();
+ let next_work_dirs = next.session_info.work_dirs.clone();
+ let next_title = next.session_info.title.clone();
// Use the thread's own workspace when it has one open (e.g. an absorbed
// linked worktree thread that appears under the main workspace's header
// but belongs to its own workspace). Loading into the wrong panel binds
@@ -2298,15 +2347,17 @@ impl Sidebar {
ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
ThreadEntryWorkspace::Closed(_) => group_workspace,
};
+ self.focused_thread = Some(next_session_id.clone());
+ self.record_thread_access(&next_session_id);
if let Some(workspace) = target_workspace {
if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
agent_panel.update(cx, |panel, cx| {
panel.load_agent_thread(
- next.agent.clone(),
- next.session_info.session_id.clone(),
- next.session_info.work_dirs.clone(),
- next.session_info.title.clone(),
+ next_agent,
+ next_session_id,
+ next_work_dirs,
+ next_title,
true,
window,
cx,
@@ -2349,6 +2400,292 @@ impl Sidebar {
self.archive_thread(&session_id, window, cx);
}
+ fn record_thread_access(&mut self, session_id: &acp::SessionId) {
+ self.thread_last_accessed
+ .insert(session_id.clone(), Utc::now());
+ }
+
+ fn record_thread_message_sent(&mut self, session_id: &acp::SessionId) {
+ self.thread_last_message_sent_or_queued
+ .insert(session_id.clone(), Utc::now());
+ }
+
+ fn mru_threads_for_switcher(&self, _cx: &App) -> Vec<ThreadSwitcherEntry> {
+ let mut current_header_workspace: Option<Entity<Workspace>> = None;
+ let mut entries: Vec<ThreadSwitcherEntry> = self
+ .contents
+ .entries
+ .iter()
+ .filter_map(|entry| match entry {
+ ListEntry::ProjectHeader { workspace, .. } => {
+ current_header_workspace = Some(workspace.clone());
+ None
+ }
+ ListEntry::Thread(thread) => {
+ let workspace = match &thread.workspace {
+ ThreadEntryWorkspace::Open(workspace) => workspace.clone(),
+ ThreadEntryWorkspace::Closed(_) => {
+ current_header_workspace.as_ref()?.clone()
+ }
+ };
+ let notified = self
+ .contents
+ .is_thread_notified(&thread.session_info.session_id);
+ let timestamp: SharedString = self
+ .thread_last_message_sent_or_queued
+ .get(&thread.session_info.session_id)
+ .copied()
+ .or(thread.session_info.created_at)
+ .or(thread.session_info.updated_at)
+ .map(format_history_entry_timestamp)
+ .unwrap_or_default()
+ .into();
+ Some(ThreadSwitcherEntry {
+ session_id: thread.session_info.session_id.clone(),
+ title: thread
+ .session_info
+ .title
+ .clone()
+ .unwrap_or_else(|| "Untitled".into()),
+ icon: thread.icon,
+ icon_from_external_svg: thread.icon_from_external_svg.clone(),
+ status: thread.status,
+ agent: thread.agent.clone(),
+ session_info: thread.session_info.clone(),
+ workspace,
+ worktree_name: thread.worktrees.first().map(|wt| wt.name.clone()),
+
+ diff_stats: thread.diff_stats,
+ is_title_generating: thread.is_title_generating,
+ notified,
+ timestamp,
+ })
+ }
+ _ => None,
+ })
+ .collect();
+
+ entries.sort_by(|a, b| {
+ let a_accessed = self.thread_last_accessed.get(&a.session_id);
+ let b_accessed = self.thread_last_accessed.get(&b.session_id);
+
+ match (a_accessed, b_accessed) {
+ (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
+ (Some(_), None) => std::cmp::Ordering::Less,
+ (None, Some(_)) => std::cmp::Ordering::Greater,
+ (None, None) => {
+ let a_sent = self.thread_last_message_sent_or_queued.get(&a.session_id);
+ let b_sent = self.thread_last_message_sent_or_queued.get(&b.session_id);
+
+ match (a_sent, b_sent) {
+ (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
+ (Some(_), None) => std::cmp::Ordering::Less,
+ (None, Some(_)) => std::cmp::Ordering::Greater,
+ (None, None) => {
+ let a_time = a.session_info.created_at.or(a.session_info.updated_at);
+ let b_time = b.session_info.created_at.or(b.session_info.updated_at);
+ b_time.cmp(&a_time)
+ }
+ }
+ }
+ }
+ });
+
+ entries
+ }
+
+ fn dismiss_thread_switcher(&mut self, cx: &mut Context<Self>) {
+ self.thread_switcher = None;
+ self._thread_switcher_subscriptions.clear();
+ if let Some(mw) = self.multi_workspace.upgrade() {
+ mw.update(cx, |mw, cx| {
+ mw.set_sidebar_overlay(None, cx);
+ });
+ }
+ }
+
+ fn on_toggle_thread_switcher(
+ &mut self,
+ action: &ToggleThreadSwitcher,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.toggle_thread_switcher_impl(action.select_last, window, cx);
+ }
+
+ fn toggle_thread_switcher_impl(
+ &mut self,
+ select_last: bool,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(thread_switcher) = &self.thread_switcher {
+ thread_switcher.update(cx, |switcher, cx| {
+ if select_last {
+ switcher.select_last(cx);
+ } else {
+ switcher.cycle_selection(cx);
+ }
+ });
+ return;
+ }
+
+ let entries = self.mru_threads_for_switcher(cx);
+ if entries.len() < 2 {
+ return;
+ }
+
+ let weak_multi_workspace = self.multi_workspace.clone();
+
+ let original_agent = self
+ .focused_thread
+ .as_ref()
+ .and_then(|focused_id| entries.iter().find(|e| &e.session_id == focused_id))
+ .map(|e| e.agent.clone());
+ let original_session_info = self
+ .focused_thread
+ .as_ref()
+ .and_then(|focused_id| entries.iter().find(|e| &e.session_id == focused_id))
+ .map(|e| e.session_info.clone());
+ let original_workspace = self
+ .multi_workspace
+ .upgrade()
+ .map(|mw| mw.read(cx).workspace().clone());
+
+ let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx));
+
+ let mut subscriptions = Vec::new();
+
+ 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 {
+ agent,
+ session_info,
+ workspace,
+ } => {
+ if let Some(mw) = weak_multi_workspace.upgrade() {
+ mw.update(cx, |mw, cx| {
+ mw.activate(workspace.clone(), cx);
+ });
+ }
+ this.focused_thread = Some(session_info.session_id.clone());
+ this.update_entries(cx);
+ Self::load_agent_thread_in_workspace(
+ workspace,
+ agent.clone(),
+ session_info.clone(),
+ false,
+ window,
+ cx,
+ );
+ let focus = thread_switcher.focus_handle(cx);
+ window.focus(&focus, cx);
+ }
+ ThreadSwitcherEvent::Confirmed {
+ agent,
+ session_info,
+ workspace,
+ } => {
+ if let Some(mw) = weak_multi_workspace.upgrade() {
+ mw.update(cx, |mw, cx| {
+ mw.activate(workspace.clone(), cx);
+ });
+ }
+ this.record_thread_access(&session_info.session_id);
+ this.focused_thread = Some(session_info.session_id.clone());
+ this.update_entries(cx);
+ Self::load_agent_thread_in_workspace(
+ workspace,
+ agent.clone(),
+ session_info.clone(),
+ false,
+ window,
+ cx,
+ );
+ this.dismiss_thread_switcher(cx);
+ workspace.update(cx, |workspace, cx| {
+ workspace.focus_panel::<AgentPanel>(window, cx);
+ });
+ }
+ ThreadSwitcherEvent::Dismissed => {
+ if let Some(mw) = weak_multi_workspace.upgrade() {
+ if let Some(original_ws) = &original_workspace {
+ mw.update(cx, |mw, cx| {
+ mw.activate(original_ws.clone(), cx);
+ });
+ }
+ }
+ if let Some(session_info) = &original_session_info {
+ this.focused_thread = Some(session_info.session_id.clone());
+ this.update_entries(cx);
+ let agent = original_agent.clone().unwrap_or(Agent::NativeAgent);
+ if let Some(original_ws) = &original_workspace {
+ Self::load_agent_thread_in_workspace(
+ original_ws,
+ agent,
+ session_info.clone(),
+ false,
+ window,
+ cx,
+ );
+ }
+ }
+ this.dismiss_thread_switcher(cx);
+ }
+ }
+ }));
+
+ subscriptions.push(cx.subscribe_in(
+ &thread_switcher,
+ window,
+ |this, _emitter, _event: &gpui::DismissEvent, _window, cx| {
+ this.dismiss_thread_switcher(cx);
+ },
+ ));
+
+ let focus = thread_switcher.focus_handle(cx);
+ let overlay_view = gpui::AnyView::from(thread_switcher.clone());
+
+ // Replay the initial preview that was emitted during construction
+ // before subscriptions were wired up.
+ let initial_preview = thread_switcher.read(cx).selected_entry().map(|entry| {
+ (
+ entry.agent.clone(),
+ entry.session_info.clone(),
+ entry.workspace.clone(),
+ )
+ });
+
+ self.thread_switcher = Some(thread_switcher);
+ self._thread_switcher_subscriptions = subscriptions;
+ if let Some(mw) = self.multi_workspace.upgrade() {
+ mw.update(cx, |mw, cx| {
+ mw.set_sidebar_overlay(Some(overlay_view), cx);
+ });
+ }
+
+ if let Some((agent, session_info, workspace)) = initial_preview {
+ if let Some(mw) = self.multi_workspace.upgrade() {
+ mw.update(cx, |mw, cx| {
+ mw.activate(workspace.clone(), cx);
+ });
+ }
+ self.focused_thread = Some(session_info.session_id.clone());
+ self.update_entries(cx);
+ Self::load_agent_thread_in_workspace(
+ &workspace,
+ agent,
+ session_info,
+ false,
+ window,
+ cx,
+ );
+ }
+
+ window.focus(&focus, cx);
+ }
+
fn render_thread(
&self,
ix: usize,
@@ -2381,9 +2718,11 @@ impl Sidebar {
let id = SharedString::from(format!("thread-entry-{}", ix));
- let timestamp = thread
- .session_info
- .created_at
+ let timestamp = self
+ .thread_last_message_sent_or_queued
+ .get(&thread.session_info.session_id)
+ .copied()
+ .or(thread.session_info.created_at)
.or(thread.session_info.updated_at)
.map(format_history_entry_timestamp);
@@ -3017,6 +3356,15 @@ impl WorkspaceSidebar for Sidebar {
self.selection = None;
cx.notify();
}
+
+ fn toggle_thread_switcher(
+ &mut self,
+ select_last: bool,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.toggle_thread_switcher_impl(select_last, window, cx);
+ }
}
impl Focusable for Sidebar {
@@ -3060,6 +3408,7 @@ impl Render for Sidebar {
.on_action(cx.listener(Self::new_thread_in_group))
.on_action(cx.listener(Self::toggle_archive))
.on_action(cx.listener(Self::focus_sidebar_filter))
+ .on_action(cx.listener(Self::on_toggle_thread_switcher))
.on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
this.recent_projects_popover_handle.toggle(window, cx);
}))
@@ -4127,6 +4127,318 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test
);
}
+#[gpui::test]
+async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
+ let project = init_test_project_with_agent_panel("/my-project", cx).await;
+ 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, &project, cx);
+
+ let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+
+ let switcher_ids =
+ |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
+ sidebar.read_with(cx, |sidebar, cx| {
+ let switcher = sidebar
+ .thread_switcher
+ .as_ref()
+ .expect("switcher should be open");
+ switcher
+ .read(cx)
+ .entries()
+ .iter()
+ .map(|e| e.session_id.clone())
+ .collect()
+ })
+ };
+
+ let switcher_selected_id =
+ |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
+ sidebar.read_with(cx, |sidebar, cx| {
+ let switcher = sidebar
+ .thread_switcher
+ .as_ref()
+ .expect("switcher should be open");
+ let s = switcher.read(cx);
+ s.selected_entry()
+ .expect("should have selection")
+ .session_id
+ .clone()
+ })
+ };
+
+ // ── Setup: create three threads with distinct created_at times ──────
+ // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
+ // We send messages in each so they also get last_message_sent_or_queued timestamps.
+ let connection_c = StubAgentConnection::new();
+ connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+ acp::ContentChunk::new("Done C".into()),
+ )]);
+ open_thread_with_connection(&panel, connection_c, cx);
+ send_message(&panel, cx);
+ let session_id_c = active_session_id(&panel, cx);
+ cx.update(|_, cx| {
+ SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.save(
+ ThreadMetadata {
+ session_id: session_id_c.clone(),
+ agent_id: None,
+ title: "Thread C".into(),
+ updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0)
+ .unwrap(),
+ created_at: Some(
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+ ),
+ folder_paths: path_list.clone(),
+ },
+ cx,
+ )
+ })
+ });
+ cx.run_until_parked();
+
+ let connection_b = StubAgentConnection::new();
+ connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+ acp::ContentChunk::new("Done B".into()),
+ )]);
+ open_thread_with_connection(&panel, connection_b, cx);
+ send_message(&panel, cx);
+ let session_id_b = active_session_id(&panel, cx);
+ cx.update(|_, cx| {
+ SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.save(
+ ThreadMetadata {
+ session_id: session_id_b.clone(),
+ agent_id: None,
+ title: "Thread B".into(),
+ updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0)
+ .unwrap(),
+ created_at: Some(
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
+ ),
+ folder_paths: path_list.clone(),
+ },
+ cx,
+ )
+ })
+ });
+ cx.run_until_parked();
+
+ let connection_a = StubAgentConnection::new();
+ connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+ acp::ContentChunk::new("Done A".into()),
+ )]);
+ open_thread_with_connection(&panel, connection_a, cx);
+ send_message(&panel, cx);
+ let session_id_a = active_session_id(&panel, cx);
+ cx.update(|_, cx| {
+ SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.save(
+ ThreadMetadata {
+ session_id: session_id_a.clone(),
+ agent_id: None,
+ title: "Thread A".into(),
+ updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0)
+ .unwrap(),
+ created_at: Some(
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
+ ),
+ folder_paths: path_list.clone(),
+ },
+ cx,
+ )
+ })
+ });
+ cx.run_until_parked();
+
+ // All three threads are now live. Thread A was opened last, so it's
+ // the one being viewed. Opening each thread called record_thread_access,
+ // so all three have last_accessed_at set.
+ // Access order is: A (most recent), B, C (oldest).
+
+ // ── 1. Open switcher: threads sorted by last_accessed_at ───────────
+ open_and_focus_sidebar(&sidebar, cx);
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
+ });
+ cx.run_until_parked();
+
+ // All three have last_accessed_at, so they sort by access time.
+ // A was accessed most recently (it's the currently viewed thread),
+ // then B, then C.
+ assert_eq!(
+ switcher_ids(&sidebar, cx),
+ vec![
+ session_id_a.clone(),
+ session_id_b.clone(),
+ session_id_c.clone()
+ ],
+ );
+ // First ctrl-tab selects the second entry (B).
+ assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
+
+ // Dismiss the switcher without confirming.
+ sidebar.update_in(cx, |sidebar, _window, cx| {
+ sidebar.dismiss_thread_switcher(cx);
+ });
+ cx.run_until_parked();
+
+ // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
+ });
+ cx.run_until_parked();
+
+ // Cycle twice to land on Thread C (index 2).
+ sidebar.read_with(cx, |sidebar, cx| {
+ let switcher = sidebar.thread_switcher.as_ref().unwrap();
+ assert_eq!(switcher.read(cx).selected_index(), 1);
+ });
+ sidebar.update_in(cx, |sidebar, _window, cx| {
+ sidebar
+ .thread_switcher
+ .as_ref()
+ .unwrap()
+ .update(cx, |s, cx| s.cycle_selection(cx));
+ });
+ cx.run_until_parked();
+ assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
+
+ // Confirm on Thread C.
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ let switcher = sidebar.thread_switcher.as_ref().unwrap();
+ let focus = switcher.focus_handle(cx);
+ focus.dispatch_action(&menu::Confirm, window, cx);
+ });
+ cx.run_until_parked();
+
+ // Switcher should be dismissed after confirm.
+ sidebar.read_with(cx, |sidebar, _cx| {
+ assert!(
+ sidebar.thread_switcher.is_none(),
+ "switcher should be dismissed"
+ );
+ });
+
+ // Re-open switcher: Thread C is now most-recently-accessed.
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
+ });
+ cx.run_until_parked();
+
+ assert_eq!(
+ switcher_ids(&sidebar, cx),
+ vec![
+ session_id_c.clone(),
+ session_id_a.clone(),
+ session_id_b.clone()
+ ],
+ );
+
+ sidebar.update_in(cx, |sidebar, _window, cx| {
+ sidebar.dismiss_thread_switcher(cx);
+ });
+ cx.run_until_parked();
+
+ // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
+ // This thread was never opened in a panel — it only exists in metadata.
+ cx.update(|_, cx| {
+ SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.save(
+ ThreadMetadata {
+ session_id: acp::SessionId::new(Arc::from("thread-historical")),
+ agent_id: None,
+ title: "Historical Thread".into(),
+ updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0)
+ .unwrap(),
+ created_at: Some(
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
+ ),
+ folder_paths: path_list.clone(),
+ },
+ cx,
+ )
+ })
+ });
+ cx.run_until_parked();
+
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
+ });
+ cx.run_until_parked();
+
+ // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
+ // so it falls to tier 3 (sorted by created_at). It should appear after all
+ // accessed threads, even though its created_at (June 2024) is much later
+ // than the others.
+ //
+ // But the live threads (A, B, C) each had send_message called which sets
+ // last_message_sent_or_queued. So for the accessed threads (tier 1) the
+ // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
+ let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
+ let ids = switcher_ids(&sidebar, cx);
+ assert_eq!(
+ ids,
+ vec![
+ session_id_c.clone(),
+ session_id_a.clone(),
+ session_id_b.clone(),
+ session_id_hist.clone()
+ ],
+ );
+
+ sidebar.update_in(cx, |sidebar, _window, cx| {
+ sidebar.dismiss_thread_switcher(cx);
+ });
+ cx.run_until_parked();
+
+ // ── 4. Add another historical thread with older created_at ─────────
+ cx.update(|_, cx| {
+ SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.save(
+ ThreadMetadata {
+ session_id: acp::SessionId::new(Arc::from("thread-old-historical")),
+ agent_id: None,
+ title: "Old Historical Thread".into(),
+ updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0)
+ .unwrap(),
+ created_at: Some(
+ chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
+ ),
+ folder_paths: path_list.clone(),
+ },
+ cx,
+ )
+ })
+ });
+ cx.run_until_parked();
+
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
+ });
+ cx.run_until_parked();
+
+ // Both historical threads have no access or message times. They should
+ // appear after accessed threads, sorted by created_at (newest first).
+ let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
+ let ids = switcher_ids(&sidebar, cx);
+ assert_eq!(
+ ids,
+ vec![
+ session_id_c.clone(),
+ session_id_a.clone(),
+ session_id_b.clone(),
+ session_id_hist,
+ session_id_old_hist,
+ ],
+ );
+
+ sidebar.update_in(cx, |sidebar, _window, cx| {
+ sidebar.dismiss_thread_switcher(cx);
+ });
+ cx.run_until_parked();
+}
+
mod property_test {
use super::*;
use gpui::EntityId;
@@ -0,0 +1,378 @@
+use acp_thread;
+use action_log::DiffStats;
+use agent_client_protocol as acp;
+use agent_ui::Agent;
+use gpui::{
+ Action as _, Animation, AnimationExt, AnyElement, DismissEvent, Entity, EventEmitter,
+ FocusHandle, Focusable, Hsla, Modifiers, ModifiersChangedEvent, Render, SharedString,
+ prelude::*, pulsating_between,
+};
+use std::time::Duration;
+use ui::{
+ AgentThreadStatus, Color, CommonAnimationExt, DecoratedIcon, DiffStat, Icon, IconDecoration,
+ IconDecorationKind, IconName, IconSize, Label, LabelSize, prelude::*,
+};
+use workspace::{ModalView, Workspace};
+use zed_actions::agents_sidebar::ToggleThreadSwitcher;
+
+const PANEL_WIDTH_REMS: f32 = 28.;
+
+pub(crate) struct ThreadSwitcherEntry {
+ pub session_id: acp::SessionId,
+ pub title: SharedString,
+ pub icon: IconName,
+ pub icon_from_external_svg: Option<SharedString>,
+ pub status: AgentThreadStatus,
+ pub agent: Agent,
+ pub session_info: acp_thread::AgentSessionInfo,
+ pub workspace: Entity<Workspace>,
+ pub worktree_name: Option<SharedString>,
+ pub diff_stats: DiffStats,
+ pub is_title_generating: bool,
+ pub notified: bool,
+ pub timestamp: SharedString,
+}
+
+pub(crate) enum ThreadSwitcherEvent {
+ Preview {
+ agent: Agent,
+ session_info: acp_thread::AgentSessionInfo,
+ workspace: Entity<Workspace>,
+ },
+ Confirmed {
+ agent: Agent,
+ session_info: acp_thread::AgentSessionInfo,
+ workspace: Entity<Workspace>,
+ },
+ Dismissed,
+}
+
+pub(crate) struct ThreadSwitcher {
+ focus_handle: FocusHandle,
+ entries: Vec<ThreadSwitcherEntry>,
+ selected_index: usize,
+ init_modifiers: Option<Modifiers>,
+}
+
+impl ThreadSwitcher {
+ pub fn new(
+ entries: Vec<ThreadSwitcherEntry>,
+ select_last: bool,
+ window: &mut gpui::Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let init_modifiers = window.modifiers().modified().then_some(window.modifiers());
+ let selected_index = if entries.is_empty() {
+ 0
+ } else if select_last {
+ entries.len() - 1
+ } else {
+ 1.min(entries.len().saturating_sub(1))
+ };
+
+ if let Some(entry) = entries.get(selected_index) {
+ cx.emit(ThreadSwitcherEvent::Preview {
+ agent: entry.agent.clone(),
+ session_info: entry.session_info.clone(),
+ workspace: entry.workspace.clone(),
+ });
+ }
+
+ let focus_handle = cx.focus_handle();
+ cx.on_focus_out(&focus_handle, window, |_this, _event, _window, cx| {
+ cx.emit(ThreadSwitcherEvent::Dismissed);
+ cx.emit(DismissEvent);
+ })
+ .detach();
+
+ Self {
+ focus_handle,
+ entries,
+ selected_index,
+ init_modifiers,
+ }
+ }
+
+ pub fn selected_entry(&self) -> Option<&ThreadSwitcherEntry> {
+ self.entries.get(self.selected_index)
+ }
+
+ #[cfg(test)]
+ pub fn entries(&self) -> &[ThreadSwitcherEntry] {
+ &self.entries
+ }
+
+ #[cfg(test)]
+ pub fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ pub fn cycle_selection(&mut self, cx: &mut Context<Self>) {
+ if self.entries.is_empty() {
+ return;
+ }
+ self.selected_index = (self.selected_index + 1) % self.entries.len();
+ self.emit_preview(cx);
+ }
+
+ pub fn select_last(&mut self, cx: &mut Context<Self>) {
+ if self.entries.is_empty() {
+ return;
+ }
+ if self.selected_index == 0 {
+ self.selected_index = self.entries.len() - 1;
+ } else {
+ self.selected_index -= 1;
+ }
+ self.emit_preview(cx);
+ }
+
+ fn emit_preview(&mut self, cx: &mut Context<Self>) {
+ if let Some(entry) = self.entries.get(self.selected_index) {
+ cx.emit(ThreadSwitcherEvent::Preview {
+ agent: entry.agent.clone(),
+ session_info: entry.session_info.clone(),
+ workspace: entry.workspace.clone(),
+ });
+ }
+ }
+
+ fn confirm(&mut self, _: &menu::Confirm, _window: &mut gpui::Window, cx: &mut Context<Self>) {
+ if let Some(entry) = self.entries.get(self.selected_index) {
+ cx.emit(ThreadSwitcherEvent::Confirmed {
+ agent: entry.agent.clone(),
+ session_info: entry.session_info.clone(),
+ workspace: entry.workspace.clone(),
+ });
+ }
+ cx.emit(DismissEvent);
+ }
+
+ fn cancel(&mut self, _: &menu::Cancel, _window: &mut gpui::Window, cx: &mut Context<Self>) {
+ cx.emit(ThreadSwitcherEvent::Dismissed);
+ cx.emit(DismissEvent);
+ }
+
+ fn toggle(
+ &mut self,
+ action: &ToggleThreadSwitcher,
+ _window: &mut gpui::Window,
+ cx: &mut Context<Self>,
+ ) {
+ if action.select_last {
+ self.select_last(cx);
+ } else {
+ self.cycle_selection(cx);
+ }
+ }
+
+ fn handle_modifiers_changed(
+ &mut self,
+ event: &ModifiersChangedEvent,
+ window: &mut gpui::Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(init_modifiers) = self.init_modifiers else {
+ return;
+ };
+ if !event.modified() || !init_modifiers.is_subset_of(event) {
+ self.init_modifiers = None;
+ if self.entries.is_empty() {
+ cx.emit(DismissEvent);
+ } else {
+ window.dispatch_action(menu::Confirm.boxed_clone(), cx);
+ }
+ }
+ }
+}
+
+impl ModalView for ThreadSwitcher {}
+
+impl EventEmitter<DismissEvent> for ThreadSwitcher {}
+impl EventEmitter<ThreadSwitcherEvent> for ThreadSwitcher {}
+
+impl Focusable for ThreadSwitcher {
+ fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Render for ThreadSwitcher {
+ fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let selected_index = self.selected_index;
+ let color = cx.theme().colors();
+ let panel_bg = color
+ .title_bar_background
+ .blend(color.panel_background.opacity(0.2));
+
+ v_flex()
+ .key_context("ThreadSwitcher")
+ .track_focus(&self.focus_handle)
+ .w(gpui::rems(PANEL_WIDTH_REMS))
+ .elevation_3(cx)
+ .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
+ .on_action(cx.listener(Self::confirm))
+ .on_action(cx.listener(Self::cancel))
+ .on_action(cx.listener(Self::toggle))
+ .children(self.entries.iter().enumerate().map(|(ix, entry)| {
+ let is_first = ix == 0;
+ let is_last = ix == self.entries.len() - 1;
+ let selected = ix == selected_index;
+ let base_bg = if selected {
+ color.element_active
+ } else {
+ panel_bg
+ };
+
+ let dot_separator = || {
+ Label::new("\u{2022}")
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .alpha(0.5)
+ };
+
+ let icon_container = || h_flex().size_4().flex_none().justify_center();
+
+ let agent_icon = || {
+ if let Some(ref svg) = entry.icon_from_external_svg {
+ Icon::from_external_svg(svg.clone())
+ .color(Color::Muted)
+ .size(IconSize::Small)
+ } else {
+ Icon::new(entry.icon)
+ .color(Color::Muted)
+ .size(IconSize::Small)
+ }
+ };
+
+ let decoration = |kind: IconDecorationKind, deco_color: Hsla| {
+ IconDecoration::new(kind, base_bg, cx)
+ .color(deco_color)
+ .position(gpui::Point {
+ x: px(-2.),
+ y: px(-2.),
+ })
+ };
+
+ let icon_element: AnyElement = if entry.status == AgentThreadStatus::Running {
+ icon_container()
+ .child(
+ Icon::new(IconName::LoadCircle)
+ .size(IconSize::Small)
+ .color(Color::Muted)
+ .with_rotate_animation(2),
+ )
+ .into_any_element()
+ } else if entry.status == AgentThreadStatus::Error {
+ icon_container()
+ .child(DecoratedIcon::new(
+ agent_icon(),
+ Some(decoration(IconDecorationKind::X, cx.theme().status().error)),
+ ))
+ .into_any_element()
+ } else if entry.status == AgentThreadStatus::WaitingForConfirmation {
+ icon_container()
+ .child(DecoratedIcon::new(
+ agent_icon(),
+ Some(decoration(
+ IconDecorationKind::Triangle,
+ cx.theme().status().warning,
+ )),
+ ))
+ .into_any_element()
+ } else if entry.notified {
+ icon_container()
+ .child(DecoratedIcon::new(
+ agent_icon(),
+ Some(decoration(IconDecorationKind::Dot, color.text_accent)),
+ ))
+ .into_any_element()
+ } else {
+ icon_container().child(agent_icon()).into_any_element()
+ };
+
+ let title_label: AnyElement = if entry.is_title_generating {
+ Label::new(entry.title.clone())
+ .color(Color::Muted)
+ .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 {
+ Label::new(entry.title.clone()).into_any_element()
+ };
+
+ let has_diff_stats =
+ entry.diff_stats.lines_added > 0 || entry.diff_stats.lines_removed > 0;
+ let has_worktree = entry.worktree_name.is_some();
+ let has_timestamp = !entry.timestamp.is_empty();
+
+ v_flex()
+ .id(ix)
+ .w_full()
+ .py_1()
+ .px_1p5()
+ .border_1()
+ .border_color(gpui::transparent_black())
+ .when(selected, |s| s.bg(color.element_active))
+ .when(is_first, |s| s.rounded_t_lg())
+ .when(is_last, |s| s.rounded_b_lg())
+ .child(
+ h_flex()
+ .min_w_0()
+ .w_full()
+ .gap_1p5()
+ .child(icon_element)
+ .child(title_label),
+ )
+ .when(has_worktree || has_diff_stats || has_timestamp, |this| {
+ this.child(
+ h_flex()
+ .min_w_0()
+ .gap_1p5()
+ .child(icon_container())
+ .when_some(entry.worktree_name.clone(), |this, worktree| {
+ this.child(
+ h_flex()
+ .gap_1()
+ .child(
+ Icon::new(IconName::GitWorktree)
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new(worktree)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ })
+ .when(has_worktree && (has_diff_stats || has_timestamp), |this| {
+ this.child(dot_separator())
+ })
+ .when(has_diff_stats, |this| {
+ this.child(DiffStat::new(
+ ix,
+ entry.diff_stats.lines_added as usize,
+ entry.diff_stats.lines_removed as usize,
+ ))
+ })
+ .when(has_diff_stats && has_timestamp, |this| {
+ this.child(dot_separator())
+ })
+ .when(has_timestamp, |this| {
+ this.child(
+ Label::new(entry.timestamp.clone())
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ }),
+ )
+ })
+ }))
+ }
+}
@@ -49,6 +49,7 @@ use util::ResultExt;
use workspace::{
MultiWorkspace, ToggleWorktreeSecurity, Workspace, WorkspaceId, notifications::NotifyResultExt,
};
+
use zed_actions::OpenRemote;
pub use onboarding_banner::restore_banner;
@@ -15,7 +15,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use ui::prelude::*;
use util::ResultExt;
-use zed_actions::agents_sidebar::MoveWorkspaceToNewWindow;
+use zed_actions::agents_sidebar::{MoveWorkspaceToNewWindow, ToggleThreadSwitcher};
use agent_settings::AgentSettings;
use settings::SidebarDockPosition;
@@ -100,8 +100,16 @@ pub trait Sidebar: Focusable + Render + Sized {
fn is_threads_list_view_active(&self) -> bool {
true
}
- /// Makes focus reset bac to the search editor upon toggling the sidebar from outside
+ /// Makes focus reset back to the search editor upon toggling the sidebar from outside
fn prepare_for_focus(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
+ /// Opens or cycles the thread switcher popup.
+ fn toggle_thread_switcher(
+ &mut self,
+ _select_last: bool,
+ _window: &mut Window,
+ _cx: &mut Context<Self>,
+ ) {
+ }
}
pub trait SidebarHandle: 'static + Send + Sync {
@@ -113,6 +121,7 @@ pub trait SidebarHandle: 'static + Send + Sync {
fn has_notifications(&self, cx: &App) -> bool;
fn to_any(&self) -> AnyView;
fn entity_id(&self) -> EntityId;
+ fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App);
fn is_threads_list_view_active(&self, cx: &App) -> bool;
@@ -162,6 +171,15 @@ impl<T: Sidebar> SidebarHandle for Entity<T> {
Entity::entity_id(self)
}
+ fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App) {
+ let entity = self.clone();
+ window.defer(cx, move |window, cx| {
+ entity.update(cx, |this, cx| {
+ this.toggle_thread_switcher(select_last, window, cx);
+ });
+ });
+ }
+
fn is_threads_list_view_active(&self, cx: &App) -> bool {
self.read(cx).is_threads_list_view_active()
}
@@ -177,6 +195,7 @@ pub struct MultiWorkspace {
active_workspace_index: usize,
sidebar: Option<Box<dyn SidebarHandle>>,
sidebar_open: bool,
+ sidebar_overlay: Option<AnyView>,
pending_removal_tasks: Vec<Task<()>>,
_serialize_task: Option<Task<()>>,
_subscriptions: Vec<Subscription>,
@@ -225,6 +244,7 @@ impl MultiWorkspace {
active_workspace_index: 0,
sidebar: None,
sidebar_open: false,
+ sidebar_overlay: None,
pending_removal_tasks: Vec::new(),
_serialize_task: None,
_subscriptions: vec![
@@ -247,6 +267,11 @@ impl MultiWorkspace {
self.sidebar.as_deref()
}
+ pub fn set_sidebar_overlay(&mut self, overlay: Option<AnyView>, cx: &mut Context<Self>) {
+ self.sidebar_overlay = overlay;
+ cx.notify();
+ }
+
pub fn sidebar_open(&self) -> bool {
self.sidebar_open
}
@@ -916,6 +941,13 @@ impl Render for MultiWorkspace {
.on_action(cx.listener(Self::next_workspace))
.on_action(cx.listener(Self::previous_workspace))
.on_action(cx.listener(Self::move_active_workspace_to_new_window))
+ .on_action(cx.listener(
+ |this: &mut Self, action: &ToggleThreadSwitcher, window, cx| {
+ if let Some(sidebar) = &this.sidebar {
+ sidebar.toggle_thread_switcher(action.select_last, window, cx);
+ }
+ },
+ ))
})
.when(
self.sidebar_open() && self.multi_workspace_enabled(cx),
@@ -947,7 +979,20 @@ impl Render for MultiWorkspace {
.child(self.workspace().clone()),
)
.children(right_sidebar)
- .child(self.workspace().read(cx).modal_layer.clone()),
+ .child(self.workspace().read(cx).modal_layer.clone())
+ .children(self.sidebar_overlay.as_ref().map(|view| {
+ deferred(div().absolute().size_full().inset_0().occlude().child(
+ v_flex().h(px(0.0)).top_20().items_center().child(
+ h_flex().occlude().child(view.clone()).on_mouse_down(
+ MouseButton::Left,
+ |_, _, cx| {
+ cx.stop_propagation();
+ },
+ ),
+ ),
+ ))
+ .with_priority(2)
+ })),
window,
cx,
Tiling {
@@ -779,7 +779,18 @@ pub mod preview {
}
pub mod agents_sidebar {
- use gpui::actions;
+ use gpui::{Action, actions};
+ use schemars::JsonSchema;
+ use serde::Deserialize;
+
+ /// Toggles the thread switcher popup when the sidebar is focused.
+ #[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)]
+ #[action(namespace = agents_sidebar)]
+ #[serde(deny_unknown_fields)]
+ pub struct ToggleThreadSwitcher {
+ #[serde(default)]
+ pub select_last: bool,
+ }
actions!(
agents_sidebar,