Merge remote-tracking branch 'origin/main' into sidebar-better-vim

cameron created

Change summary

Cargo.toml                                           |   2 
assets/keymaps/default-linux.json                    |  11 
assets/keymaps/default-macos.json                    |  11 
assets/keymaps/default-windows.json                  |  11 
crates/agent_ui/src/agent_panel.rs                   |   5 
crates/agent_ui/src/conversation_view/thread_view.rs |   5 
crates/gpui/src/elements/div.rs                      |  26 
crates/gpui/src/interactive.rs                       |  11 
crates/gpui/src/window.rs                            |   1 
crates/gpui_linux/src/linux/x11/client.rs            |  67 ++
crates/gpui_linux/src/linux/x11/window.rs            |   8 
crates/gpui_windows/src/direct_manipulation.rs       | 359 +++++++++++++
crates/gpui_windows/src/events.rs                    |  22 
crates/gpui_windows/src/gpui_windows.rs              |   1 
crates/gpui_windows/src/window.rs                    |   6 
crates/image_viewer/src/image_viewer.rs              |  26 
crates/sidebar/src/sidebar.rs                        | 385 +++++++++++++
crates/sidebar/src/sidebar_tests.rs                  | 312 +++++++++++
crates/sidebar/src/thread_switcher.rs                | 378 +++++++++++++
crates/title_bar/src/title_bar.rs                    |   1 
crates/workspace/src/multi_workspace.rs              |  51 +
crates/zed_actions/src/lib.rs                        |  13 
22 files changed, 1,624 insertions(+), 88 deletions(-)

Detailed changes

Cargo.toml 🔗

@@ -812,6 +812,7 @@ features = [
     "Win32_Graphics_Direct3D_Fxc",
     "Win32_Graphics_DirectComposition",
     "Win32_Graphics_DirectWrite",
+    "Win32_Graphics_DirectManipulation",
     "Win32_Graphics_Dwm",
     "Win32_Graphics_Dxgi",
     "Win32_Graphics_Dxgi_Common",
@@ -843,6 +844,7 @@ features = [
     "Win32_UI_HiDpi",
     "Win32_UI_Input_Ime",
     "Win32_UI_Input_KeyboardAndMouse",
+    "Win32_UI_Input_Pointer",
     "Win32_UI_Shell",
     "Win32_UI_Shell_Common",
     "Win32_UI_Shell_PropertiesSystem",

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": {

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,

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,

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<PanelEvent> for AgentPanel {}

crates/agent_ui/src/conversation_view/thread_view.rs 🔗

@@ -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)
     }
 

crates/gpui/src/elements/div.rs 🔗

@@ -15,7 +15,6 @@
 //! and Tailwind-like styling that you can use to build your own custom elements. Div is
 //! constructed by combining these two systems into an all-in-one element.
 
-#[cfg(any(target_os = "linux", target_os = "macos"))]
 use crate::PinchEvent;
 use crate::{
     AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent,
@@ -357,11 +356,7 @@ impl Interactivity {
 
     /// Bind the given callback to pinch gesture events during the bubble phase.
     ///
-    /// Note: This event is only available on macOS and Wayland (Linux).
-    /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
-    ///
     /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
-    #[cfg(any(target_os = "linux", target_os = "macos"))]
     pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) {
         self.pinch_listeners
             .push(Box::new(move |event, phase, hitbox, window, cx| {
@@ -373,11 +368,7 @@ impl Interactivity {
 
     /// Bind the given callback to pinch gesture events during the capture phase.
     ///
-    /// Note: This event is only available on macOS and Wayland (Linux).
-    /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
-    ///
     /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
-    #[cfg(any(target_os = "linux", target_os = "macos"))]
     pub fn capture_pinch(
         &mut self,
         listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static,
@@ -675,15 +666,9 @@ impl Interactivity {
         self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll;
     }
 
-    #[cfg(any(target_os = "linux", target_os = "macos"))]
     fn has_pinch_listeners(&self) -> bool {
         !self.pinch_listeners.is_empty()
     }
-
-    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
-    fn has_pinch_listeners(&self) -> bool {
-        false
-    }
 }
 
 /// A trait for elements that want to use the standard GPUI event handlers that don't
@@ -957,11 +942,7 @@ pub trait InteractiveElement: Sized {
     /// Bind the given callback to pinch gesture events during the bubble phase.
     /// The fluent API equivalent to [`Interactivity::on_pinch`].
     ///
-    /// Note: This event is only available on macOS and Wayland (Linux).
-    /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
-    ///
     /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
-    #[cfg(any(target_os = "linux", target_os = "macos"))]
     fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self {
         self.interactivity().on_pinch(listener);
         self
@@ -970,11 +951,7 @@ pub trait InteractiveElement: Sized {
     /// Bind the given callback to pinch gesture events during the capture phase.
     /// The fluent API equivalent to [`Interactivity::capture_pinch`].
     ///
-    /// Note: This event is only available on macOS and Wayland (Linux).
-    /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
-    ///
     /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
-    #[cfg(any(target_os = "linux", target_os = "macos"))]
     fn capture_pinch(
         mut self,
         listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static,
@@ -1367,7 +1344,6 @@ pub(crate) type MouseMoveListener =
 pub(crate) type ScrollWheelListener =
     Box<dyn Fn(&ScrollWheelEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
 
-#[cfg(any(target_os = "linux", target_os = "macos"))]
 pub(crate) type PinchListener =
     Box<dyn Fn(&PinchEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
 
@@ -1725,7 +1701,6 @@ pub struct Interactivity {
     pub(crate) mouse_pressure_listeners: Vec<MousePressureListener>,
     pub(crate) mouse_move_listeners: Vec<MouseMoveListener>,
     pub(crate) scroll_wheel_listeners: Vec<ScrollWheelListener>,
-    #[cfg(any(target_os = "linux", target_os = "macos"))]
     pub(crate) pinch_listeners: Vec<PinchListener>,
     pub(crate) key_down_listeners: Vec<KeyDownListener>,
     pub(crate) key_up_listeners: Vec<KeyUpListener>,
@@ -2297,7 +2272,6 @@ impl Interactivity {
             })
         }
 
-        #[cfg(any(target_os = "linux", target_os = "macos"))]
         for listener in self.pinch_listeners.drain(..) {
             let hitbox = hitbox.clone();
             window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| {

crates/gpui/src/interactive.rs 🔗

@@ -473,10 +473,7 @@ impl Default for ScrollDelta {
 /// A pinch gesture event from the platform, generated when the user performs
 /// a pinch-to-zoom gesture (typically on a trackpad).
 ///
-/// Note: This event is only available on macOS and Wayland (Linux).
-/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
 #[derive(Clone, Debug, Default)]
-#[cfg(any(target_os = "linux", target_os = "macos"))]
 pub struct PinchEvent {
     /// The position of the pinch center on the window.
     pub position: Point<Pixels>,
@@ -493,20 +490,15 @@ pub struct PinchEvent {
     pub phase: TouchPhase,
 }
 
-#[cfg(any(target_os = "linux", target_os = "macos"))]
 impl Sealed for PinchEvent {}
-#[cfg(any(target_os = "linux", target_os = "macos"))]
 impl InputEvent for PinchEvent {
     fn to_platform_input(self) -> PlatformInput {
         PlatformInput::Pinch(self)
     }
 }
-#[cfg(any(target_os = "linux", target_os = "macos"))]
 impl GestureEvent for PinchEvent {}
-#[cfg(any(target_os = "linux", target_os = "macos"))]
 impl MouseEvent for PinchEvent {}
 
-#[cfg(any(target_os = "linux", target_os = "macos"))]
 impl Deref for PinchEvent {
     type Target = Modifiers;
 
@@ -675,7 +667,6 @@ pub enum PlatformInput {
     /// The scroll wheel was used.
     ScrollWheel(ScrollWheelEvent),
     /// A pinch gesture was performed.
-    #[cfg(any(target_os = "linux", target_os = "macos"))]
     Pinch(PinchEvent),
     /// Files were dragged and dropped onto the window.
     FileDrop(FileDropEvent),
@@ -693,7 +684,6 @@ impl PlatformInput {
             PlatformInput::MousePressure(event) => Some(event),
             PlatformInput::MouseExited(event) => Some(event),
             PlatformInput::ScrollWheel(event) => Some(event),
-            #[cfg(any(target_os = "linux", target_os = "macos"))]
             PlatformInput::Pinch(event) => Some(event),
             PlatformInput::FileDrop(event) => Some(event),
         }
@@ -710,7 +700,6 @@ impl PlatformInput {
             PlatformInput::MousePressure(_) => None,
             PlatformInput::MouseExited(_) => None,
             PlatformInput::ScrollWheel(_) => None,
-            #[cfg(any(target_os = "linux", target_os = "macos"))]
             PlatformInput::Pinch(_) => None,
             PlatformInput::FileDrop(_) => None,
         }

crates/gpui/src/window.rs 🔗

@@ -4146,7 +4146,6 @@ impl Window {
                 self.modifiers = scroll_wheel.modifiers;
                 PlatformInput::ScrollWheel(scroll_wheel)
             }
-            #[cfg(any(target_os = "linux", target_os = "macos"))]
             PlatformInput::Pinch(pinch) => {
                 self.mouse_position = pinch.position;
                 self.modifiers = pinch.modifiers;

crates/gpui_linux/src/linux/x11/client.rs 🔗

@@ -176,6 +176,7 @@ pub struct X11ClientState {
     pub(crate) last_mouse_button: Option<MouseButton>,
     pub(crate) last_location: Point<Pixels>,
     pub(crate) current_count: usize,
+    pub(crate) pinch_scale: f32,
 
     pub(crate) gpu_context: GpuContext,
     pub(crate) compositor_gpu: Option<CompositorGpuHint>,
@@ -342,11 +343,12 @@ impl X11Client {
         xcb_connection.prefetch_extension_information(render::X11_EXTENSION_NAME)?;
         xcb_connection.prefetch_extension_information(xinput::X11_EXTENSION_NAME)?;
 
-        // Announce to X server that XInput up to 2.1 is supported. To increase this to 2.2 and
-        // beyond, support for touch events would need to be added.
+        // Announce to X server that XInput up to 2.4 is supported.
+        // Version 2.4 is needed for gesture events (GesturePinchBegin/Update/End).
+        // If the server only supports an older version, gesture events simply won't be delivered.
         let xinput_version = get_reply(
             || "XInput XiQueryVersion failed",
-            xcb_connection.xinput_xi_query_version(2, 1),
+            xcb_connection.xinput_xi_query_version(2, 4),
         )?;
         assert!(
             xinput_version.major_version >= 2,
@@ -502,6 +504,7 @@ impl X11Client {
             last_mouse_button: None,
             last_location: Point::new(px(0.0), px(0.0)),
             current_count: 0,
+            pinch_scale: 1.0,
             gpu_context: Rc::new(RefCell::new(None)),
             compositor_gpu,
             scale_factor,
@@ -1324,6 +1327,64 @@ impl X11Client {
                     reset_pointer_device_scroll_positions(pointer);
                 }
             }
+            Event::XinputGesturePinchBegin(event) => {
+                let window = self.get_window(event.event)?;
+                let mut state = self.0.borrow_mut();
+                state.pinch_scale = 1.0;
+                let modifiers = modifiers_from_xinput_info(event.mods);
+                state.modifiers = modifiers;
+                let position = point(
+                    px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor),
+                    px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor),
+                );
+                drop(state);
+                window.handle_input(PlatformInput::Pinch(gpui::PinchEvent {
+                    position,
+                    delta: 0.0,
+                    modifiers,
+                    phase: gpui::TouchPhase::Started,
+                }));
+            }
+            Event::XinputGesturePinchUpdate(event) => {
+                let window = self.get_window(event.event)?;
+                let mut state = self.0.borrow_mut();
+                let modifiers = modifiers_from_xinput_info(event.mods);
+                state.modifiers = modifiers;
+                let position = point(
+                    px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor),
+                    px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor),
+                );
+                // scale is in FP16.16 format: divide by 65536 to get the float value
+                let new_absolute_scale = event.scale as f32 / 65536.0;
+                let previous_scale = state.pinch_scale;
+                let zoom_delta = new_absolute_scale - previous_scale;
+                state.pinch_scale = new_absolute_scale;
+                drop(state);
+                window.handle_input(PlatformInput::Pinch(gpui::PinchEvent {
+                    position,
+                    delta: zoom_delta,
+                    modifiers,
+                    phase: gpui::TouchPhase::Moved,
+                }));
+            }
+            Event::XinputGesturePinchEnd(event) => {
+                let window = self.get_window(event.event)?;
+                let mut state = self.0.borrow_mut();
+                state.pinch_scale = 1.0;
+                let modifiers = modifiers_from_xinput_info(event.mods);
+                state.modifiers = modifiers;
+                let position = point(
+                    px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor),
+                    px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor),
+                );
+                drop(state);
+                window.handle_input(PlatformInput::Pinch(gpui::PinchEvent {
+                    position,
+                    delta: 0.0,
+                    modifiers,
+                    phase: gpui::TouchPhase::Ended,
+                }));
+            }
             _ => {}
         };
 

crates/gpui_linux/src/linux/x11/window.rs 🔗

@@ -671,7 +671,13 @@ impl X11WindowState {
                                 | xinput::XIEventMask::BUTTON_PRESS
                                 | xinput::XIEventMask::BUTTON_RELEASE
                                 | xinput::XIEventMask::ENTER
-                                | xinput::XIEventMask::LEAVE,
+                                | xinput::XIEventMask::LEAVE
+                                // x11rb 0.13 doesn't define XIEventMask constants for gesture
+                                // events, so we construct them from the event opcodes (each
+                                // XInput event type N maps to mask bit N).
+                                | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_BEGIN_EVENT)
+                                | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_UPDATE_EVENT)
+                                | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_END_EVENT),
                         ],
                     }],
                 ),

crates/gpui_windows/src/direct_manipulation.rs 🔗

@@ -0,0 +1,359 @@
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+
+use ::util::ResultExt;
+use anyhow::Result;
+use gpui::*;
+use windows::Win32::{
+    Foundation::*,
+    Graphics::{DirectManipulation::*, Gdi::*},
+    System::Com::*,
+    UI::{Input::Pointer::*, WindowsAndMessaging::*},
+};
+
+use crate::*;
+
+/// Default viewport size in pixels. The actual content size doesn't matter
+/// because we're using the viewport only for gesture recognition, not for
+/// visual output.
+const DEFAULT_VIEWPORT_SIZE: i32 = 1000;
+
+pub(crate) struct DirectManipulationHandler {
+    manager: IDirectManipulationManager,
+    update_manager: IDirectManipulationUpdateManager,
+    viewport: IDirectManipulationViewport,
+    _handler_cookie: u32,
+    window: HWND,
+    scale_factor: Rc<Cell<f32>>,
+    pending_events: Rc<RefCell<Vec<PlatformInput>>>,
+}
+
+impl DirectManipulationHandler {
+    pub fn new(window: HWND, scale_factor: f32) -> Result<Self> {
+        unsafe {
+            let manager: IDirectManipulationManager =
+                CoCreateInstance(&DirectManipulationManager, None, CLSCTX_INPROC_SERVER)?;
+
+            let update_manager: IDirectManipulationUpdateManager = manager.GetUpdateManager()?;
+
+            let viewport: IDirectManipulationViewport = manager.CreateViewport(None, window)?;
+
+            let configuration = DIRECTMANIPULATION_CONFIGURATION_INTERACTION
+                | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_X
+                | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_Y
+                | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_INERTIA
+                | DIRECTMANIPULATION_CONFIGURATION_RAILS_X
+                | DIRECTMANIPULATION_CONFIGURATION_RAILS_Y
+                | DIRECTMANIPULATION_CONFIGURATION_SCALING;
+            viewport.ActivateConfiguration(configuration)?;
+
+            viewport.SetViewportOptions(
+                DIRECTMANIPULATION_VIEWPORT_OPTIONS_MANUALUPDATE
+                    | DIRECTMANIPULATION_VIEWPORT_OPTIONS_DISABLEPIXELSNAPPING,
+            )?;
+
+            let mut rect = RECT {
+                left: 0,
+                top: 0,
+                right: DEFAULT_VIEWPORT_SIZE,
+                bottom: DEFAULT_VIEWPORT_SIZE,
+            };
+            viewport.SetViewportRect(&mut rect)?;
+
+            manager.Activate(window)?;
+            viewport.Enable()?;
+
+            let scale_factor = Rc::new(Cell::new(scale_factor));
+            let pending_events = Rc::new(RefCell::new(Vec::new()));
+
+            let event_handler: IDirectManipulationViewportEventHandler =
+                DirectManipulationEventHandler::new(
+                    window,
+                    Rc::clone(&scale_factor),
+                    Rc::clone(&pending_events),
+                )
+                .into();
+
+            let handler_cookie = viewport.AddEventHandler(Some(window), &event_handler)?;
+
+            update_manager.Update(None)?;
+
+            Ok(Self {
+                manager,
+                update_manager,
+                viewport,
+                _handler_cookie: handler_cookie,
+                window,
+                scale_factor,
+                pending_events,
+            })
+        }
+    }
+
+    pub fn set_scale_factor(&self, scale_factor: f32) {
+        self.scale_factor.set(scale_factor);
+    }
+
+    pub fn on_pointer_hit_test(&self, wparam: WPARAM) {
+        unsafe {
+            let pointer_id = wparam.loword() as u32;
+            let mut pointer_type = POINTER_INPUT_TYPE::default();
+            if GetPointerType(pointer_id, &mut pointer_type).is_ok() && pointer_type == PT_TOUCHPAD
+            {
+                self.viewport.SetContact(pointer_id).log_err();
+            }
+        }
+    }
+
+    pub fn update(&self) {
+        unsafe {
+            self.update_manager.Update(None).log_err();
+        }
+    }
+
+    pub fn drain_events(&self) -> Vec<PlatformInput> {
+        std::mem::take(&mut *self.pending_events.borrow_mut())
+    }
+}
+
+impl Drop for DirectManipulationHandler {
+    fn drop(&mut self) {
+        unsafe {
+            self.viewport.Stop().log_err();
+            self.viewport.Abandon().log_err();
+            self.manager.Deactivate(self.window).log_err();
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum GestureKind {
+    None,
+    Scroll,
+    Pinch,
+}
+
+#[windows_core::implement(IDirectManipulationViewportEventHandler)]
+struct DirectManipulationEventHandler {
+    window: HWND,
+    scale_factor: Rc<Cell<f32>>,
+    gesture_kind: Cell<GestureKind>,
+    last_scale: Cell<f32>,
+    last_x_offset: Cell<f32>,
+    last_y_offset: Cell<f32>,
+    scroll_phase: Cell<TouchPhase>,
+    pending_events: Rc<RefCell<Vec<PlatformInput>>>,
+}
+
+impl DirectManipulationEventHandler {
+    fn new(
+        window: HWND,
+        scale_factor: Rc<Cell<f32>>,
+        pending_events: Rc<RefCell<Vec<PlatformInput>>>,
+    ) -> Self {
+        Self {
+            window,
+            scale_factor,
+            gesture_kind: Cell::new(GestureKind::None),
+            last_scale: Cell::new(1.0),
+            last_x_offset: Cell::new(0.0),
+            last_y_offset: Cell::new(0.0),
+            scroll_phase: Cell::new(TouchPhase::Started),
+            pending_events,
+        }
+    }
+
+    fn end_gesture(&self) {
+        let position = self.mouse_position();
+        let modifiers = current_modifiers();
+        match self.gesture_kind.get() {
+            GestureKind::Scroll => {
+                self.pending_events
+                    .borrow_mut()
+                    .push(PlatformInput::ScrollWheel(ScrollWheelEvent {
+                        position,
+                        delta: ScrollDelta::Pixels(point(px(0.0), px(0.0))),
+                        modifiers,
+                        touch_phase: TouchPhase::Ended,
+                    }));
+            }
+            GestureKind::Pinch => {
+                self.pending_events
+                    .borrow_mut()
+                    .push(PlatformInput::Pinch(PinchEvent {
+                        position,
+                        delta: 0.0,
+                        modifiers,
+                        phase: TouchPhase::Ended,
+                    }));
+            }
+            GestureKind::None => {}
+        }
+        self.gesture_kind.set(GestureKind::None);
+    }
+
+    fn mouse_position(&self) -> Point<Pixels> {
+        let scale_factor = self.scale_factor.get();
+        unsafe {
+            let mut point: POINT = std::mem::zeroed();
+            let _ = GetCursorPos(&mut point);
+            let _ = ScreenToClient(self.window, &mut point);
+            logical_point(point.x as f32, point.y as f32, scale_factor)
+        }
+    }
+}
+
+impl IDirectManipulationViewportEventHandler_Impl for DirectManipulationEventHandler_Impl {
+    fn OnViewportStatusChanged(
+        &self,
+        viewport: windows_core::Ref<'_, IDirectManipulationViewport>,
+        current: DIRECTMANIPULATION_STATUS,
+        previous: DIRECTMANIPULATION_STATUS,
+    ) -> windows_core::Result<()> {
+        if current == previous {
+            return Ok(());
+        }
+
+        // A new gesture interrupted inertia, so end the old sequence.
+        if current == DIRECTMANIPULATION_RUNNING && previous == DIRECTMANIPULATION_INERTIA {
+            self.end_gesture();
+        }
+
+        if current == DIRECTMANIPULATION_READY {
+            self.end_gesture();
+
+            // Reset the content transform so the viewport is ready for the next gesture.
+            // ZoomToRect triggers a second RUNNING -> READY cycle, so prevent an infinite loop here.
+            if self.last_scale.get() != 1.0
+                || self.last_x_offset.get() != 0.0
+                || self.last_y_offset.get() != 0.0
+            {
+                if let Some(viewport) = viewport.as_ref() {
+                    unsafe {
+                        viewport
+                            .ZoomToRect(
+                                0.0,
+                                0.0,
+                                DEFAULT_VIEWPORT_SIZE as f32,
+                                DEFAULT_VIEWPORT_SIZE as f32,
+                                false,
+                            )
+                            .log_err();
+                    }
+                }
+            }
+
+            self.last_scale.set(1.0);
+            self.last_x_offset.set(0.0);
+            self.last_y_offset.set(0.0);
+        }
+
+        Ok(())
+    }
+
+    fn OnViewportUpdated(
+        &self,
+        _viewport: windows_core::Ref<'_, IDirectManipulationViewport>,
+    ) -> windows_core::Result<()> {
+        Ok(())
+    }
+
+    fn OnContentUpdated(
+        &self,
+        _viewport: windows_core::Ref<'_, IDirectManipulationViewport>,
+        content: windows_core::Ref<'_, IDirectManipulationContent>,
+    ) -> windows_core::Result<()> {
+        let content = content.as_ref().ok_or(E_POINTER)?;
+
+        // Get the 6-element content transform: [scale, 0, 0, scale, tx, ty]
+        let mut xform = [0.0f32; 6];
+        unsafe {
+            content.GetContentTransform(&mut xform)?;
+        }
+
+        let scale = xform[0];
+        let scale_factor = self.scale_factor.get();
+        let x_offset = xform[4] / scale_factor;
+        let y_offset = xform[5] / scale_factor;
+
+        if scale == 0.0 {
+            return Ok(());
+        }
+
+        let last_scale = self.last_scale.get();
+        let last_x = self.last_x_offset.get();
+        let last_y = self.last_y_offset.get();
+
+        if float_equals(scale, last_scale)
+            && float_equals(x_offset, last_x)
+            && float_equals(y_offset, last_y)
+        {
+            return Ok(());
+        }
+
+        let position = self.mouse_position();
+        let modifiers = current_modifiers();
+
+        // Direct Manipulation reports both translation and scale in every content update.
+        // Translation values can shift during a pinch due to the zoom center shifting.
+        // We classify each gesture as either scroll or pinch and only emit one type of event.
+        // We allow Scroll -> Pinch (a pinch can start with a small pan) but not the reverse.
+        if !float_equals(scale, 1.0) {
+            if self.gesture_kind.get() != GestureKind::Pinch {
+                self.end_gesture();
+                self.gesture_kind.set(GestureKind::Pinch);
+                self.pending_events
+                    .borrow_mut()
+                    .push(PlatformInput::Pinch(PinchEvent {
+                        position,
+                        delta: 0.0,
+                        modifiers,
+                        phase: TouchPhase::Started,
+                    }));
+            }
+        } else if self.gesture_kind.get() == GestureKind::None {
+            self.gesture_kind.set(GestureKind::Scroll);
+            self.scroll_phase.set(TouchPhase::Started);
+        }
+
+        match self.gesture_kind.get() {
+            GestureKind::Scroll => {
+                let dx = x_offset - last_x;
+                let dy = y_offset - last_y;
+                let touch_phase = self.scroll_phase.get();
+                self.scroll_phase.set(TouchPhase::Moved);
+                self.pending_events
+                    .borrow_mut()
+                    .push(PlatformInput::ScrollWheel(ScrollWheelEvent {
+                        position,
+                        delta: ScrollDelta::Pixels(point(px(dx), px(dy))),
+                        modifiers,
+                        touch_phase,
+                    }));
+            }
+            GestureKind::Pinch => {
+                let scale_delta = scale / last_scale;
+                self.pending_events
+                    .borrow_mut()
+                    .push(PlatformInput::Pinch(PinchEvent {
+                        position,
+                        delta: scale_delta - 1.0,
+                        modifiers,
+                        phase: TouchPhase::Moved,
+                    }));
+            }
+            GestureKind::None => {}
+        }
+
+        self.last_scale.set(scale);
+        self.last_x_offset.set(x_offset);
+        self.last_y_offset.set(y_offset);
+
+        Ok(())
+    }
+}
+
+fn float_equals(f1: f32, f2: f32) -> bool {
+    const EPSILON_SCALE: f32 = 0.00001;
+    (f1 - f2).abs() < EPSILON_SCALE * f1.abs().max(f2.abs()).max(EPSILON_SCALE)
+}

crates/gpui_windows/src/events.rs 🔗

@@ -111,6 +111,7 @@ impl WindowsWindowInner {
             WM_GPUI_CURSOR_STYLE_CHANGED => self.handle_cursor_changed(lparam),
             WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true),
             WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam),
+            DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam),
             _ => None,
         };
         if let Some(n) = handled {
@@ -758,6 +759,10 @@ impl WindowsWindowInner {
         self.state.scale_factor.set(new_scale_factor);
         self.state.border_offset.update(handle).log_err();
 
+        self.state
+            .direct_manipulation
+            .set_scale_factor(new_scale_factor);
+
         if is_maximized {
             // Get the monitor and its work area at the new DPI
             let monitor = unsafe { MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST) };
@@ -1139,10 +1144,27 @@ impl WindowsWindowInner {
         Some(0)
     }
 
+    fn handle_dm_pointer_hit_test(&self, wparam: WPARAM) -> Option<isize> {
+        self.state.direct_manipulation.on_pointer_hit_test(wparam);
+        None
+    }
+
     #[inline]
     fn draw_window(&self, handle: HWND, force_render: bool) -> Option<isize> {
         let mut request_frame = self.state.callbacks.request_frame.take()?;
 
+        self.state.direct_manipulation.update();
+
+        let events = self.state.direct_manipulation.drain_events();
+        if !events.is_empty() {
+            if let Some(mut func) = self.state.callbacks.input.take() {
+                for event in events {
+                    func(event);
+                }
+                self.state.callbacks.input.set(Some(func));
+            }
+        }
+
         if force_render {
             // Re-enable drawing after a device loss recovery. The forced render
             // will rebuild the scene with fresh atlas textures.

crates/gpui_windows/src/window.rs 🔗

@@ -26,6 +26,7 @@ use windows::{
     core::*,
 };
 
+use crate::direct_manipulation::DirectManipulationHandler;
 use crate::*;
 use gpui::*;
 
@@ -57,6 +58,7 @@ pub struct WindowsWindowState {
     pub last_reported_modifiers: Cell<Option<Modifiers>>,
     pub last_reported_capslock: Cell<Option<Capslock>>,
     pub hovered: Cell<bool>,
+    pub direct_manipulation: DirectManipulationHandler,
 
     pub renderer: RefCell<DirectXRenderer>,
 
@@ -131,6 +133,9 @@ impl WindowsWindowState {
         let fullscreen = None;
         let initial_placement = None;
 
+        let direct_manipulation = DirectManipulationHandler::new(hwnd, scale_factor)
+            .context("initializing Direct Manipulation")?;
+
         Ok(Self {
             origin: Cell::new(origin),
             logical_size: Cell::new(logical_size),
@@ -157,6 +162,7 @@ impl WindowsWindowState {
             initial_placement: Cell::new(initial_placement),
             hwnd,
             invalidate_devices,
+            direct_manipulation,
         })
     }
 

crates/image_viewer/src/image_viewer.rs 🔗

@@ -6,14 +6,12 @@ use std::path::Path;
 use anyhow::Context as _;
 use editor::{EditorSettings, items::entry_git_aware_label_color};
 use file_icons::FileIcons;
-#[cfg(any(target_os = "linux", target_os = "macos"))]
-use gpui::PinchEvent;
 use gpui::{
     AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter,
     FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement,
     IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
-    ParentElement, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task,
-    WeakEntity, Window, actions, checkerboard, div, img, point, px, size,
+    ParentElement, PinchEvent, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled,
+    Task, WeakEntity, Window, actions, checkerboard, div, img, point, px, size,
 };
 use language::File as _;
 use persistence::ImageViewerDb;
@@ -263,7 +261,6 @@ impl ImageView {
         }
     }
 
-    #[cfg(any(target_os = "linux", target_os = "macos"))]
     fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context<Self>) {
         let zoom_factor = 1.0 + event.delta;
         self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx);
@@ -685,7 +682,6 @@ impl Render for ImageView {
             .relative()
             .bg(cx.theme().colors().editor_background)
             .child({
-                #[cfg(any(target_os = "linux", target_os = "macos"))]
                 let container = div()
                     .id("image-container")
                     .size_full()
@@ -704,24 +700,6 @@ impl Render for ImageView {
                     .on_mouse_move(cx.listener(Self::handle_mouse_move))
                     .child(ImageContentElement::new(cx.entity()));
 
-                #[cfg(not(any(target_os = "linux", target_os = "macos")))]
-                let container = div()
-                    .id("image-container")
-                    .size_full()
-                    .overflow_hidden()
-                    .cursor(if self.is_dragging() {
-                        gpui::CursorStyle::ClosedHand
-                    } else {
-                        gpui::CursorStyle::OpenHand
-                    })
-                    .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel))
-                    .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
-                    .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down))
-                    .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
-                    .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up))
-                    .on_mouse_move(cx.listener(Self::handle_mouse_move))
-                    .child(ImageContentElement::new(cx.entity()));
-
                 container
             })
     }

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<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,
@@ -2498,6 +2549,292 @@ impl Sidebar {
         }
     }
 
+    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,
@@ -2530,9 +2867,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);
 
@@ -3166,6 +3505,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 {
@@ -3215,6 +3563,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);
             }))

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<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;

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<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),
+                                    )
+                                }),
+                        )
+                    })
+            }))
+    }
+}

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;

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<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 {

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,