diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 617d7a6d0662264858ac3066d40481135dab9ae6..1bc3b666beaaac78740c335bbcac9603d6a4d07f 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -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": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index d3dda49c9a52a8c9b52dfddc04ae573f2fa4cf28..e2073c170b375baea22f5018c2c7dba632dd9b05 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index e665d26aaf0c90d6c2fa4ee66284687c843fcd62..aa31629bcb1739288dcdaef16ad1af5b116d68f1 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -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, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 67ddd5a09c14c36f06f2dd19abd486d7c346892e..8ab640a55ef72ed29efbac21ee3b69cfc84c15dc 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -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 for AgentPanel {} diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 7caeb687bebb32083c2647a2bc0d359b36e03b58..a295cb562f0d46b8d979412ced39abf5f94d1ad9 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -166,6 +166,7 @@ impl ThreadFeedbackState { pub enum AcpThreadViewEvent { FirstSendRequested { content: Vec }, + MessageSentOrQueued, } impl EventEmitter 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) } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 420942da64acec08f9d7acf2d618764e36066aa0..345a8f4ac8c6f518073879b8e72b067fd4cc5fb0 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -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, collapsed_groups: HashSet, expanded_groups: HashMap, + /// 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>, + /// 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>, + thread_switcher: Option>, + _thread_switcher_subscriptions: Vec, view: SidebarView, recent_projects_popover_handle: PopoverMenuHandle, project_header_menu_ix: Option, @@ -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, 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::().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::(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 { + let mut current_header_workspace: Option> = None; + let mut entries: Vec = 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.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.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, + ) { + 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::(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.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); })) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 8170a2956886f1bc0b90acd2f83b5a9ccd2c979b..57edbd4212acf7a40e7c29f02403e9cb2b00e11d 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -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, cx: &mut gpui::VisualTestContext| -> Vec { + 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, 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; diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs new file mode 100644 index 0000000000000000000000000000000000000000..56767a5adfc6c73ac21b8968b7ed35587faa36f4 --- /dev/null +++ b/crates/sidebar/src/thread_switcher.rs @@ -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, + pub status: AgentThreadStatus, + pub agent: Agent, + pub session_info: acp_thread::AgentSessionInfo, + pub workspace: Entity, + pub worktree_name: Option, + 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, + }, + Confirmed { + agent: Agent, + session_info: acp_thread::AgentSessionInfo, + workspace: Entity, + }, + Dismissed, +} + +pub(crate) struct ThreadSwitcher { + focus_handle: FocusHandle, + entries: Vec, + selected_index: usize, + init_modifiers: Option, +} + +impl ThreadSwitcher { + pub fn new( + entries: Vec, + select_last: bool, + window: &mut gpui::Window, + cx: &mut Context, + ) -> 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) { + 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) { + 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) { + 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) { + 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) { + cx.emit(ThreadSwitcherEvent::Dismissed); + cx.emit(DismissEvent); + } + + fn toggle( + &mut self, + action: &ToggleThreadSwitcher, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + 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, + ) { + 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 for ThreadSwitcher {} +impl EventEmitter 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) -> 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), + ) + }), + ) + }) + })) + } +} diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 86e15dd7284881961cbc2c43f17e603ea3d39bc3..42c348bacb680e2a09586d0dc0279fc8c95d1604 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -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; diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 924471d4dd08fa14f08723ffb990e9d8f555c048..adfbd99866544fe1045d2edebd271111928cf6f4 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -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) {} + /// Opens or cycles the thread switcher popup. + fn toggle_thread_switcher( + &mut self, + _select_last: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } } 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 SidebarHandle for Entity { 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>, sidebar_open: bool, + sidebar_overlay: Option, pending_removal_tasks: Vec>, _serialize_task: Option>, _subscriptions: Vec, @@ -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, cx: &mut Context) { + 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 { diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index f64df42e050023f9f8615dc468e7c3459a4af063..7d8811de705713df7ac8e3a161f14c9f9138ebfc 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -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,