From f4f1efb36f6963a529f07f94b3ce2411efb992bc Mon Sep 17 00:00:00 2001
From: lucascouts <lucascs@protonmail.com>
Date: Sat, 17 Oct 2026 18:35:50 -0300
Subject: [PATCH] sidebar: drag to reorder projects and threads

The threads sidebar orders projects one step at a time, through the
header menu's Move Up and Move Down, and orders a project's threads by
activity only, so the thread someone wants on top moves whenever another
one receives a message. This patch adds three drags and a way back, the
second consumer of 0048's store after 0049: story 036, 2026-10-17.

What it does. A project header dragged onto another header moves the
project there, in this window's order (upstream's per-window
multi_workspace_state). A thread dragged inside its project group records
the order on screen and switches that project to Manual mode, where
activity no longer reorders it; the order survives a restart and shows in
every window. A pinned thread dragged inside the Pinned section reorders
the section. "Sort by Recent" in the header menu of a Manual project
returns it to Recent mode, most recently active first. Drags never cross
lists: a thread row is not accepted on a header, a group row not in the
Pinned section or another group. Drafts, terminals and the sticky header
offer no drag, and while the filter has text no thread drag is offered and
a drop records nothing, since a filtered list shows only part of the order
and recording it would erase every hidden thread's place.

How. All new code is in crates/sidebar/src/organization.rs: three typed
drag payloads (GPUI dispatches a drop by payload type, so a header ignores
a dropped thread row), pure ordering functions, and the drop and menu
handlers in an impl Sidebar block, the way pinned_section.rs reaches the
parent's private items. Project drops call upstream's
MultiWorkspace::move_project_group_up/down once per step, unchanged.
Thread and pinned drops call 0048's set_manual_order, move_pinned and
set_sort_mode; Manual rows are ordered by manual_position_in after the
final display-time sort. Every window already observes the store (0049,
Sidebar::new), so a reorder reaches the others with no new code.

Failure: a store refusal (a draft, a thread it does not know) logs one
warning naming the count or the thread id, never a title, and changes
nothing; a refused thread order also rebuilds the list from the unchanged
stores, since the rows on screen then disagree with them. A project drop
whose group left the window logs a warning without paths and moves
nothing. Without the store
(try_global is None) no drag and no menu entry are offered, as before.

Upstream touch points: crates/sidebar/src/sidebar.rs only through five
hooks, 9 hunks and 26 added lines: (1) the module lines beside
mod sidebar_pinned_tests; (2) push_entries_by_display_time takes the group
key and cx and calls organization::order_manually, with its two calls in
rebuild_contents; (3) render_project_header passes the header through
project_header_drag after .block_mouse_except_scroll(); (4) render_thread
binds its final element and returns thread_row_drag(element, ..); (5) the
header's ellipsis menu gains sort_by_recent_entry before "Remove".
crates/sidebar/src/sidebar_pinned_tests.rs: capture_logs and warn_lines
made pub(super), since a process installs its logger once. New:
crates/sidebar/src/organization.rs, sidebar_organization_tests.rs (47
tests) and sidebar_organization_filter_tests.rs (2 tests).

UNGROUPED and unconditional, right after 0049, whose Pinned section it
makes draggable and whose store observation it reuses. The
claude-agent-acp-plus patches that also edit sidebar.rs (0028-0030) and
0063 are generated on top of it; 0063 extends the filter guard to its
devtools-only view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---

diff --git a/crates/sidebar/src/organization.rs b/crates/sidebar/src/organization.rs
new file mode 100644
index 0000000..55c7c53
--- /dev/null
+++ b/crates/sidebar/src/organization.rs
@@ -0,0 +1,523 @@
+//! Drag to reorder projects, threads and pinned threads.
+//!
+//! New organization UI lives here; `sidebar.rs` only calls into it through one-line hooks.
+
+use std::cmp::Reverse;
+use std::collections::HashMap;
+
+use agent_ui::ThreadId;
+use agent_ui::thread_organization::{ProjectKey, SortMode, ThreadOrganizationStore};
+use chrono::{DateTime, Utc};
+use gpui::{DismissEvent, Div, Stateful, StyleRefinement, WeakEntity};
+use ui::ContextMenu;
+use ui::prelude::*;
+use workspace::ProjectGroupKey;
+
+use crate::{DraftKind, ListEntry, Sidebar, ThreadEntry};
+
+/// What a dropped project header carries. `from_ix` only picks the highlight edge: a drop
+/// resolves keys against the window's order at drop time, since a rebuild can shift indices.
+#[derive(Clone)]
+pub(crate) struct DraggedProjectHeader {
+    pub key: ProjectGroupKey,
+    pub label: SharedString,
+    pub from_ix: usize,
+}
+
+/// Where a thread row sits. A thread's group row and its pinned row have different origins,
+/// which is what keeps a drag inside its own list.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) enum RowOrigin {
+    Group(ProjectGroupKey),
+    Pinned,
+}
+
+/// What a dropped thread row carries. `from_ix` only picks the highlight edge: a drop resolves
+/// ids against the entries at drop time, since a rebuild can shift indices.
+#[derive(Clone)]
+pub(crate) struct DraggedThreadRow {
+    pub thread_id: ThreadId,
+    pub origin: RowOrigin,
+    pub title: SharedString,
+    pub from_ix: usize,
+}
+
+/// The floating preview while dragging: one muted label, like a dragged tab.
+pub(crate) struct DragPreview {
+    label: SharedString,
+}
+
+impl Render for DragPreview {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let colors = cx.theme().colors();
+        div()
+            .px_2()
+            .py_1()
+            .rounded_sm()
+            .border_1()
+            .border_color(colors.border)
+            .bg(colors.elevated_surface_background)
+            .child(
+                Label::new(self.label.clone())
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+            )
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum StepDirection {
+    Up,
+    Down,
+}
+
+/// The drop-target highlight of a vertical list: the background, plus a 2px border on the
+/// side where the dragged item will land (below the target when it moves down, above it when
+/// it moves up).
+pub(crate) fn drop_edge(
+    style: StyleRefinement,
+    ix: usize,
+    from_ix: usize,
+    cx: &App,
+) -> StyleRefinement {
+    let colors = cx.theme().colors();
+    let style = style
+        .bg(colors.drop_target_background)
+        .border_color(colors.drop_target_border)
+        .border_0();
+    if ix > from_ix {
+        style.border_b_2()
+    } else if ix < from_ix {
+        style.border_t_2()
+    } else {
+        style
+    }
+}
+
+/// `ids` with `dragged` moved onto `target`: right after it when moving down, right before it
+/// when moving up. `None` when the move would change nothing or either id is not in `ids`, so a
+/// caller never re-records an unchanged order.
+pub(crate) fn reordered(
+    ids: &[ThreadId],
+    dragged: ThreadId,
+    target: ThreadId,
+) -> Option<Vec<ThreadId>> {
+    if dragged == target {
+        return None;
+    }
+    let from = ids.iter().position(|id| *id == dragged)?;
+    let to = ids.iter().position(|id| *id == target)?;
+    let mut order = ids.to_vec();
+    let moved = order.remove(from);
+    // After the removal, inserting at the target's former index lands after the target when
+    // it was below `dragged` (it shifted up by one) and before it when it was above.
+    order.insert(to, moved);
+    Some(order)
+}
+
+/// The direction and number of single swaps that bring `dragged` to `target`'s former index.
+/// `None` when they are the same key or either is not in `keys`.
+pub(crate) fn project_steps(
+    keys: &[ProjectGroupKey],
+    dragged: &ProjectGroupKey,
+    target: &ProjectGroupKey,
+) -> Option<(StepDirection, usize)> {
+    let from = keys.iter().position(|key| key == dragged)?;
+    let to = keys.iter().position(|key| key == target)?;
+    if to > from {
+        Some((StepDirection::Down, to - from))
+    } else if to < from {
+        Some((StepDirection::Up, from - to))
+    } else {
+        None
+    }
+}
+
+/// The ids of the thread rows of the group that entry `ix` belongs to, in screen order. Drafts
+/// are skipped (the store refuses them) and so are terminals (they have no `ThreadId`). Empty
+/// in the Pinned section and for a stale index.
+pub(crate) fn group_thread_ids(entries: &[ListEntry], ix: usize) -> Vec<ThreadId> {
+    let Some(header_ix) = nearest_header(entries, ix) else {
+        return Vec::new();
+    };
+    let Some(ListEntry::ProjectHeader { .. }) = entries.get(header_ix) else {
+        return Vec::new();
+    };
+    entries
+        .iter()
+        .skip(header_ix + 1)
+        .take_while(|entry| !is_header(entry))
+        .filter_map(|entry| match entry {
+            ListEntry::Thread(thread) if thread.draft.is_none() && !thread.metadata.is_draft() => {
+                Some(thread.metadata.thread_id)
+            }
+            _ => None,
+        })
+        .collect()
+}
+
+/// The origin of entry `ix`: the group of the nearest project header above it, or the Pinned
+/// section. `None` for a stale index.
+pub(crate) fn row_origin(entries: &[ListEntry], ix: usize) -> Option<RowOrigin> {
+    match entries.get(nearest_header(entries, ix)?)? {
+        ListEntry::ProjectHeader { key, .. } => Some(RowOrigin::Group(key.clone())),
+        ListEntry::PinnedHeader { .. } => Some(RowOrigin::Pinned),
+        ListEntry::Thread(_) | ListEntry::Terminal(_) => None,
+    }
+}
+
+/// Each given thread's recorded place in the project of `key`. `None` without the store or when
+/// the project is in Recent mode, so a Recent project keeps exactly the upstream sort. The ids
+/// come from the caller because the store does not list a project's threads. Read only.
+pub(crate) fn manual_slots(
+    key: &ProjectGroupKey,
+    thread_ids: &[ThreadId],
+    cx: &App,
+) -> Option<HashMap<ThreadId, u32>> {
+    let store = ThreadOrganizationStore::try_global(cx)?;
+    let store = store.read(cx);
+    let project = ProjectKey::from_group(key);
+    if store.sort_mode(&project) != SortMode::Manual {
+        return None;
+    }
+    Some(
+        thread_ids
+            .iter()
+            .filter_map(|&thread_id| {
+                let slot = store.manual_position_in(thread_id, &project)?;
+                Some((thread_id, slot))
+            })
+            .collect(),
+    )
+}
+
+/// Re-sorts one group's rows into its recorded order when the project is in Manual mode; a
+/// Recent project, or no store, leaves the upstream sort untouched.
+pub(crate) fn order_manually(rows: &mut Vec<ListEntry>, key: &ProjectGroupKey, cx: &App) {
+    let thread_ids: Vec<ThreadId> = rows
+        .iter()
+        .filter_map(|entry| match entry {
+            ListEntry::Thread(thread) => Some(thread.metadata.thread_id),
+            _ => None,
+        })
+        .collect();
+    if let Some(slots) = manual_slots(key, &thread_ids, cx) {
+        apply_manual_order(rows, &slots);
+    }
+}
+
+/// The project's sort mode; `None` without the store.
+pub(crate) fn sort_mode_of(key: &ProjectGroupKey, cx: &App) -> Option<SortMode> {
+    let store = ThreadOrganizationStore::try_global(cx)?;
+    Some(store.read(cx).sort_mode(&ProjectKey::from_group(key)))
+}
+
+/// Re-sorts one group's rows for a Manual project, stably, into three bands: empty drafts; rows
+/// with no slot (drafts with text, threads new since the last drag, terminals), newest created
+/// first; then slotted threads by slot. Activity (`interacted_at`) is never read, so using a
+/// thread does not move it.
+pub(crate) fn apply_manual_order(rows: &mut Vec<ListEntry>, slots: &HashMap<ThreadId, u32>) {
+    rows.sort_by_cached_key(|row| manual_key(row, slots));
+}
+
+/// Variant order is band order; within a band the field orders.
+#[derive(PartialEq, Eq, PartialOrd, Ord)]
+enum ManualKey {
+    /// Not expected among one group's rows; kept first and in place rather than panicking.
+    Header,
+    EmptyDraft,
+    Unplaced(Reverse<DateTime<Utc>>),
+    Placed(u32),
+}
+
+fn manual_key(row: &ListEntry, slots: &HashMap<ThreadId, u32>) -> ManualKey {
+    match row {
+        ListEntry::Thread(thread) if thread.draft == Some(DraftKind::Empty) => {
+            ManualKey::EmptyDraft
+        }
+        ListEntry::Thread(thread) => {
+            let slot = match thread.draft {
+                Some(_) => None,
+                None => slots.get(&thread.metadata.thread_id).copied(),
+            };
+            match slot {
+                Some(slot) => ManualKey::Placed(slot),
+                None => ManualKey::Unplaced(Reverse(
+                    thread
+                        .metadata
+                        .created_at
+                        .unwrap_or(thread.metadata.updated_at),
+                )),
+            }
+        }
+        ListEntry::Terminal(terminal) => ManualKey::Unplaced(Reverse(terminal.metadata.created_at)),
+        ListEntry::ProjectHeader { .. } | ListEntry::PinnedHeader { .. } => ManualKey::Header,
+    }
+}
+
+fn is_header(entry: &ListEntry) -> bool {
+    matches!(
+        entry,
+        ListEntry::ProjectHeader { .. } | ListEntry::PinnedHeader { .. }
+    )
+}
+
+/// The index of the nearest header at or before `ix`, or `None` when `ix` is stale or no
+/// header precedes it.
+fn nearest_header(entries: &[ListEntry], ix: usize) -> Option<usize> {
+    entries.get(..=ix)?.iter().rposition(is_header)
+}
+
+impl Sidebar {
+    /// Moves the dragged project to the target header's former place in this window, one
+    /// upstream swap per step, so the order is emitted, persisted per window and notified exactly
+    /// as "Move Up"/"Move Down" do. Keys are resolved against the window's order at drop time.
+    /// Logs never name a folder.
+    pub(super) fn drop_project_header(
+        &mut self,
+        dragged: &DraggedProjectHeader,
+        target: &ProjectGroupKey,
+        cx: &mut Context<Self>,
+    ) {
+        let from_ix = dragged.from_ix;
+        let keys = match self.multi_workspace.read_with(cx, |multi_workspace, _| {
+            multi_workspace.project_group_keys()
+        }) {
+            Ok(keys) => keys,
+            Err(error) => {
+                log::warn!(
+                    "sidebar: project drop ignored, window is gone (from {from_ix}): {error}"
+                );
+                return;
+            }
+        };
+        let position = |key: &ProjectGroupKey| keys.iter().position(|k| k == key);
+        let (Some(from), Some(to)) = (position(&dragged.key), position(target)) else {
+            log::warn!("sidebar: project drop ignored, group no longer in window (from {from_ix})");
+            return;
+        };
+        // `None` here means the own header: nothing to move.
+        let Some((direction, steps)) = project_steps(&keys, &dragged.key, target) else {
+            return;
+        };
+        let key = dragged.key.clone();
+        let stopped_at = self.multi_workspace.update(cx, |multi_workspace, cx| {
+            (1..=steps).find(|_| {
+                let moved = match direction {
+                    StepDirection::Up => multi_workspace.move_project_group_up(&key, cx),
+                    StepDirection::Down => multi_workspace.move_project_group_down(&key, cx),
+                };
+                !moved
+            })
+        });
+        match stopped_at {
+            Ok(None) => {}
+            Ok(Some(step)) => log::warn!(
+                "sidebar: project move stopped at step {step} of {steps} (from {from} to {to})"
+            ),
+            Err(error) => {
+                log::warn!(
+                    "sidebar: project drop ignored, window is gone (from {from_ix}): {error}"
+                )
+            }
+        }
+    }
+
+    /// Moves the dragged thread row onto entry `target_ix` of its own list. The target is
+    /// resolved against the entries at drop time, since a rebuild can shift indices: a target
+    /// that is no longer a thread row of the dragged row's origin changes nothing. Called from
+    /// the drop listener only, outside any `ThreadMetadataStore` update.
+    pub(super) fn drop_thread_row(
+        &mut self,
+        dragged: &DraggedThreadRow,
+        target_ix: usize,
+        cx: &mut Context<Self>,
+    ) {
+        // A filtered list shows part of the order, and recording it would drop every hidden
+        // thread's place. The drag-offer gate is not enough: text typed mid-drag gets past it.
+        if self.has_filter_query(cx) {
+            return;
+        }
+        // Without the store no drag is offered, so there is nothing to report.
+        let Some(store) = ThreadOrganizationStore::try_global(cx) else {
+            return;
+        };
+        let Some(ListEntry::Thread(target)) = self.contents.entries.get(target_ix) else {
+            return;
+        };
+        if row_origin(&self.contents.entries, target_ix).as_ref() != Some(&dragged.origin) {
+            return;
+        }
+        let target_id = target.metadata.thread_id;
+        match &dragged.origin {
+            RowOrigin::Group(key) => {
+                let ids = group_thread_ids(&self.contents.entries, target_ix);
+                let Some(order) = reordered(&ids, dragged.thread_id, target_id) else {
+                    return;
+                };
+                // The whole group as shown: a thread left out of the order loses its place.
+                let result = store.update(cx, |store, cx| {
+                    store.set_manual_order(ProjectKey::from_group(key), &order, cx)
+                });
+                if let Err(error) = result {
+                    log::warn!(
+                        "sidebar: manual order refused for {} threads: {error}",
+                        order.len()
+                    );
+                    // The rows on screen disagree with the stores; show the stores' truth again.
+                    self.schedule_update_entries(false, cx);
+                }
+            }
+            RowOrigin::Pinned => {
+                if dragged.thread_id == target_id {
+                    return;
+                }
+                // The section hides archived, draft and unknown pins, so an index among the
+                // visible rows would land off by one next to a hidden pin.
+                let Some(to_index) = store
+                    .read(cx)
+                    .pinned()
+                    .position(|pinned| pinned == target_id)
+                else {
+                    // Unpinned elsewhere during the drag: no other index is the user's intent.
+                    return;
+                };
+                let result = store.update(cx, |store, cx| {
+                    store.move_pinned(dragged.thread_id, to_index, cx)
+                });
+                if let Err(error) = result {
+                    log::warn!(
+                        "sidebar: pinned move refused for thread {}: {error}",
+                        dragged.thread_id.to_key_string()
+                    );
+                }
+            }
+        }
+    }
+
+    /// Makes a thread row of a project group or of the Pinned section a drag source and a drop
+    /// target for rows of the same list. The row is returned unchanged without the store (no
+    /// drag, R2.12) and while filtering (a partial list cannot be reordered, R5.1). A new wrapper
+    /// carries the drag, since `on_drag` may be called only once per element. Drafts and
+    /// terminals never reach this hook (R5.2).
+    pub(crate) fn thread_row_drag(
+        &self,
+        row: AnyElement,
+        ix: usize,
+        thread: &ThreadEntry,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        if ThreadOrganizationStore::try_global(cx).is_none() || self.has_filter_query(cx) {
+            return row;
+        }
+        let Some(origin) = row_origin(&self.contents.entries, ix) else {
+            return row;
+        };
+        let payload = DraggedThreadRow {
+            thread_id: thread.metadata.thread_id,
+            origin: origin.clone(),
+            title: thread.metadata.display_title(),
+            from_ix: ix,
+        };
+        div()
+            .id(("thread-drag", ix))
+            .child(row)
+            .on_drag(payload, |dragged, _, _, cx| {
+                cx.new(|_| DragPreview {
+                    label: dragged.title.clone(),
+                })
+            })
+            // Gates both the highlight and the drop: a row of another list is no target.
+            .can_drop(move |value, _, _| {
+                value
+                    .downcast_ref::<DraggedThreadRow>()
+                    .is_some_and(|dragged| dragged.origin == origin)
+            })
+            .drag_over::<DraggedThreadRow>(move |style, dragged, _, cx| {
+                drop_edge(style, ix, dragged.from_ix, cx)
+            })
+            .on_drop(cx.listener(move |this, dragged: &DraggedThreadRow, _, cx| {
+                this.drop_thread_row(dragged, ix, cx)
+            }))
+            .into_any_element()
+    }
+
+    /// Makes a list header a drag source and a drop target for other project headers. The
+    /// sticky copy at the top is left alone, so each group has one drag source. The drop is
+    /// typed: a dragged thread row finds no listener on a header.
+    pub(crate) fn project_header_drag(
+        &self,
+        header: Stateful<Div>,
+        ix: usize,
+        is_sticky: bool,
+        key: &ProjectGroupKey,
+        cx: &mut Context<Self>,
+    ) -> Stateful<Div> {
+        if is_sticky {
+            return header;
+        }
+        // The rendered header shadows its label parameter, so the label is read from the entry.
+        let Some(ListEntry::ProjectHeader { label, .. }) = self.contents.entries.get(ix) else {
+            return header;
+        };
+        let payload = DraggedProjectHeader {
+            key: key.clone(),
+            label: label.clone(),
+            from_ix: ix,
+        };
+        let target = key.clone();
+        header
+            .on_drag(payload, |dragged, _, _, cx| {
+                cx.new(|_| DragPreview {
+                    label: dragged.label.clone(),
+                })
+            })
+            .drag_over::<DraggedProjectHeader>(move |style, dragged, _, cx| {
+                drop_edge(style, ix, dragged.from_ix, cx)
+            })
+            .on_drop(
+                cx.listener(move |this, dragged: &DraggedProjectHeader, _, cx| {
+                    this.drop_project_header(dragged, &target, cx)
+                }),
+            )
+    }
+}
+
+/// Adds "Sort by Recent" to a project header menu, only for a Manual project. Called from the
+/// menu's builder, so the mode is read when the menu opens rather than when the sidebar rendered.
+pub(crate) fn sort_by_recent_entry(
+    menu: ContextMenu,
+    sidebar: WeakEntity<Sidebar>,
+    key: ProjectGroupKey,
+    weak_menu: WeakEntity<ContextMenu>,
+    cx: &App,
+) -> ContextMenu {
+    if sort_mode_of(&key, cx) != Some(SortMode::Manual) {
+        return menu;
+    }
+    menu.separator()
+        .entry("Sort by Recent", None, move |_window, cx| {
+            if let Err(error) =
+                sidebar.update(cx, |sidebar, cx| sidebar.sort_project_by_recent(&key, cx))
+            {
+                log::warn!("sidebar: sort by recent ignored, sidebar is gone: {error}");
+            }
+            weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
+        })
+}
+
+impl Sidebar {
+    /// Returns one project to most-recent-first, discarding only its recorded thread places.
+    /// On a project already in Recent mode the store writes nothing. Logs never name a folder.
+    pub(super) fn sort_project_by_recent(&mut self, key: &ProjectGroupKey, cx: &mut Context<Self>) {
+        let Some(store) = ThreadOrganizationStore::try_global(cx) else {
+            return;
+        };
+        let result = store.update(cx, |store, cx| {
+            store.set_sort_mode(ProjectKey::from_group(key), SortMode::Recent, cx)
+        });
+        if let Err(error) = result {
+            log::warn!("sidebar: sort by recent refused: {error}");
+        }
+    }
+}
diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs
index ee2b342..e070355 100644
--- a/crates/sidebar/src/sidebar.rs
+++ b/crates/sidebar/src/sidebar.rs
@@ -90,6 +90,12 @@ mod sidebar_tests;
 #[cfg(test)]
 mod sidebar_pinned_tests;
 
+mod organization;
+#[cfg(test)]
+mod sidebar_organization_filter_tests;
+#[cfg(test)]
+mod sidebar_organization_tests;
+
 gpui::actions!(
     agents_sidebar,
     [
@@ -2082,6 +2088,8 @@ impl Sidebar {
                     matched_threads,
                     &mut current_session_ids,
                     &mut current_thread_ids,
+                    group_key,
+                    cx,
                 );
             } else {
                 let has_terminal_notifications = terminals
@@ -2135,6 +2143,8 @@ impl Sidebar {
                     threads,
                     &mut current_session_ids,
                     &mut current_thread_ids,
+                    group_key,
+                    cx,
                 );
             }
         }
@@ -2699,6 +2709,7 @@ impl Sidebar {
                 }),
             )
             .block_mouse_except_scroll();
+        let header = self.project_header_drag(header, ix, is_sticky, key, cx);
 
         if !is_collapsed && !has_threads {
             v_flex()
@@ -3363,6 +3374,13 @@ impl Sidebar {
                                 )
                         });
 
+                        let menu = organization::sort_by_recent_entry(
+                            menu,
+                            this_for_menu.clone(),
+                            project_group_key.clone(),
+                            weak_menu.clone(),
+                            menu_cx,
+                        );
                         let project_group_key = project_group_key.clone();
                         let remove_multi_workspace = multi_workspace.clone();
                         menu.separator().entry("Remove", None, move |window, cx| {
@@ -6121,6 +6139,8 @@ impl Sidebar {
         threads: Vec<Arc<ThreadEntry>>,
         current_session_ids: &mut HashSet<acp::SessionId>,
         current_thread_ids: &mut HashSet<agent_ui::ThreadId>,
+        group_key: &ProjectGroupKey,
+        cx: &App,
     ) {
         fn display_time(entry: &ListEntry) -> DateTime<Utc> {
             match entry {
@@ -6138,6 +6158,9 @@ impl Sidebar {
             .map(ListEntry::Terminal)
             .chain(threads.into_iter().map(ListEntry::Thread))
             .sorted_by_key(|right| std::cmp::Reverse(display_time(right)));
+        let mut row_entries: Vec<ListEntry> = row_entries.collect();
+        // After the final sort, which would otherwise undo it.
+        organization::order_manually(&mut row_entries, group_key, cx);
 
         for entry in row_entries {
             if let ListEntry::Thread(thread) = &entry {
@@ -6843,7 +6866,7 @@ impl Sidebar {
         let can_open_as_markdown = thread.is_live || is_zed_thread;
         let folder_paths = thread.metadata.folder_paths().clone();
 
-        right_click_menu(context_menu_id)
+        let element = right_click_menu(context_menu_id)
             .trigger(move |_, _, _| thread_item)
             .menu({
                 let thread_id = thread.metadata.thread_id;
@@ -6972,7 +6995,8 @@ impl Sidebar {
                     })
                 }
             })
-            .into_any_element()
+            .into_any_element();
+        self.thread_row_drag(element, ix, thread, cx)
     }
 
     fn render_terminal(
diff --git a/crates/sidebar/src/sidebar_organization_filter_tests.rs b/crates/sidebar/src/sidebar_organization_filter_tests.rs
new file mode 100644
index 0000000..48c8d98
--- /dev/null
+++ b/crates/sidebar/src/sidebar_organization_filter_tests.rs
@@ -0,0 +1,189 @@
+// A thread drop while the list is filtered records nothing (R5.1, R2.11, R2.2).
+//
+// `set_manual_order` replaces a project's whole order, and a filtered list shows only part of
+// it: a drop there would take every hidden thread out of the order. The search text is
+// covered over a Manual project whose hidden thread must keep its slot, and over a Recent
+// project that must stay Recent.
+
+use super::*;
+use crate::sidebar_tests::{
+    init_test, save_thread_metadata_with_main_paths, setup_sidebar, type_in_search,
+    visible_entries_as_strings,
+};
+use agent_ui::thread_metadata_store::{ThreadId, ThreadMetadataStore};
+use agent_ui::thread_organization::{ProjectKey, SortMode, ThreadOrganizationStore};
+use fs::FakeFs;
+use gpui::{TestAppContext, VisualTestContext};
+use organization::{DraggedThreadRow, RowOrigin};
+use pretty_assertions::assert_eq;
+use std::path::PathBuf;
+use util::path_list::PathList;
+
+const ALPHA: &str = "/alpha";
+
+// ── Setup ──────────────────────────────────────────────────────────────────
+
+/// A window on `ALPHA` with its sidebar open and the organization store installed.
+async fn alpha_window(cx: &mut TestAppContext) -> (Entity<Sidebar>, &mut VisualTestContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(ALPHA, serde_json::json!({ "src": {} }))
+        .await;
+    cx.update(|cx| {
+        <dyn fs::Fs>::set_global(fs.clone(), cx);
+        agent_ui::thread_organization::init(cx);
+    });
+    cx.run_until_parked();
+    let project = project::Project::test(fs.clone(), [ALPHA.as_ref()], cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+    let sidebar = setup_sidebar(&multi_workspace, cx);
+    (sidebar, cx)
+}
+
+fn alpha_key() -> ProjectKey {
+    ProjectKey::from_group(&ProjectGroupKey::new(
+        None,
+        PathList::new(&[PathBuf::from(ALPHA)]),
+    ))
+}
+
+/// Saves a thread with a sent message in `ALPHA`, last active at `minute`.
+fn thread(session: &str, title: &str, minute: u32, cx: &mut VisualTestContext) -> ThreadId {
+    let paths = PathList::new(&[ALPHA]);
+    save_thread_metadata_with_main_paths(
+        session,
+        title,
+        paths.clone(),
+        paths,
+        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2026, 10, 17, 12, minute, 0).unwrap(),
+        cx,
+    );
+    cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entries()
+            .find(|entry| {
+                entry
+                    .session_id
+                    .as_ref()
+                    .is_some_and(|id| id.0.as_ref() == session)
+            })
+            .map(|entry| entry.thread_id)
+            .expect("fixture: the thread was saved")
+    })
+}
+
+/// Seeds a Manual order straight into the store (a fixture, not the code under test).
+fn record_order(order: &[ThreadId], cx: &mut VisualTestContext) {
+    cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store.set_manual_order(alpha_key(), order, cx)
+        })
+    })
+    .expect("fixture: the store accepts an order of known threads with a message");
+    cx.run_until_parked();
+}
+
+// ── Reading the store and the sidebar ──────────────────────────────────────
+
+fn mode(cx: &mut VisualTestContext) -> SortMode {
+    cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx)
+            .read(cx)
+            .sort_mode(&alpha_key())
+    })
+}
+
+fn slots(threads: &[ThreadId], cx: &mut VisualTestContext) -> Vec<Option<u32>> {
+    cx.update(|_, cx| {
+        let store = ThreadOrganizationStore::global(cx).read(cx);
+        threads
+            .iter()
+            .map(|thread_id| store.manual_position_in(*thread_id, &alpha_key()))
+            .collect()
+    })
+}
+
+fn row_ix(sidebar: &Entity<Sidebar>, thread_id: ThreadId, cx: &mut VisualTestContext) -> usize {
+    sidebar.read_with(cx, |sidebar, _| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .position(|entry| {
+                matches!(entry, ListEntry::Thread(thread)
+                    if thread.metadata.thread_id == thread_id
+                        && thread.pinned_project_label.is_none())
+            })
+            .expect("fixture: the thread has a row in its group")
+    })
+}
+
+/// Drops the group row of `dragged` on the group row of `target`, the way the drop listener does.
+fn drop_on(
+    sidebar: &Entity<Sidebar>,
+    dragged: ThreadId,
+    target: ThreadId,
+    cx: &mut VisualTestContext,
+) {
+    let from_ix = row_ix(sidebar, dragged, cx);
+    let target_ix = row_ix(sidebar, target, cx);
+    let payload = DraggedThreadRow {
+        thread_id: dragged,
+        origin: RowOrigin::Group(ProjectGroupKey::new(
+            None,
+            PathList::new(&[PathBuf::from(ALPHA)]),
+        )),
+        title: "dragged".into(),
+        from_ix,
+    };
+    sidebar.update(cx, |sidebar, cx| {
+        sidebar.drop_thread_row(&payload, target_ix, cx)
+    });
+    cx.run_until_parked();
+}
+
+// ── The search text ────────────────────────────────────────────────────────
+
+// R5.1: text typed while the pointer is held gets past the drag-offer gate; the drop itself
+// still records nothing, and the thread the text hides keeps its slot.
+#[gpui::test]
+async fn filter_text_drop_keeps_the_hidden_threads_in_place(cx: &mut TestAppContext) {
+    let (sidebar, cx) = alpha_window(cx).await;
+    let other = thread("s-other", "Refactor parser", 3, cx);
+    let first = thread("s-first", "Fix the header", 2, cx);
+    let second = thread("s-second", "Fix the footer", 1, cx);
+    record_order(&[other, first, second], cx);
+    type_in_search(&sidebar, "fix", cx);
+    let before = visible_entries_as_strings(&sidebar, cx);
+    assert!(
+        !before.iter().any(|line| line.contains("Refactor parser")),
+        "fixture: the search text hides the other thread"
+    );
+
+    drop_on(&sidebar, second, first, cx);
+
+    assert_eq!(
+        slots(&[other, first, second], cx),
+        vec![Some(0), Some(1), Some(2)],
+        "a drop while the filter has text records nothing"
+    );
+    assert_eq!(mode(cx), SortMode::Manual, "the mode is unchanged");
+    assert_eq!(visible_entries_as_strings(&sidebar, cx), before);
+}
+
+// R2.2: a Recent project shown through the search text stays Recent.
+#[gpui::test]
+async fn filter_text_drop_leaves_a_recent_project_recent(cx: &mut TestAppContext) {
+    let (sidebar, cx) = alpha_window(cx).await;
+    let _other = thread("s-other", "Refactor parser", 3, cx);
+    let first = thread("s-first", "Fix the header", 2, cx);
+    let second = thread("s-second", "Fix the footer", 1, cx);
+    type_in_search(&sidebar, "fix", cx);
+
+    drop_on(&sidebar, second, first, cx);
+
+    assert_eq!(mode(cx), SortMode::Recent, "nothing recorded");
+    assert_eq!(slots(&[first, second], cx), vec![None, None]);
+}
diff --git a/crates/sidebar/src/sidebar_organization_tests.rs b/crates/sidebar/src/sidebar_organization_tests.rs
new file mode 100644
index 0000000..5d35420
--- /dev/null
+++ b/crates/sidebar/src/sidebar_organization_tests.rs
@@ -0,0 +1,2248 @@
+// Story 036 — drag to reorder projects, threads and pinned threads (tasks 3.1, 4.1, 5.1, 6.1,
+// 6.2, 6.3, 7.1). One file, one module per task:
+//   pure            task 3.1  reordered, project_steps, group_thread_ids, row_origin,
+//                             apply_manual_order, payload identity (no window)
+//   manual_order    task 4.1  manual_slots / sort_mode_of and hook 2 (Recent untouched, Manual
+//                             bands, activity ignored, new thread on top, restart, every window)
+//   project_drag    task 5.1  drop_project_header (steps, own header, per window, persisted)
+//   thread_drag     task 6.1  drop_thread_row for group rows (whole order, Manual, refusals)
+//   pinned_drag     task 6.2  drop_thread_row for pinned rows (index in the store's full order)
+//   drag_offer      task 6.3  thread_row_drag / project_header_drag with a real simulated pointer
+//                             (what can be dragged where, click vs drag)
+//   sort_by_recent  task 7.1  "Sort by Recent" menu entry and sort_project_by_recent
+//
+// Materialize as `crates/sidebar/src/sidebar_organization_tests.rs`, wired in `sidebar.rs` beside
+// `mod sidebar_pinned_tests;`:
+//     #[cfg(test)]
+//     mod sidebar_organization_tests;
+// Filter: `cargo test -p sidebar organization`.
+//
+// What the tests need from the code, beyond design.md's signatures:
+//   - The handlers this file calls directly — `drop_project_header`, `drop_thread_row`,
+//     `sort_project_by_recent` — live in `organization.rs`'s `impl Sidebar`, so they must be at
+//     least `pub(super)` (a private method of a child module is invisible to this sibling module).
+//     The payload structs' fields are `pub`, as design.md shows them.
+//   - `capture_logs` and `warn_lines` in `sidebar_pinned_tests.rs` become `pub(super)` (nothing
+//     else changes in them). They are imported, not copied: `log::set_boxed_logger` succeeds once
+//     per process, and a second copy of the capture would panic with "another logger is
+//     installed" in whichever file ran second.
+//
+// The store is installed with `agent_ui::thread_organization::init(cx)` BEFORE the sidebar is
+// created (the sidebar observes it from `Sidebar::new`); `init_test` gives every test its own
+// database, so orders never leak between tests.
+//
+// Drops are driven two ways. Most tests call the drop handler directly with a payload, as
+// `project_panel_tests.rs` `drag_entries_onto` does: that is the contract design.md names. The
+// `drag_offer` module drives a real pointer through `simulate_mouse_down/move/up`, so at least one
+// scenario per drag kind crosses the GPUI drag-and-drop boundary for real (on_drag, can_drop,
+// on_drop, the drag threshold) — what a direct call cannot prove.
+//
+// Hostile halves come first in every identity rule:
+//   - activity after a drag must NOT reorder a Manual project, while the same activity DOES reorder
+//     a Recent one (the converse, proving the activity fixture is real);
+//   - a new thread lands ABOVE the slotted ones even when its timestamp is older than all of them;
+//   - "Sort by Recent" on alpha leaves beta Manual with its slots;
+//   - a drop on another group's row, a pinned row, a header or a stale index is a no-op;
+//   - the pinned index is taken in the store's FULL pinned order, with an archived (hidden) pin in
+//     the way, in the direction where the visible index would give a different answer;
+//   - moving down places after the target, moving up places before it;
+//   - a drop on the own row changes neither the order nor the MODE (a Recent project stays
+//     Recent: re-recording the current order would wrongly flip it to Manual);
+//   - one thread produces two DIFFERENT origins (group row vs pinned row), while two keys built
+//     from the same path are the SAME origin; and a project whose folder is named "Pinned" is a
+//     group, never the Pinned section (a third element colliding with a derived value);
+//   - refusal warnings carry a count or an id, never a thread title or a folder path.
+//
+// Known gaps, reported rather than faked:
+//   - R3.6: `ThreadOrganizationStore::set_sort_mode` has no `Err` path at series tip d945c3b, so
+//     a refused "Sort by Recent" cannot be produced through the real store.
+//   - R1.7: a group that leaves the window between steps cannot be produced from outside one
+//     synchronous `multi_workspace.update`; the reachable half (the dragged group already gone)
+//     is asserted to change nothing and to log no path.
+//   - R1.3, R2.9, R4.3 (the edge highlight) and R1.8 (no drag from the sticky header) are render
+//     details with no observable state; the same-origin `can_drop` gate behind R2.9 is covered by
+//     the foreign-drop tests.
+
+use super::*;
+use crate::sidebar_pinned_tests::{capture_logs, warn_lines};
+use crate::sidebar_tests::{
+    init_test, init_test_project_with_agent_panel, setup_sidebar, setup_sidebar_with_agent_panel,
+    type_in_search, visible_entries_as_strings,
+};
+use agent_ui::thread_organization::{ProjectKey, SortMode, ThreadOrganizationStore};
+use fs::FakeFs;
+use gpui::{Bounds, MouseButton, Point, TestAppContext, VisualTestContext, point};
+use organization::{DraggedProjectHeader, DraggedThreadRow, RowOrigin};
+
+const ALPHA: &str = "/alpha";
+const BETA: &str = "/beta";
+const GAMMA: &str = "/gamma";
+/// Never opened in any window: a group key that is not in the window.
+const DELTA: &str = "/delta";
+
+// ── Setup ──────────────────────────────────────────────────────────────────
+
+/// Sidebar test globals, a FakeFs with every project folder and, when `with_store`, the
+/// organization store. Returns the fs and a project for `ALPHA`.
+async fn init_org_test(
+    with_store: bool,
+    cx: &mut TestAppContext,
+) -> (Arc<FakeFs>, Entity<project::Project>) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    for path in [ALPHA, BETA, GAMMA, DELTA] {
+        fs.insert_tree(path, serde_json::json!({ "src": {} })).await;
+    }
+    cx.update(|cx| {
+        <dyn fs::Fs>::set_global(fs.clone(), cx);
+        if with_store {
+            agent_ui::thread_organization::init(cx);
+        }
+    });
+    cx.run_until_parked();
+    let project = project::Project::test(fs.clone(), [ALPHA.as_ref()], cx).await;
+    (fs, project)
+}
+
+/// A window on `ALPHA` with its sidebar open, the organization store installed.
+async fn alpha_window(
+    cx: &mut TestAppContext,
+) -> (
+    Arc<FakeFs>,
+    Entity<MultiWorkspace>,
+    Entity<Sidebar>,
+    &mut VisualTestContext,
+) {
+    let (fs, project) = init_org_test(true, cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+    let sidebar = setup_sidebar(&multi_workspace, cx);
+    (fs, multi_workspace, sidebar, cx)
+}
+
+/// Opens `path` as another project group of this window.
+async fn open_in_window(
+    path: &str,
+    fs: &Arc<FakeFs>,
+    multi_workspace: &Entity<MultiWorkspace>,
+    cx: &mut VisualTestContext,
+) -> Entity<Workspace> {
+    let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
+    let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(project, window, cx)
+    });
+    cx.run_until_parked();
+    workspace
+}
+
+fn at(hour: u32) -> DateTime<Utc> {
+    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap()
+}
+
+fn local_key(path: &str) -> ProjectGroupKey {
+    ProjectGroupKey::new(None, PathList::new(&[PathBuf::from(path)]))
+}
+
+fn find_thread_id(session_id: &acp::SessionId, cx: &mut VisualTestContext) -> Option<ThreadId> {
+    cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entries()
+            .find(|entry| entry.session_id.as_ref() == Some(session_id))
+            .map(|entry| entry.thread_id)
+    })
+}
+
+fn metadata_for(
+    thread_id: ThreadId,
+    session_id: Option<acp::SessionId>,
+    title: &str,
+    folder: &str,
+    created: Option<u32>,
+    updated: u32,
+) -> ThreadMetadata {
+    let paths = PathList::new(&[PathBuf::from(folder)]);
+    ThreadMetadata {
+        thread_id,
+        session_id,
+        agent_id: agent::ZED_AGENT_ID.clone(),
+        title: Some(SharedString::from(title.to_string())),
+        title_override: None,
+        updated_at: at(updated),
+        created_at: created.map(at),
+        interacted_at: None,
+        worktree_paths: WorktreePaths::from_path_lists(paths.clone(), paths)
+            .expect("fixture: one folder is a valid worktree path list"),
+        archived: false,
+        remote_connection: None,
+    }
+}
+
+fn store_metadata(metadata: ThreadMetadata, cx: &mut VisualTestContext) {
+    cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
+    });
+    cx.run_until_parked();
+}
+
+/// Saves (or re-saves, keeping its id) a thread with a sent message in the local project
+/// `folder`. `updated` is the last activity; `created` is the creation time (`None` like a legacy
+/// row, which falls back to `updated`).
+fn save_thread(
+    session: &str,
+    title: &str,
+    folder: &str,
+    created: Option<u32>,
+    updated: u32,
+    cx: &mut VisualTestContext,
+) -> ThreadId {
+    let session_id = acp::SessionId::new(Arc::from(session));
+    let thread_id = find_thread_id(&session_id, cx).unwrap_or_else(ThreadId::new);
+    store_metadata(
+        metadata_for(thread_id, Some(session_id), title, folder, created, updated),
+        cx,
+    );
+    thread_id
+}
+
+/// A thread created at `hour` and last active at `hour`.
+fn save_in(
+    session: &str,
+    title: &str,
+    folder: &str,
+    hour: u32,
+    cx: &mut VisualTestContext,
+) -> ThreadId {
+    save_thread(session, title, folder, Some(hour), hour, cx)
+}
+
+/// Activity on a thread, the way the sidebar records a sent message.
+fn touch(thread_id: ThreadId, hour: u32, cx: &mut VisualTestContext) {
+    cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+            store.update_interacted_at(&thread_id, at(hour), cx)
+        })
+    });
+    cx.run_until_parked();
+}
+
+fn archive(thread_id: ThreadId, cx: &mut VisualTestContext) {
+    cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(thread_id, None, cx))
+    });
+    cx.run_until_parked();
+}
+
+// ── The store, read and seeded directly ────────────────────────────────────
+
+fn mode(path: &str, cx: &mut VisualTestContext) -> SortMode {
+    cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx)
+            .read(cx)
+            .sort_mode(&ProjectKey::from_group(&local_key(path)))
+    })
+}
+
+fn slot(thread_id: ThreadId, path: &str, cx: &mut VisualTestContext) -> Option<u32> {
+    cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx)
+            .read(cx)
+            .manual_position_in(thread_id, &ProjectKey::from_group(&local_key(path)))
+    })
+}
+
+/// Seeds a Manual order straight into the store (a fixture, not the code under test).
+fn record_order(path: &str, order: &[ThreadId], cx: &mut VisualTestContext) {
+    cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store.set_manual_order(ProjectKey::from_group(&local_key(path)), order, cx)
+        })
+    })
+    .expect("fixture: the store accepts an order of known threads with a message");
+    cx.run_until_parked();
+}
+
+fn store_pin(thread_id: ThreadId, cx: &mut VisualTestContext) {
+    cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| store.pin(thread_id, cx))
+    })
+    .expect("fixture: the store accepts a pin of a known thread with a message");
+    cx.run_until_parked();
+}
+
+fn stored_pins(cx: &mut VisualTestContext) -> Vec<ThreadId> {
+    cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx)
+            .read(cx)
+            .pinned()
+            .collect()
+    })
+}
+
+// ── Reading the sidebar ────────────────────────────────────────────────────
+
+/// `visible_entries_as_strings`, with a pinned row written "  {title} · {project label}" so it
+/// can never be confused with the thread's group row.
+fn lines(sidebar: &Entity<Sidebar>, cx: &mut VisualTestContext) -> Vec<String> {
+    let base = visible_entries_as_strings(sidebar, cx);
+    sidebar.read_with(cx, |sidebar, _| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .zip(base)
+            .map(|(entry, line)| match entry {
+                ListEntry::Thread(thread) => match &thread.pinned_project_label {
+                    Some(label) => format!("  {} · {label}", thread.metadata.display_title()),
+                    None => line,
+                },
+                _ => line,
+            })
+            .collect()
+    })
+}
+
+/// The header lines only, in screen order.
+fn headers(sidebar: &Entity<Sidebar>, cx: &mut VisualTestContext) -> Vec<String> {
+    lines(sidebar, cx)
+        .into_iter()
+        .filter(|line| line.starts_with("v [") || line.starts_with("> ["))
+        .collect()
+}
+
+/// The window's project order as upstream stores it.
+fn window_order(
+    multi_workspace: &Entity<MultiWorkspace>,
+    cx: &mut VisualTestContext,
+) -> Vec<String> {
+    multi_workspace.read_with(cx, |mw, _| {
+        mw.project_group_keys()
+            .iter()
+            .map(|key| {
+                key.path_list()
+                    .paths()
+                    .iter()
+                    .map(|path| path.to_string_lossy().into_owned())
+                    .collect::<Vec<_>>()
+                    .join(",")
+            })
+            .collect()
+    })
+}
+
+fn header_ix(sidebar: &Entity<Sidebar>, path: &str, cx: &mut VisualTestContext) -> usize {
+    let key = local_key(path);
+    sidebar.read_with(cx, |sidebar, _| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .position(|entry| matches!(entry, ListEntry::ProjectHeader { key: k, .. } if *k == key))
+            .expect("the project has a header")
+    })
+}
+
+fn pinned_header_ix(sidebar: &Entity<Sidebar>, cx: &mut VisualTestContext) -> usize {
+    sidebar.read_with(cx, |sidebar, _| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .position(|entry| matches!(entry, ListEntry::PinnedHeader { .. }))
+            .expect("the Pinned header is shown")
+    })
+}
+
+fn group_row_ix(
+    sidebar: &Entity<Sidebar>,
+    thread_id: ThreadId,
+    cx: &mut VisualTestContext,
+) -> usize {
+    sidebar.read_with(cx, |sidebar, _| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .position(|entry| {
+                matches!(entry, ListEntry::Thread(thread)
+                    if thread.metadata.thread_id == thread_id
+                        && thread.pinned_project_label.is_none())
+            })
+            .expect("the thread has a row in its group")
+    })
+}
+
+fn pinned_row_ix(
+    sidebar: &Entity<Sidebar>,
+    thread_id: ThreadId,
+    cx: &mut VisualTestContext,
+) -> usize {
+    sidebar.read_with(cx, |sidebar, _| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .position(|entry| {
+                matches!(entry, ListEntry::Thread(thread)
+                    if thread.metadata.thread_id == thread_id
+                        && thread.pinned_project_label.is_some())
+            })
+            .expect("the thread has a pinned row")
+    })
+}
+
+// ── Driving the handlers directly ──────────────────────────────────────────
+
+/// Drops the group row of `dragged` (payload origin: its own group, `path`) on entry `target_ix`.
+fn drop_group_row(
+    sidebar: &Entity<Sidebar>,
+    dragged: ThreadId,
+    path: &str,
+    target_ix: usize,
+    cx: &mut VisualTestContext,
+) {
+    let from_ix = group_row_ix(sidebar, dragged, cx);
+    let payload = DraggedThreadRow {
+        thread_id: dragged,
+        origin: RowOrigin::Group(local_key(path)),
+        title: "dragged".into(),
+        from_ix,
+    };
+    sidebar.update(cx, |sidebar, cx| {
+        sidebar.drop_thread_row(&payload, target_ix, cx)
+    });
+    cx.run_until_parked();
+}
+
+/// Drops the pinned row of `dragged` on entry `target_ix`.
+fn drop_pinned_row(
+    sidebar: &Entity<Sidebar>,
+    dragged: ThreadId,
+    from_ix: usize,
+    target_ix: usize,
+    cx: &mut VisualTestContext,
+) {
+    let payload = DraggedThreadRow {
+        thread_id: dragged,
+        origin: RowOrigin::Pinned,
+        title: "dragged".into(),
+        from_ix,
+    };
+    sidebar.update(cx, |sidebar, cx| {
+        sidebar.drop_thread_row(&payload, target_ix, cx)
+    });
+    cx.run_until_parked();
+}
+
+/// Drops the header of `dragged` on the header of `target`.
+fn drop_header(sidebar: &Entity<Sidebar>, dragged: &str, target: &str, cx: &mut VisualTestContext) {
+    let from_ix = sidebar.read_with(cx, |sidebar, _| {
+        let key = local_key(dragged);
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .position(|entry| matches!(entry, ListEntry::ProjectHeader { key: k, .. } if *k == key))
+            .unwrap_or(0)
+    });
+    let payload = DraggedProjectHeader {
+        key: local_key(dragged),
+        label: dragged.trim_start_matches('/').to_string().into(),
+        from_ix,
+    };
+    let target = local_key(target);
+    sidebar.update(cx, |sidebar, cx| {
+        sidebar.drop_project_header(&payload, &target, cx)
+    });
+    cx.run_until_parked();
+}
+
+// ── A real pointer ─────────────────────────────────────────────────────────
+
+/// The bounds of entry `ix`, after a frame of the window.
+fn entry_bounds(
+    sidebar: &Entity<Sidebar>,
+    ix: usize,
+    cx: &mut VisualTestContext,
+) -> Bounds<Pixels> {
+    cx.update(|window, _| window.refresh());
+    cx.run_until_parked();
+    sidebar.read_with(cx, |sidebar, _| {
+        sidebar
+            .list_state
+            .bounds_for_item(ix)
+            .expect("the entry is rendered")
+    })
+}
+
+/// Press at `from`, move 6 px (past GPUI's 2 px drag threshold) with the button held, glide to
+/// `to`, release there. Returns whether a drag was active after the first move.
+fn pointer_drag(from: Point<Pixels>, to: Point<Pixels>, cx: &mut VisualTestContext) -> bool {
+    cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
+    cx.simulate_mouse_move(
+        point(from.x, from.y + px(6.)),
+        MouseButton::Left,
+        Modifiers::none(),
+    );
+    let started = cx.update(|_, cx| cx.has_active_drag());
+    let middle = point((from.x + to.x) / 2., (from.y + to.y) / 2.);
+    cx.simulate_mouse_move(middle, MouseButton::Left, Modifiers::none());
+    cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none());
+    cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
+    cx.run_until_parked();
+    started
+}
+
+/// Press at `from` and move past the threshold, then release where it started; returns whether a
+/// drag started. Used where only "is a drag offered here" is asked.
+fn drag_starts_at(from: Point<Pixels>, cx: &mut VisualTestContext) -> bool {
+    pointer_drag(from, from, cx)
+}
+
+fn right_click(position: Point<Pixels>, cx: &mut VisualTestContext) {
+    cx.simulate_mouse_down(position, MouseButton::Right, Modifiers::none());
+    cx.simulate_mouse_up(position, MouseButton::Right, Modifiers::none());
+    cx.run_until_parked();
+}
+
+// ═══ Task 3.1 — pure ordering ══════════════════════════════════════════════
+
+mod pure {
+    use super::*;
+    use organization::{
+        StepDirection, apply_manual_order, group_thread_ids, project_steps, reordered, row_origin,
+    };
+    use pretty_assertions::assert_eq;
+    use std::collections::HashMap;
+
+    fn closed(path: &str) -> ThreadEntryWorkspace {
+        ThreadEntryWorkspace::Closed {
+            folder_paths: PathList::new(&[PathBuf::from(path)]),
+            project_group_key: local_key(path),
+        }
+    }
+
+    fn header(path: &str) -> ListEntry {
+        ListEntry::ProjectHeader {
+            key: local_key(path),
+            label: path.trim_start_matches('/').to_string().into(),
+            highlight_positions: Vec::new(),
+            has_running_threads: false,
+            waiting_thread_count: 0,
+            has_notifications: false,
+            is_active: false,
+            has_threads: true,
+        }
+    }
+
+    fn thread_entry(
+        thread_id: ThreadId,
+        title: &str,
+        created: Option<u32>,
+        updated: u32,
+        interacted: Option<u32>,
+        draft: Option<DraftKind>,
+        pinned_label: Option<&str>,
+    ) -> ListEntry {
+        ListEntry::Thread(Arc::new(ThreadEntry {
+            metadata: ThreadMetadata {
+                thread_id,
+                session_id: match draft {
+                    Some(_) => None,
+                    None => Some(acp::SessionId::new(Arc::from(title))),
+                },
+                agent_id: AgentId::new("zed-agent"),
+                worktree_paths: WorktreePaths::default(),
+                title: Some(title.to_string().into()),
+                title_override: None,
+                updated_at: at(updated),
+                created_at: created.map(at),
+                interacted_at: interacted.map(at),
+                archived: false,
+                remote_connection: None,
+            },
+            icon: IconName::ZedAgent,
+            icon_from_external_svg: None,
+            status: AgentThreadStatus::Completed,
+            workspace: closed(ALPHA),
+            is_live: false,
+            is_background: false,
+            is_title_generating: false,
+            draft,
+            highlight_positions: Vec::new(),
+            worktrees: Vec::new(),
+            diff_stats: DiffStats::default(),
+            pinned_project_label: pinned_label.map(|label| SharedString::from(label.to_string())),
+        }))
+    }
+
+    /// A thread with a message: (id, row).
+    fn row(
+        title: &str,
+        created: Option<u32>,
+        updated: u32,
+        interacted: Option<u32>,
+    ) -> (ThreadId, ListEntry) {
+        let id = ThreadId::new();
+        (
+            id,
+            thread_entry(id, title, created, updated, interacted, None, None),
+        )
+    }
+
+    fn draft(title: &str, kind: DraftKind, created: Option<u32>, updated: u32) -> ListEntry {
+        thread_entry(
+            ThreadId::new(),
+            title,
+            created,
+            updated,
+            None,
+            Some(kind),
+            None,
+        )
+    }
+
+    fn titles(rows: &[ListEntry]) -> Vec<String> {
+        rows.iter()
+            .map(|entry| match entry {
+                ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
+                ListEntry::Terminal(terminal) => terminal.metadata.display_title().to_string(),
+                ListEntry::ProjectHeader { label, .. } => format!("[{label}]"),
+                ListEntry::PinnedHeader { .. } => "Pinned".to_string(),
+            })
+            .collect()
+    }
+
+    // ── reordered (R2.3, R2.10, R4.2) ──────────────────────────────────────
+
+    #[test]
+    fn reordered_refuses_the_own_row_and_missing_ids() {
+        let [a, b, c] = [ThreadId::new(), ThreadId::new(), ThreadId::new()];
+        let ids = [a, b, c];
+        // Hostile first: the own row must be None, never Some(the same order), or the caller
+        // would record it and flip a Recent project to Manual (R2.10).
+        assert_eq!(
+            reordered(&ids, b, b),
+            None,
+            "a drop on the own row is no move"
+        );
+        assert_eq!(
+            reordered(&ids, ThreadId::new(), b),
+            None,
+            "dragged id not in the group"
+        );
+        assert_eq!(
+            reordered(&ids, a, ThreadId::new()),
+            None,
+            "target id not in the group"
+        );
+        assert_eq!(reordered(&[], a, b), None, "an empty group");
+    }
+
+    #[test]
+    fn reordered_places_after_when_moving_down_and_before_when_moving_up() {
+        let [a, b, c, d] = [
+            ThreadId::new(),
+            ThreadId::new(),
+            ThreadId::new(),
+            ThreadId::new(),
+        ];
+        let ids = [a, b, c, d];
+        assert_eq!(
+            reordered(&ids, a, c),
+            Some(vec![b, c, a, d]),
+            "down: right after"
+        );
+        assert_eq!(
+            reordered(&ids, d, b),
+            Some(vec![a, d, b, c]),
+            "up: right before"
+        );
+        // The two converse adjacent moves reach the same order from opposite sides.
+        assert_eq!(
+            reordered(&ids, a, b),
+            Some(vec![b, a, c, d]),
+            "adjacent down"
+        );
+        assert_eq!(reordered(&ids, b, a), Some(vec![b, a, c, d]), "adjacent up");
+        assert_eq!(
+            reordered(&ids, a, d),
+            Some(vec![b, c, d, a]),
+            "first onto last"
+        );
+        assert_eq!(
+            reordered(&ids, d, a),
+            Some(vec![d, a, b, c]),
+            "last onto first"
+        );
+    }
+
+    #[test]
+    fn reordered_is_a_permutation_for_every_pair() {
+        let ids: Vec<ThreadId> = (0..5).map(|_| ThreadId::new()).collect();
+        for &dragged in &ids {
+            for &target in &ids {
+                let Some(order) = reordered(&ids, dragged, target) else {
+                    assert_eq!(dragged, target, "only the own row yields None");
+                    continue;
+                };
+                assert_eq!(order.len(), ids.len(), "no thread is lost or duplicated");
+                for id in &ids {
+                    assert_eq!(order.iter().filter(|x| *x == id).count(), 1);
+                }
+                let at_target = order.iter().position(|x| *x == target);
+                let at_dragged = order.iter().position(|x| *x == dragged);
+                let was_above =
+                    ids.iter().position(|x| *x == dragged) < ids.iter().position(|x| *x == target);
+                if was_above {
+                    assert_eq!(at_dragged, at_target.map(|t| t + 1), "moved down: after");
+                } else {
+                    assert_eq!(at_dragged.map(|d| d + 1), at_target, "moved up: before");
+                }
+            }
+        }
+    }
+
+    // ── project_steps (R1.1, R1.2) ─────────────────────────────────────────
+
+    #[test]
+    fn project_steps_counts_single_swaps_to_the_targets_former_index() {
+        let keys = [
+            local_key("/k0"),
+            local_key("/k1"),
+            local_key("/k2"),
+            local_key("/k3"),
+        ];
+        // Hostile first: self and missing keys move nothing.
+        assert!(
+            project_steps(&keys, &keys[1], &keys[1]).is_none(),
+            "own header"
+        );
+        assert!(
+            project_steps(&keys, &local_key(DELTA), &keys[1]).is_none(),
+            "dragged not in window"
+        );
+        assert!(
+            project_steps(&keys, &keys[1], &local_key(DELTA)).is_none(),
+            "target not in window"
+        );
+        assert!(matches!(
+            project_steps(&keys, &keys[0], &keys[2]),
+            Some((StepDirection::Down, 2))
+        ));
+        assert!(matches!(
+            project_steps(&keys, &keys[3], &keys[1]),
+            Some((StepDirection::Up, 2))
+        ));
+        assert!(matches!(
+            project_steps(&keys, &keys[1], &keys[2]),
+            Some((StepDirection::Down, 1))
+        ));
+        assert!(matches!(
+            project_steps(&keys, &keys[2], &keys[1]),
+            Some((StepDirection::Up, 1))
+        ));
+        assert!(matches!(
+            project_steps(&keys, &keys[0], &keys[3]),
+            Some((StepDirection::Down, 3))
+        ));
+    }
+
+    #[test]
+    fn applying_the_steps_lands_on_the_targets_former_index_for_every_pair() {
+        let keys: Vec<ProjectGroupKey> = (0..4).map(|i| local_key(&format!("/k{i}"))).collect();
+        for from in 0..keys.len() {
+            for to in 0..keys.len() {
+                let steps = project_steps(&keys, &keys[from], &keys[to]);
+                if from == to {
+                    assert!(steps.is_none());
+                    continue;
+                }
+                let Some((direction, n)) = steps else {
+                    panic!("{from} -> {to} must move");
+                };
+                let mut order = keys.clone();
+                let mut ix = from;
+                for _ in 0..n {
+                    match direction {
+                        StepDirection::Up => {
+                            order.swap(ix - 1, ix);
+                            ix -= 1;
+                        }
+                        StepDirection::Down => {
+                            order.swap(ix, ix + 1);
+                            ix += 1;
+                        }
+                    }
+                }
+                assert_eq!(
+                    order[to], keys[from],
+                    "{from} -> {to} lands at the target's index"
+                );
+            }
+        }
+    }
+
+    // ── group_thread_ids and row_origin (R2.2, R5.3, R5.4) ─────────────────
+
+    /// [alpha] a1, empty draft, a2 | [beta] b1 | Pinned a1·alpha | (indices 0..=7)
+    fn two_groups_and_pinned() -> (Vec<ListEntry>, ThreadId, ThreadId, ThreadId) {
+        let (a1, a1_row) = row("a1", Some(3), 3, None);
+        let (a2, a2_row) = row("a2", Some(1), 1, None);
+        let (b1, b1_row) = row("b1", Some(2), 2, None);
+        let entries = vec![
+            header(ALPHA),
+            a1_row,
+            draft("draft", DraftKind::Empty, None, 9),
+            a2_row,
+            header(BETA),
+            b1_row,
+            ListEntry::PinnedHeader {
+                has_notifications: false,
+            },
+            thread_entry(a1, "a1", Some(3), 3, None, None, Some("alpha")),
+        ];
+        (entries, a1, a2, b1)
+    }
+
+    #[test]
+    fn group_thread_ids_stays_inside_one_group_and_skips_drafts() {
+        let (entries, a1, a2, b1) = two_groups_and_pinned();
+        // Hostile first: a row of beta never collects alpha's rows, and a pinned row never
+        // collects the group above the section (beta).
+        assert_eq!(
+            group_thread_ids(&entries, 5),
+            vec![b1],
+            "beta's row: beta only"
+        );
+        assert_eq!(
+            group_thread_ids(&entries, 7),
+            Vec::<ThreadId>::new(),
+            "pinned row"
+        );
+        assert_eq!(
+            group_thread_ids(&entries, 6),
+            Vec::<ThreadId>::new(),
+            "Pinned header"
+        );
+        assert_eq!(
+            group_thread_ids(&entries, 3),
+            vec![a1, a2],
+            "screen order, no draft"
+        );
+        assert_eq!(
+            group_thread_ids(&entries, 0),
+            vec![a1, a2],
+            "from the header itself"
+        );
+    }
+
+    #[test]
+    fn row_origin_tells_one_threads_two_rows_apart() {
+        let (entries, _a1, _a2, _b1) = two_groups_and_pinned();
+        // Hostile first (wrongly collapse): the same thread a1, as a group row and as a pinned
+        // row, has two DIFFERENT origins; adjacent rows of two groups differ too.
+        assert_ne!(row_origin(&entries, 1), row_origin(&entries, 7));
+        assert_ne!(row_origin(&entries, 3), row_origin(&entries, 5));
+        // Hostile second (wrongly split): two keys built from the same path are the SAME origin.
+        assert_eq!(
+            row_origin(&entries, 1),
+            Some(RowOrigin::Group(local_key(ALPHA)))
+        );
+        assert_eq!(
+            row_origin(&entries, 3),
+            Some(RowOrigin::Group(local_key(ALPHA)))
+        );
+        assert_eq!(
+            row_origin(&entries, 5),
+            Some(RowOrigin::Group(local_key(BETA)))
+        );
+        assert_eq!(row_origin(&entries, 7), Some(RowOrigin::Pinned));
+        assert_eq!(
+            row_origin(&entries, 99),
+            None,
+            "a stale index has no origin"
+        );
+    }
+
+    #[test]
+    fn a_project_named_pinned_is_a_group_not_the_pinned_section() {
+        // A third element against the derived origin: a project whose folder (and label) is
+        // "Pinned" must never be read as the Pinned section.
+        let (p1, p1_row) = row("p1", Some(1), 1, None);
+        let entries = vec![header("/Pinned"), p1_row];
+        assert_eq!(
+            row_origin(&entries, 1),
+            Some(RowOrigin::Group(local_key("/Pinned")))
+        );
+        assert_eq!(group_thread_ids(&entries, 1), vec![p1]);
+    }
+
+    // ── apply_manual_order (R2.4, R2.5, R2.6) ──────────────────────────────
+
+    #[test]
+    fn manual_order_ignores_activity_in_both_bands() {
+        // Hostile first: the slotted thread with the newest activity stays at its slot, and the
+        // unslotted thread with the newest activity stays at its creation place.
+        let (first, first_row) = row("Slot 0, idle", Some(1), 1, None);
+        let (second, second_row) = row("Slot 1, busy", Some(2), 2, Some(23));
+        let (_, busy_new) = row("Unslotted old, busy", Some(4), 4, Some(23));
+        let (_, idle_new) = row("Unslotted new, idle", Some(5), 5, None);
+        let slots = HashMap::from([(first, 0), (second, 1)]);
+        let mut rows = vec![second_row, busy_new, first_row, idle_new];
+        apply_manual_order(&mut rows, &slots);
+        assert_eq!(
+            titles(&rows),
+            vec![
+                "Unslotted new, idle",
+                "Unslotted old, busy",
+                "Slot 0, idle",
+                "Slot 1, busy"
+            ]
+        );
+    }
+
+    #[test]
+    fn manual_order_has_three_bands() {
+        let (s0, s0_row) = row("S0", Some(9), 9, None);
+        let (s1, s1_row) = row("S1", Some(8), 8, None);
+        let (_, old_row) = row("Unslotted old", Some(1), 1, None);
+        let (_, new_row) = row("Unslotted new", Some(5), 5, None);
+        let mut rows = vec![
+            s1_row,
+            old_row,
+            draft("Draft with text", DraftKind::WithContent, Some(4), 4),
+            s0_row,
+            draft("Empty draft", DraftKind::Empty, None, 0),
+            new_row,
+        ];
+        let slots = HashMap::from([(s0, 0), (s1, 1)]);
+        apply_manual_order(&mut rows, &slots);
+        assert_eq!(
+            titles(&rows),
+            vec![
+                "Empty draft",
+                "Unslotted new",
+                "Draft with text",
+                "Unslotted old",
+                "S0",
+                "S1"
+            ],
+            "empty draft, then no-slot rows newest created first, then slots ascending"
+        );
+    }
+
+    #[test]
+    fn a_new_thread_with_the_oldest_timestamp_still_lands_above_the_slotted_ones() {
+        let (s0, s0_row) = row("S0", Some(10), 10, None);
+        let (s1, s1_row) = row("S1", Some(11), 11, None);
+        let (_, fresh_row) = row("New but dated", Some(0), 0, None);
+        let mut rows = vec![s0_row, s1_row, fresh_row];
+        apply_manual_order(&mut rows, &HashMap::from([(s0, 0), (s1, 1)]));
+        assert_eq!(titles(&rows), vec!["New but dated", "S0", "S1"]);
+    }
+
+    #[test]
+    fn creation_time_falls_back_to_updated_at() {
+        let (_, legacy) = row("Legacy, updated 7", None, 7, None);
+        let (_, created) = row("Created 6", Some(6), 6, None);
+        let mut rows = vec![created, legacy];
+        apply_manual_order(&mut rows, &HashMap::new());
+        assert_eq!(titles(&rows), vec!["Legacy, updated 7", "Created 6"]);
+    }
+
+    #[test]
+    fn manual_order_is_stable_and_ignores_slots_of_absent_threads() {
+        let (x, x_row) = row("X", Some(1), 1, None);
+        let (y, y_row) = row("Y", Some(2), 2, None);
+        // A tie the store never produces (slots are dense) keeps the input order, either way.
+        let tie = HashMap::from([(x, 0), (y, 0), (ThreadId::new(), 1)]);
+        let mut forward = vec![x_row.clone(), y_row.clone()];
+        apply_manual_order(&mut forward, &tie);
+        assert_eq!(titles(&forward), vec!["X", "Y"]);
+        let mut backward = vec![y_row, x_row];
+        apply_manual_order(&mut backward, &tie);
+        assert_eq!(titles(&backward), vec!["Y", "X"]);
+    }
+
+    // ── payloads ───────────────────────────────────────────────────────────
+
+    #[test]
+    #[allow(clippy::redundant_clone, reason = "the test exercises Clone itself")]
+    fn payloads_clone_with_their_fields() {
+        let id = ThreadId::new();
+        let row = DraggedThreadRow {
+            thread_id: id,
+            origin: RowOrigin::Pinned,
+            title: "t".into(),
+            from_ix: 4,
+        };
+        let copy = row.clone();
+        assert_eq!(
+            (copy.thread_id, copy.origin, copy.from_ix),
+            (id, RowOrigin::Pinned, 4)
+        );
+        let header = DraggedProjectHeader {
+            key: local_key(ALPHA),
+            label: "alpha".into(),
+            from_ix: 2,
+        };
+        assert_eq!(header.clone().key, local_key(ALPHA));
+    }
+}
+
+// ═══ Task 4.1 — Manual order in the list ═══════════════════════════════════
+
+mod manual_order {
+    use super::*;
+    use organization::{manual_slots, sort_mode_of};
+    use pretty_assertions::assert_eq;
+
+    /// alpha: Newest (3), Middle (2), Oldest (1).
+    async fn alpha_three(
+        cx: &mut TestAppContext,
+    ) -> (
+        Arc<FakeFs>,
+        Entity<MultiWorkspace>,
+        Entity<Sidebar>,
+        [ThreadId; 3],
+        &mut VisualTestContext,
+    ) {
+        let (fs, mw, sidebar, cx) = alpha_window(cx).await;
+        let newest = save_in("n", "Newest", ALPHA, 3, cx);
+        let middle = save_in("m", "Middle", ALPHA, 2, cx);
+        let oldest = save_in("o", "Oldest", ALPHA, 1, cx);
+        (fs, mw, sidebar, [newest, middle, oldest], cx)
+    }
+
+    // R2.4 (hostile) and R2.1 (its converse): the same activity that reorders a Recent project
+    // keeps every row of a Manual project in place.
+    #[gpui::test]
+    async fn activity_reorders_recent_but_not_manual(cx: &mut TestAppContext) {
+        let (fs, mw, sidebar, [newest, middle, oldest], cx) = alpha_three(cx).await;
+        open_in_window(BETA, &fs, &mw, cx).await;
+        save_in("bn", "Beta newest", BETA, 3, cx);
+        let b_old = save_in("bo", "Beta oldest", BETA, 1, cx);
+        record_order(ALPHA, &[oldest, newest, middle], cx);
+
+        // Hostile: a message to alpha's last row (re-saved later AND interacted with).
+        save_thread("m", "Middle", ALPHA, Some(2), 23, cx);
+        touch(middle, 23, cx);
+        // Converse control: the same activity on beta's last row.
+        save_thread("bo", "Beta oldest", BETA, Some(1), 23, cx);
+        touch(b_old, 23, cx);
+
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec![
+                "v [beta]",
+                "  Beta oldest",
+                "  Beta newest",
+                "v [alpha]",
+                "  Oldest",
+                "  Newest",
+                "  Middle",
+            ],
+            "Manual alpha holds its order; Recent beta moves its active thread to the top"
+        );
+    }
+
+    // R2.5 (hostile): a new thread lands above every slotted one, even with the oldest timestamp.
+    #[gpui::test]
+    async fn new_thread_in_manual_lands_above_every_slotted_thread(cx: &mut TestAppContext) {
+        let (_fs, _mw, sidebar, [newest, middle, oldest], cx) = alpha_three(cx).await;
+        record_order(ALPHA, &[oldest, middle, newest], cx);
+
+        save_in("f", "Fresh", ALPHA, 0, cx);
+
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec!["v [alpha]", "  Fresh", "  Oldest", "  Middle", "  Newest"]
+        );
+    }
+
+    // R2.6: rows without a place, newest CREATED first, activity ignored among them too.
+    #[gpui::test]
+    async fn unslotted_rows_follow_creation_not_activity(cx: &mut TestAppContext) {
+        let (_fs, _mw, sidebar, [newest, middle, oldest], cx) = alpha_three(cx).await;
+        record_order(ALPHA, &[newest, middle, oldest], cx);
+        let new_a = save_thread("na", "New A", ALPHA, Some(5), 5, cx);
+        save_thread("nb", "New B", ALPHA, Some(6), 6, cx);
+
+        // Hostile: activity on the older of the two unslotted threads.
+        save_thread("na", "New A", ALPHA, Some(5), 23, cx);
+        touch(new_a, 23, cx);
+
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec![
+                "v [alpha]",
+                "  New B",
+                "  New A",
+                "  Newest",
+                "  Middle",
+                "  Oldest"
+            ]
+        );
+    }
+
+    // R2.6: an empty draft first, then a terminal and a new thread by creation, then the slots.
+    #[gpui::test]
+    async fn manual_bands_with_an_empty_draft_and_a_terminal(cx: &mut TestAppContext) {
+        let project = init_test_project_with_agent_panel("/my-project", cx).await;
+        cx.update(|cx| agent_ui::thread_organization::init(cx));
+        cx.run_until_parked();
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+        let first = save_in("s0", "Slot zero", "/my-project", 20, cx);
+        let second = save_in("s1", "Slot one", "/my-project", 21, cx);
+        record_order("/my-project", &[first, second], cx);
+        save_in("u", "Unslotted", "/my-project", 1, cx);
+        // Created now: newer than every 2024 thread.
+        panel
+            .update_in(cx, |panel, window, cx| {
+                panel.insert_test_terminal("Dev Server", false, window, cx)
+            })
+            .expect("fixture: the terminal is inserted");
+        // `insert_test_terminal` makes Terminal the panel's last-created kind, so `NewThread`
+        // would open a second terminal; the agent-draft entry point opens the empty draft.
+        panel.update_in(cx, |panel, window, cx| {
+            panel.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx)
+        });
+        cx.run_until_parked();
+
+        let shape = sidebar.read_with(cx, |sidebar, _| {
+            sidebar
+                .contents
+                .entries
+                .iter()
+                .map(|entry| match entry {
+                    ListEntry::ProjectHeader { label, .. } => format!("[{label}]"),
+                    ListEntry::Thread(thread) if thread.draft == Some(DraftKind::Empty) => {
+                        "<empty draft>".to_string()
+                    }
+                    ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
+                    ListEntry::Terminal(terminal) => terminal.metadata.display_title().to_string(),
+                    ListEntry::PinnedHeader { .. } => "Pinned".to_string(),
+                })
+                .collect::<Vec<_>>()
+        });
+        assert!(
+            shape.contains(&"<empty draft>".to_string()),
+            "fixture: new_thread shows an empty draft: {shape:?}"
+        );
+        assert_eq!(
+            shape,
+            vec![
+                "[my-project]",
+                "<empty draft>",
+                "Dev Server",
+                "Unslotted",
+                "Slot zero",
+                "Slot one"
+            ],
+            "a terminal never sinks below the slotted threads"
+        );
+    }
+
+    // R2.1, R2.12: without the store there is no slot map and no mode, and activity orders.
+    #[gpui::test]
+    async fn no_store_means_no_slots_and_recent_order(cx: &mut TestAppContext) {
+        let (_fs, project) = init_org_test(false, cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+        let older = save_in("o", "Older", ALPHA, 1, cx);
+        save_in("n", "Newer", ALPHA, 2, cx);
+        touch(older, 23, cx);
+
+        assert_eq!(
+            cx.update(|_, cx| manual_slots(&local_key(ALPHA), &[older], cx)),
+            None,
+            "no store: no slot map"
+        );
+        assert_eq!(cx.update(|_, cx| sort_mode_of(&local_key(ALPHA), cx)), None);
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec!["v [alpha]", "  Older", "  Newer"],
+            "no store: activity still orders (Recent)"
+        );
+    }
+
+    #[gpui::test]
+    async fn manual_slots_only_for_a_manual_project(cx: &mut TestAppContext) {
+        let (fs, mw, _sidebar, [newest, middle, oldest], cx) = alpha_three(cx).await;
+        open_in_window(BETA, &fs, &mw, cx).await;
+        save_in("b", "Beta", BETA, 1, cx);
+        record_order(ALPHA, &[oldest, newest, middle], cx);
+        // Hostile first: beta, Recent, gets None even though alpha is Manual.
+        assert_eq!(
+            cx.update(|_, cx| manual_slots(&local_key(BETA), &[oldest, newest, middle], cx)),
+            None
+        );
+        assert_eq!(
+            cx.update(|_, cx| sort_mode_of(&local_key(BETA), cx)),
+            Some(SortMode::Recent)
+        );
+        assert_eq!(
+            cx.update(|_, cx| sort_mode_of(&local_key(ALPHA), cx)),
+            Some(SortMode::Manual)
+        );
+        let slots = cx
+            .update(|_, cx| manual_slots(&local_key(ALPHA), &[oldest, newest, middle], cx))
+            .expect("a Manual project has a slot map");
+        assert_eq!(
+            (slots.get(&oldest), slots.get(&newest), slots.get(&middle)),
+            (Some(&0), Some(&1), Some(&2))
+        );
+    }
+
+    // R2.7: a restart (a new store over the same database, a new window) shows the same order.
+    #[gpui::test]
+    async fn manual_order_survives_a_restart(cx: &mut TestAppContext) {
+        let (_fs, project) = init_org_test(true, cx).await;
+        let (multi_workspace, cx_a) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx_a);
+        let newest = save_in("n", "Newest", ALPHA, 3, cx_a);
+        save_in("m", "Middle", ALPHA, 2, cx_a);
+        let oldest = save_in("o", "Oldest", ALPHA, 1, cx_a);
+        let target = group_row_ix(&sidebar, newest, cx_a);
+        drop_group_row(&sidebar, oldest, ALPHA, target, cx_a);
+        let before = lines(&sidebar, cx_a);
+        assert_eq!(
+            before,
+            vec!["v [alpha]", "  Oldest", "  Newest", "  Middle"]
+        );
+
+        cx.run_until_parked();
+        cx.update(|cx| agent_ui::thread_organization::init(cx));
+        cx.run_until_parked();
+        let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
+        let reopened = project::Project::test(fs, [ALPHA.as_ref()], cx).await;
+        let (multi_workspace, cx_b) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(reopened, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx_b);
+
+        assert_eq!(lines(&sidebar, cx_b), before);
+    }
+
+    // R2.8: an order recorded in window A shows in window B, which never received the drop.
+    #[gpui::test]
+    async fn manual_order_shows_in_every_window(cx: &mut TestAppContext) {
+        let (_fs, project_a) = init_org_test(true, cx).await;
+        let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
+        let project_b = project::Project::test(fs, [ALPHA.as_ref()], cx).await;
+        let window_a = cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+        let window_b = cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
+        let mw_a = window_a.root(cx).unwrap();
+        let mw_b = window_b.root(cx).unwrap();
+        let cx_b = &mut VisualTestContext::from_window(window_b.into(), cx);
+        let sidebar_b = setup_sidebar(&mw_b, cx_b);
+        let cx_a = &mut VisualTestContext::from_window(window_a.into(), cx);
+        let sidebar_a = setup_sidebar(&mw_a, cx_a);
+        let newest = save_in("n", "Newest", ALPHA, 3, cx_a);
+        save_in("m", "Middle", ALPHA, 2, cx_a);
+        let oldest = save_in("o", "Oldest", ALPHA, 1, cx_a);
+        cx_b.run_until_parked();
+
+        let target = group_row_ix(&sidebar_a, newest, cx_a);
+        drop_group_row(&sidebar_a, oldest, ALPHA, target, cx_a);
+        cx_b.run_until_parked();
+
+        let expected = vec!["v [alpha]", "  Oldest", "  Newest", "  Middle"];
+        assert_eq!(lines(&sidebar_a, cx_a), expected);
+        assert_eq!(
+            lines(&sidebar_b, cx_b),
+            expected,
+            "the other window rebuilds from the store, with no event of its own"
+        );
+    }
+}
+
+// ═══ Task 5.1 — drag a project ═════════════════════════════════════════════
+
+mod project_drag {
+    use super::*;
+    use pretty_assertions::assert_eq;
+
+    /// One window with alpha, beta, gamma (shown gamma, beta, alpha: newest opened first), one
+    /// thread each so every header has rows.
+    async fn three_projects(
+        cx: &mut TestAppContext,
+    ) -> (
+        Entity<MultiWorkspace>,
+        Entity<Sidebar>,
+        &mut VisualTestContext,
+    ) {
+        let (fs, mw, sidebar, cx) = alpha_window(cx).await;
+        open_in_window(BETA, &fs, &mw, cx).await;
+        open_in_window(GAMMA, &fs, &mw, cx).await;
+        save_in("a", "Alpha thread", ALPHA, 1, cx);
+        save_in("b", "Beta thread", BETA, 1, cx);
+        save_in("g", "Gamma thread", GAMMA, 1, cx);
+        assert_eq!(
+            headers(&sidebar, cx),
+            vec!["v [gamma]", "v [beta]", "v [alpha]"],
+            "fixture: newest opened first"
+        );
+        (mw, sidebar, cx)
+    }
+
+    // R1.2 (hostile first) then R1.1 in both directions and adjacent.
+    #[gpui::test]
+    async fn drop_on_a_header_takes_its_place(cx: &mut TestAppContext) {
+        let (mw, sidebar, cx) = three_projects(cx).await;
+
+        drop_header(&sidebar, BETA, BETA, cx);
+        assert_eq!(
+            headers(&sidebar, cx),
+            vec!["v [gamma]", "v [beta]", "v [alpha]"],
+            "own header"
+        );
+        assert_eq!(window_order(&mw, cx), vec![GAMMA, BETA, ALPHA]);
+
+        drop_header(&sidebar, ALPHA, GAMMA, cx);
+        assert_eq!(
+            headers(&sidebar, cx),
+            vec!["v [alpha]", "v [gamma]", "v [beta]"],
+            "up two: alpha at gamma's former place, the others shifted by one"
+        );
+        assert_eq!(window_order(&mw, cx), vec![ALPHA, GAMMA, BETA]);
+
+        drop_header(&sidebar, ALPHA, BETA, cx);
+        assert_eq!(
+            headers(&sidebar, cx),
+            vec!["v [gamma]", "v [beta]", "v [alpha]"],
+            "down two"
+        );
+
+        drop_header(&sidebar, BETA, GAMMA, cx);
+        assert_eq!(
+            headers(&sidebar, cx),
+            vec!["v [beta]", "v [gamma]", "v [alpha]"],
+            "adjacent up"
+        );
+        assert_eq!(window_order(&mw, cx), vec![BETA, GAMMA, ALPHA]);
+    }
+
+    // R1.4: Move Up/Down still move one place, from wherever a drag left the project.
+    #[gpui::test]
+    async fn move_down_still_moves_one_place_after_a_drag(cx: &mut TestAppContext) {
+        let (mw, sidebar, cx) = three_projects(cx).await;
+        drop_header(&sidebar, ALPHA, GAMMA, cx);
+        assert_eq!(window_order(&mw, cx), vec![ALPHA, GAMMA, BETA]);
+
+        let ix = header_ix(&sidebar, ALPHA, cx);
+        right_click(entry_bounds(&sidebar, ix, cx).center(), cx);
+        let item = cx
+            .debug_bounds("MENU_ITEM-Move Down")
+            .expect("the header menu offers Move Down");
+        cx.simulate_click(item.center(), Modifiers::none());
+        cx.run_until_parked();
+
+        assert_eq!(window_order(&mw, cx), vec![GAMMA, ALPHA, BETA]);
+        assert_eq!(
+            headers(&sidebar, cx),
+            vec!["v [gamma]", "v [alpha]", "v [beta]"]
+        );
+    }
+
+    // R1.7 (reachable half): a dragged group no longer in the window moves nothing, and nothing
+    // logged names a folder.
+    #[gpui::test]
+    async fn a_group_gone_from_the_window_moves_nothing(cx: &mut TestAppContext) {
+        let (mw, sidebar, cx) = three_projects(cx).await;
+        capture_logs();
+
+        drop_header(&sidebar, DELTA, GAMMA, cx);
+        drop_header(&sidebar, GAMMA, DELTA, cx);
+
+        assert_eq!(window_order(&mw, cx), vec![GAMMA, BETA, ALPHA]);
+        assert_eq!(
+            headers(&sidebar, cx),
+            vec!["v [gamma]", "v [beta]", "v [alpha]"]
+        );
+        for line in warn_lines() {
+            for path in [ALPHA, BETA, GAMMA, DELTA] {
+                assert!(
+                    !line.contains(path),
+                    "a warning never names a folder: {line}"
+                );
+            }
+        }
+    }
+
+    // R1.6: a drag in window A leaves window B's order (the same projects) untouched.
+    #[gpui::test]
+    async fn a_drag_in_one_window_leaves_the_other_window_order(cx: &mut TestAppContext) {
+        let (fs, project_a) = init_org_test(true, cx).await;
+        let project_b = project::Project::test(fs.clone(), [ALPHA.as_ref()], cx).await;
+        let window_a = cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+        let window_b = cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
+        let mw_a = window_a.root(cx).unwrap();
+        let mw_b = window_b.root(cx).unwrap();
+        let cx_b = &mut VisualTestContext::from_window(window_b.into(), cx);
+        let sidebar_b = setup_sidebar(&mw_b, cx_b);
+        open_in_window(BETA, &fs, &mw_b, cx_b).await;
+        let cx_a = &mut VisualTestContext::from_window(window_a.into(), cx);
+        let sidebar_a = setup_sidebar(&mw_a, cx_a);
+        open_in_window(BETA, &fs, &mw_a, cx_a).await;
+        save_in("a", "Alpha thread", ALPHA, 1, cx_a);
+        save_in("b", "Beta thread", BETA, 1, cx_a);
+        cx_b.run_until_parked();
+        assert_eq!(window_order(&mw_b, cx_b), vec![BETA, ALPHA], "fixture");
+
+        drop_header(&sidebar_a, ALPHA, BETA, cx_a);
+        cx_b.run_until_parked();
+
+        assert_eq!(window_order(&mw_a, cx_a), vec![ALPHA, BETA]);
+        assert_eq!(
+            window_order(&mw_b, cx_b),
+            vec![BETA, ALPHA],
+            "window B keeps its own order"
+        );
+        assert_eq!(headers(&sidebar_b, cx_b), vec!["v [beta]", "v [alpha]"]);
+    }
+
+    // R1.5: the dragged order is what upstream persists for this window (multi_workspace_state),
+    // which is what a restart reads. Rung: the stored record; a real relaunch is the E2E's.
+    #[gpui::test]
+    async fn the_dragged_order_is_persisted_for_the_window(cx: &mut TestAppContext) {
+        let (_mw, sidebar, cx) = three_projects(cx).await;
+
+        drop_header(&sidebar, ALPHA, GAMMA, cx);
+        cx.run_until_parked();
+
+        let window_id = cx.update(|window, _| window.window_handle().window_id());
+        let json = cx
+            .update(|_, cx| {
+                db::kvp::KeyValueStore::global(cx)
+                    .scoped("multi_workspace_state")
+                    .read(&window_id.as_u64().to_string())
+            })
+            .expect("the window state is readable")
+            .expect("the window state was written");
+        let position = |path: &str| {
+            json.find(path)
+                .unwrap_or_else(|| panic!("{path} is in the stored state: {json}"))
+        };
+        assert!(
+            position(ALPHA) < position(GAMMA) && position(GAMMA) < position(BETA),
+            "stored as alpha, gamma, beta: {json}"
+        );
+    }
+}
+
+// ═══ Task 6.1 — drag a thread inside its group ═════════════════════════════
+
+mod thread_drag {
+    use super::*;
+    use pretty_assertions::assert_eq;
+
+    async fn alpha_and_beta(
+        cx: &mut TestAppContext,
+    ) -> (
+        Entity<Sidebar>,
+        [ThreadId; 3],
+        ThreadId,
+        &mut VisualTestContext,
+    ) {
+        let (fs, mw, sidebar, cx) = alpha_window(cx).await;
+        open_in_window(BETA, &fs, &mw, cx).await;
+        let newest = save_in("n", "Newest", ALPHA, 3, cx);
+        let middle = save_in("m", "Middle", ALPHA, 2, cx);
+        let oldest = save_in("o", "Oldest", ALPHA, 1, cx);
+        let beta = save_in("b", "Beta thread", BETA, 1, cx);
+        (sidebar, [newest, middle, oldest], beta, cx)
+    }
+
+    // R2.10 (hostile first): the own row changes neither the order nor the MODE.
+    #[gpui::test]
+    async fn drop_on_the_own_row_changes_nothing_not_even_the_mode(cx: &mut TestAppContext) {
+        let (sidebar, [_newest, middle, _oldest], _beta, cx) = alpha_and_beta(cx).await;
+        let before = lines(&sidebar, cx);
+
+        let own = group_row_ix(&sidebar, middle, cx);
+        drop_group_row(&sidebar, middle, ALPHA, own, cx);
+
+        assert_eq!(
+            mode(ALPHA, cx),
+            SortMode::Recent,
+            "a Recent project stays Recent"
+        );
+        assert_eq!(slot(middle, ALPHA, cx), None);
+        assert_eq!(lines(&sidebar, cx), before);
+    }
+
+    // R2.2, R2.3: up places before, down places after; the WHOLE group is recorded (a thread not
+    // involved keeps a slot) and the project turns Manual.
+    #[gpui::test]
+    async fn drop_records_the_whole_group_and_turns_it_manual(cx: &mut TestAppContext) {
+        let (sidebar, [newest, middle, oldest], beta, cx) = alpha_and_beta(cx).await;
+
+        let target = group_row_ix(&sidebar, newest, cx);
+        drop_group_row(&sidebar, oldest, ALPHA, target, cx);
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec![
+                "v [beta]",
+                "  Beta thread",
+                "v [alpha]",
+                "  Oldest",
+                "  Newest",
+                "  Middle"
+            ],
+            "dropped on a row above: right before it"
+        );
+        assert_eq!(mode(ALPHA, cx), SortMode::Manual);
+        assert_eq!(
+            (
+                slot(oldest, ALPHA, cx),
+                slot(newest, ALPHA, cx),
+                slot(middle, ALPHA, cx)
+            ),
+            (Some(0), Some(1), Some(2)),
+            "the uninvolved thread keeps a place: the whole group was recorded"
+        );
+        assert_eq!(
+            mode(BETA, cx),
+            SortMode::Recent,
+            "the other project is untouched"
+        );
+        assert_eq!(slot(beta, BETA, cx), None);
+
+        let target = group_row_ix(&sidebar, middle, cx);
+        drop_group_row(&sidebar, oldest, ALPHA, target, cx);
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec![
+                "v [beta]",
+                "  Beta thread",
+                "v [alpha]",
+                "  Newest",
+                "  Middle",
+                "  Oldest"
+            ],
+            "dropped on a row below: right after it"
+        );
+    }
+
+    // R5.3 (hostile): another group's row, the group's header, a pinned row, a stale index.
+    #[gpui::test]
+    async fn drop_outside_the_own_group_changes_nothing(cx: &mut TestAppContext) {
+        let (sidebar, [newest, middle, oldest], beta, cx) = alpha_and_beta(cx).await;
+        store_pin(middle, cx);
+        let before = lines(&sidebar, cx);
+
+        let beta_row = group_row_ix(&sidebar, beta, cx);
+        let alpha_header = header_ix(&sidebar, ALPHA, cx);
+        let pinned_row = pinned_row_ix(&sidebar, middle, cx);
+        drop_group_row(&sidebar, oldest, ALPHA, beta_row, cx);
+        drop_group_row(&sidebar, oldest, ALPHA, alpha_header, cx);
+        drop_group_row(&sidebar, newest, ALPHA, pinned_row, cx);
+        drop_group_row(&sidebar, oldest, ALPHA, 9_999, cx);
+
+        assert_eq!(lines(&sidebar, cx), before);
+        assert_eq!(
+            (mode(ALPHA, cx), mode(BETA, cx)),
+            (SortMode::Recent, SortMode::Recent)
+        );
+        assert_eq!(
+            slot(oldest, BETA, cx),
+            None,
+            "a thread never moves to another project"
+        );
+        assert_eq!(stored_pins(cx), vec![middle], "a drop never pins or unpins");
+    }
+
+    // R2.11: a refused order changes nothing and warns with the count and the refusal, never a
+    // title or a path. The refusal comes from a row the metadata store does not know.
+    #[gpui::test]
+    async fn refused_order_changes_nothing_and_warns_without_titles(cx: &mut TestAppContext) {
+        let (sidebar, [newest, _middle, oldest], _beta, cx) = alpha_and_beta(cx).await;
+        let before = lines(&sidebar, cx);
+        let unknown = ThreadId::new();
+        capture_logs();
+
+        let from_ix = group_row_ix(&sidebar, oldest, cx);
+        let target_ix = group_row_ix(&sidebar, newest, cx);
+        sidebar.update(cx, |sidebar, cx| {
+            let ListEntry::Thread(template) = &sidebar.contents.entries[from_ix] else {
+                panic!("fixture: the oldest row is a thread row");
+            };
+            let mut ghost = (**template).clone();
+            ghost.metadata.thread_id = unknown;
+            ghost.metadata.title = Some("Secret plan title".into());
+            sidebar
+                .contents
+                .entries
+                .insert(from_ix + 1, ListEntry::Thread(Arc::new(ghost)));
+            let payload = DraggedThreadRow {
+                thread_id: oldest,
+                origin: RowOrigin::Group(local_key(ALPHA)),
+                title: "Oldest".into(),
+                from_ix,
+            };
+            sidebar.drop_thread_row(&payload, target_ix, cx);
+        });
+        cx.run_until_parked();
+
+        assert_eq!(mode(ALPHA, cx), SortMode::Recent, "nothing recorded");
+        assert_eq!(slot(oldest, ALPHA, cx), None);
+        assert_eq!(lines(&sidebar, cx), before);
+        let warnings = warn_lines();
+        let refusal: Vec<&String> = warnings
+            .iter()
+            .filter(|line| line.contains(&unknown.to_key_string()))
+            .collect();
+        assert_eq!(
+            refusal.len(),
+            1,
+            "one warning names the refusal: {warnings:?}"
+        );
+        assert!(
+            refusal[0].contains("4 threads"),
+            "it names the count: {}",
+            refusal[0]
+        );
+        for line in &warnings {
+            for secret in ["Secret plan title", "Oldest", "Newest", "Middle", ALPHA] {
+                assert!(
+                    !line.contains(secret),
+                    "no title or path in a log line: {line}"
+                );
+            }
+        }
+    }
+}
+
+// ═══ Task 6.2 — drag a pinned thread ═══════════════════════════════════════
+
+mod pinned_drag {
+    use super::*;
+    use pretty_assertions::assert_eq;
+
+    /// alpha with four threads, three pinned in the order [c, b, a] (the newest pin first).
+    async fn three_pins(
+        cx: &mut TestAppContext,
+    ) -> (
+        Entity<Sidebar>,
+        [ThreadId; 3],
+        ThreadId,
+        &mut VisualTestContext,
+    ) {
+        let (_fs, _mw, sidebar, cx) = alpha_window(cx).await;
+        let a = save_in("a", "Pin A", ALPHA, 1, cx);
+        let b = save_in("b", "Pin B", ALPHA, 2, cx);
+        let c = save_in("c", "Pin C", ALPHA, 3, cx);
+        let plain = save_in("p", "Plain", ALPHA, 4, cx);
+        for id in [a, b, c] {
+            store_pin(id, cx);
+        }
+        assert_eq!(
+            stored_pins(cx),
+            vec![c, b, a],
+            "fixture: the newest pin first"
+        );
+        (sidebar, [a, b, c], plain, cx)
+    }
+
+    // R4.2 (hostile first), then R4.1 up and down.
+    #[gpui::test]
+    async fn drop_on_a_pinned_row_takes_its_place(cx: &mut TestAppContext) {
+        let (sidebar, [a, b, c], _plain, cx) = three_pins(cx).await;
+
+        let own = pinned_row_ix(&sidebar, b, cx);
+        drop_pinned_row(&sidebar, b, own, own, cx);
+        assert_eq!(stored_pins(cx), vec![c, b, a], "own row: unchanged");
+
+        let from = pinned_row_ix(&sidebar, a, cx);
+        let target = pinned_row_ix(&sidebar, c, cx);
+        drop_pinned_row(&sidebar, a, from, target, cx);
+        assert_eq!(
+            stored_pins(cx),
+            vec![a, c, b],
+            "up: right before the target"
+        );
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec![
+                "v [alpha]",
+                "  Plain",
+                "v Pinned",
+                "  Pin A · alpha",
+                "  Pin C · alpha",
+                "  Pin B · alpha"
+            ]
+        );
+
+        let from = pinned_row_ix(&sidebar, a, cx);
+        let target = pinned_row_ix(&sidebar, c, cx);
+        drop_pinned_row(&sidebar, a, from, target, cx);
+        assert_eq!(
+            stored_pins(cx),
+            vec![c, a, b],
+            "down: right after the target"
+        );
+        assert_eq!(
+            mode(ALPHA, cx),
+            SortMode::Recent,
+            "a pinned drag never touches the group's mode"
+        );
+    }
+
+    // GOTCHA pinned index (hostile): with an archived pin hidden between the rows, the index is
+    // taken in the store's full order. Dragging the top row down onto "Pin B" must land after it;
+    // the visible index would leave the order unchanged.
+    #[gpui::test]
+    async fn the_index_counts_a_hidden_pin(cx: &mut TestAppContext) {
+        let (_fs, _mw, sidebar, cx) = alpha_window(cx).await;
+        let a = save_in("a", "Pin A", ALPHA, 1, cx);
+        let b = save_in("b", "Pin B", ALPHA, 2, cx);
+        let hidden = save_in("h", "Hidden pin", ALPHA, 3, cx);
+        let c = save_in("c", "Pin C", ALPHA, 4, cx);
+        for id in [a, b, hidden, c] {
+            store_pin(id, cx);
+        }
+        archive(hidden, cx);
+        assert_eq!(
+            stored_pins(cx),
+            vec![c, hidden, b, a],
+            "fixture: the archived pin is kept"
+        );
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec![
+                "v [alpha]",
+                "v Pinned",
+                "  Pin C · alpha",
+                "  Pin B · alpha",
+                "  Pin A · alpha"
+            ],
+            "fixture: the archived pin is hidden"
+        );
+
+        let from = pinned_row_ix(&sidebar, c, cx);
+        let target = pinned_row_ix(&sidebar, b, cx);
+        drop_pinned_row(&sidebar, c, from, target, cx);
+
+        assert_eq!(stored_pins(cx), vec![hidden, b, c, a]);
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec![
+                "v [alpha]",
+                "v Pinned",
+                "  Pin B · alpha",
+                "  Pin C · alpha",
+                "  Pin A · alpha"
+            ]
+        );
+    }
+
+    // R5.4 (hostile): a pinned row dropped on a group row, a header or the Pinned header.
+    #[gpui::test]
+    async fn pinned_drop_outside_the_section_changes_nothing(cx: &mut TestAppContext) {
+        let (sidebar, [a, _b, _c], plain, cx) = three_pins(cx).await;
+        let before = lines(&sidebar, cx);
+        let pins_before = stored_pins(cx);
+
+        let from = pinned_row_ix(&sidebar, a, cx);
+        let group_row = group_row_ix(&sidebar, plain, cx);
+        let alpha_header = header_ix(&sidebar, ALPHA, cx);
+        let pinned_header = pinned_header_ix(&sidebar, cx);
+        drop_pinned_row(&sidebar, a, from, group_row, cx);
+        drop_pinned_row(&sidebar, a, from, alpha_header, cx);
+        drop_pinned_row(&sidebar, a, from, pinned_header, cx);
+
+        assert_eq!(lines(&sidebar, cx), before);
+        assert_eq!(stored_pins(cx), pins_before);
+        assert_eq!(mode(ALPHA, cx), SortMode::Recent);
+        assert_eq!(
+            slot(a, ALPHA, cx),
+            None,
+            "never moved into the group's order"
+        );
+    }
+
+    // R4.1: the new pinned order shows in every window and after a restart.
+    #[gpui::test]
+    async fn pinned_order_shows_in_every_window_and_after_a_restart(cx: &mut TestAppContext) {
+        let (fs, project_a) = init_org_test(true, cx).await;
+        let project_b = project::Project::test(fs, [BETA.as_ref()], cx).await;
+        let window_a = cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+        let window_b = cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
+        let mw_a = window_a.root(cx).unwrap();
+        let mw_b = window_b.root(cx).unwrap();
+        let cx_b = &mut VisualTestContext::from_window(window_b.into(), cx);
+        let sidebar_b = setup_sidebar(&mw_b, cx_b);
+        let cx_a = &mut VisualTestContext::from_window(window_a.into(), cx);
+        let sidebar_a = setup_sidebar(&mw_a, cx_a);
+        let a = save_in("a", "Pin A", ALPHA, 1, cx_a);
+        let b = save_in("b", "Pin B", ALPHA, 2, cx_a);
+        store_pin(a, cx_a);
+        store_pin(b, cx_a);
+
+        let from = pinned_row_ix(&sidebar_a, a, cx_a);
+        let target = pinned_row_ix(&sidebar_a, b, cx_a);
+        drop_pinned_row(&sidebar_a, a, from, target, cx_a);
+        cx_b.run_until_parked();
+
+        let expected = vec!["v [beta]", "v Pinned", "  Pin A · alpha", "  Pin B · alpha"];
+        assert_eq!(
+            lines(&sidebar_b, cx_b),
+            expected,
+            "the other window shows the new order"
+        );
+
+        cx.run_until_parked();
+        cx.update(|cx| agent_ui::thread_organization::init(cx));
+        cx.run_until_parked();
+        let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
+        let reopened = project::Project::test(fs, [BETA.as_ref()], cx).await;
+        let (multi_workspace, cx_c) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(reopened, window, cx));
+        let sidebar_c = setup_sidebar(&multi_workspace, cx_c);
+        assert_eq!(lines(&sidebar_c, cx_c), expected, "after a restart");
+    }
+
+    // R4.4: a refused move changes nothing and warns naming the thread id. The refused thread is
+    // a pinned thread whose metadata became a draft again: it is still in the store's full
+    // pinned order (the section hides it), and the store refuses it with `Draft`.
+    #[gpui::test]
+    async fn refused_pinned_move_changes_nothing_and_warns_with_the_id(cx: &mut TestAppContext) {
+        let (_fs, _mw, sidebar, cx) = alpha_window(cx).await;
+        let k1 = save_in("k1", "Known one", ALPHA, 1, cx);
+        let k2 = save_in("k2", "Known two", ALPHA, 2, cx);
+        let refused = save_in("r", "Refused one", ALPHA, 3, cx);
+        for id in [k1, refused, k2] {
+            store_pin(id, cx);
+        }
+        store_metadata(
+            metadata_for(refused, None, "Refused one", ALPHA, Some(3), 3),
+            cx,
+        );
+        let pins_before = stored_pins(cx);
+        assert_eq!(
+            pins_before,
+            vec![k2, refused, k1],
+            "fixture: the draft keeps its stored pin"
+        );
+        let before = lines(&sidebar, cx);
+        capture_logs();
+
+        let target = pinned_row_ix(&sidebar, k1, cx);
+        drop_pinned_row(&sidebar, refused, target, target, cx);
+
+        assert_eq!(stored_pins(cx), pins_before);
+        assert_eq!(lines(&sidebar, cx), before);
+        let warnings = warn_lines();
+        assert_eq!(
+            warnings
+                .iter()
+                .filter(|line| line.contains(&refused.to_key_string()))
+                .count(),
+            1,
+            "one warning names the refused thread: {warnings:?}"
+        );
+        assert!(
+            warnings.iter().all(|line| !line.contains("Refused one")
+                && !line.contains("Known")
+                && !line.contains(ALPHA)),
+            "no title or path in a log line: {warnings:?}"
+        );
+    }
+}
+
+// ═══ Task 6.3 — what can be dragged where, with a real pointer ═════════════
+
+mod drag_offer {
+    use super::*;
+    use pretty_assertions::assert_eq;
+
+    async fn alpha_and_beta(
+        cx: &mut TestAppContext,
+    ) -> (
+        Entity<MultiWorkspace>,
+        Entity<Sidebar>,
+        [ThreadId; 3],
+        ThreadId,
+        &mut VisualTestContext,
+    ) {
+        let (fs, mw, sidebar, cx) = alpha_window(cx).await;
+        open_in_window(BETA, &fs, &mw, cx).await;
+        let newest = save_in("n", "Newest", ALPHA, 3, cx);
+        let middle = save_in("m", "Middle", ALPHA, 2, cx);
+        let oldest = save_in("o", "Oldest", ALPHA, 1, cx);
+        let beta = save_in("b", "Beta thread", BETA, 1, cx);
+        (mw, sidebar, [newest, middle, oldest], beta, cx)
+    }
+
+    fn center_of(
+        sidebar: &Entity<Sidebar>,
+        ix: usize,
+        cx: &mut VisualTestContext,
+    ) -> Point<Pixels> {
+        entry_bounds(sidebar, ix, cx).center()
+    }
+
+    // Fidelity: one real pointer drag per kind, through on_drag / can_drop / on_drop. R5.8: the
+    // release after a drag activates nothing and collapses nothing.
+    #[gpui::test]
+    async fn a_real_pointer_drags_a_thread_and_a_header(cx: &mut TestAppContext) {
+        let (mw, sidebar, [newest, _middle, oldest], _beta, cx) = alpha_and_beta(cx).await;
+        let active_before =
+            sidebar.read_with(cx, |sidebar, _| format!("{:?}", sidebar.active_entry));
+
+        let from = center_of(&sidebar, group_row_ix(&sidebar, oldest, cx), cx);
+        let to = center_of(&sidebar, group_row_ix(&sidebar, newest, cx), cx);
+        assert!(pointer_drag(from, to, cx), "a thread row offers a drag");
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec![
+                "v [beta]",
+                "  Beta thread",
+                "v [alpha]",
+                "  Oldest",
+                "  Newest",
+                "  Middle"
+            ]
+        );
+        assert_eq!(mode(ALPHA, cx), SortMode::Manual);
+        assert_eq!(
+            sidebar.read_with(cx, |sidebar, _| format!("{:?}", sidebar.active_entry)),
+            active_before,
+            "R5.8: the dragged row was not activated on release"
+        );
+
+        let from = center_of(&sidebar, header_ix(&sidebar, ALPHA, cx), cx);
+        let to = center_of(&sidebar, header_ix(&sidebar, BETA, cx), cx);
+        assert!(pointer_drag(from, to, cx), "a project header offers a drag");
+        assert_eq!(window_order(&mw, cx), vec![ALPHA, BETA]);
+        assert_eq!(
+            headers(&sidebar, cx),
+            vec!["v [alpha]", "v [beta]"],
+            "R5.8: the dragged header did not collapse on release"
+        );
+    }
+
+    #[gpui::test]
+    async fn a_real_pointer_drags_a_pinned_row(cx: &mut TestAppContext) {
+        let (_mw, sidebar, [newest, middle, _oldest], _beta, cx) = alpha_and_beta(cx).await;
+        store_pin(middle, cx);
+        store_pin(newest, cx);
+        assert_eq!(stored_pins(cx), vec![newest, middle], "fixture");
+
+        let from = center_of(&sidebar, pinned_row_ix(&sidebar, middle, cx), cx);
+        let to = center_of(&sidebar, pinned_row_ix(&sidebar, newest, cx), cx);
+        assert!(pointer_drag(from, to, cx), "a pinned row offers a drag");
+        assert_eq!(stored_pins(cx), vec![middle, newest]);
+    }
+
+    // R5.3, R5.5, R5.6 with a real pointer: the typed drop and can_drop refuse foreign targets.
+    #[gpui::test]
+    async fn a_real_pointer_drop_on_a_foreign_target_changes_nothing(cx: &mut TestAppContext) {
+        let (mw, sidebar, [_newest, middle, oldest], beta, cx) = alpha_and_beta(cx).await;
+        store_pin(middle, cx);
+        let before = lines(&sidebar, cx);
+
+        // Thread row onto another group's row, onto a header, onto a pinned row (R5.3).
+        let from = center_of(&sidebar, group_row_ix(&sidebar, oldest, cx), cx);
+        let beta_row = center_of(&sidebar, group_row_ix(&sidebar, beta, cx), cx);
+        let beta_header = center_of(&sidebar, header_ix(&sidebar, BETA, cx), cx);
+        let pinned_row = center_of(&sidebar, pinned_row_ix(&sidebar, middle, cx), cx);
+        pointer_drag(from, beta_row, cx);
+        pointer_drag(from, beta_header, cx);
+        pointer_drag(from, pinned_row, cx);
+        // Header onto a thread row and onto the Pinned header (R5.5).
+        let alpha_header = center_of(&sidebar, header_ix(&sidebar, ALPHA, cx), cx);
+        let pinned_header = center_of(&sidebar, pinned_header_ix(&sidebar, cx), cx);
+        pointer_drag(alpha_header, beta_row, cx);
+        pointer_drag(alpha_header, pinned_header, cx);
+        // Released where nothing accepts a drop (R5.6): the window's top-left corner.
+        pointer_drag(from, point(px(1.), px(1.)), cx);
+
+        assert_eq!(lines(&sidebar, cx), before);
+        assert_eq!(window_order(&mw, cx), vec![BETA, ALPHA]);
+        assert_eq!(
+            (mode(ALPHA, cx), mode(BETA, cx)),
+            (SortMode::Recent, SortMode::Recent)
+        );
+        assert_eq!(stored_pins(cx), vec![middle]);
+    }
+
+    // R5.7: a press and release without motion toggles the project as before and records nothing.
+    #[gpui::test]
+    async fn a_click_without_motion_toggles_and_records_nothing(cx: &mut TestAppContext) {
+        let (mw, sidebar, _threads, _beta, cx) = alpha_and_beta(cx).await;
+        let header = center_of(&sidebar, header_ix(&sidebar, ALPHA, cx), cx);
+
+        cx.simulate_click(header, Modifiers::none());
+        cx.run_until_parked();
+
+        assert_eq!(
+            headers(&sidebar, cx),
+            vec!["v [beta]", "> [alpha]"],
+            "the click collapsed alpha"
+        );
+        assert_eq!(window_order(&mw, cx), vec![BETA, ALPHA]);
+        assert_eq!(mode(ALPHA, cx), SortMode::Recent);
+        assert!(!cx.update(|_, cx| cx.has_active_drag()));
+    }
+
+    // R5.1: with filter text, no thread drag and no pinned drag; the project drag stays.
+    #[gpui::test]
+    async fn filter_text_disables_thread_and_pinned_drags_only(cx: &mut TestAppContext) {
+        let (_mw, sidebar, [newest, middle, _oldest], _beta, cx) = alpha_and_beta(cx).await;
+        store_pin(middle, cx);
+        type_in_search(&sidebar, "e", cx);
+
+        let row = center_of(&sidebar, group_row_ix(&sidebar, newest, cx), cx);
+        let pinned = center_of(&sidebar, pinned_row_ix(&sidebar, middle, cx), cx);
+        let header = center_of(&sidebar, header_ix(&sidebar, ALPHA, cx), cx);
+        assert!(!drag_starts_at(row, cx), "no thread drag while filtering");
+        assert!(
+            !drag_starts_at(pinned, cx),
+            "no pinned drag while filtering"
+        );
+        assert!(
+            drag_starts_at(header, cx),
+            "the project drag stays available"
+        );
+        assert_eq!(mode(ALPHA, cx), SortMode::Recent);
+    }
+
+    // R2.12: without the store, no thread drag (and no Manual mode anywhere).
+    #[gpui::test]
+    async fn no_store_offers_no_thread_drag(cx: &mut TestAppContext) {
+        let (_fs, project) = init_org_test(false, cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+        let newest = save_in("n", "Newest", ALPHA, 2, cx);
+        save_in("o", "Oldest", ALPHA, 1, cx);
+
+        let row = center_of(&sidebar, group_row_ix(&sidebar, newest, cx), cx);
+        assert!(!drag_starts_at(row, cx), "no thread drag without the store");
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec!["v [alpha]", "  Newest", "  Oldest"]
+        );
+    }
+
+    // R5.2: no drag from an empty draft or a terminal row.
+    #[gpui::test]
+    async fn drafts_and_terminals_offer_no_drag(cx: &mut TestAppContext) {
+        let project = init_test_project_with_agent_panel("/my-project", cx).await;
+        cx.update(|cx| agent_ui::thread_organization::init(cx));
+        cx.run_until_parked();
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+        save_in("t", "Thread", "/my-project", 1, cx);
+        panel
+            .update_in(cx, |panel, window, cx| {
+                panel.insert_test_terminal("Dev Server", false, window, cx)
+            })
+            .expect("fixture: the terminal is inserted");
+        // `insert_test_terminal` makes Terminal the panel's last-created kind, so `NewThread`
+        // would open a second terminal; the agent-draft entry point opens the empty draft.
+        panel.update_in(cx, |panel, window, cx| {
+            panel.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx)
+        });
+        cx.run_until_parked();
+        let (draft_ix, terminal_ix) = sidebar.read_with(cx, |sidebar, _| {
+            let entries = &sidebar.contents.entries;
+            (
+                entries
+                    .iter()
+                    .position(|e| matches!(e, ListEntry::Thread(t) if t.draft.is_some()))
+                    .expect("fixture: the draft is shown"),
+                entries
+                    .iter()
+                    .position(|e| matches!(e, ListEntry::Terminal(_)))
+                    .expect("fixture: the terminal is shown"),
+            )
+        });
+
+        let draft = center_of(&sidebar, draft_ix, cx);
+        let terminal = center_of(&sidebar, terminal_ix, cx);
+        assert!(!drag_starts_at(draft, cx), "no drag from a draft");
+        assert!(!drag_starts_at(terminal, cx), "no drag from a terminal");
+        assert_eq!(mode("/my-project", cx), SortMode::Recent);
+    }
+}
+
+// ═══ Task 7.1 — Sort by Recent ═════════════════════════════════════════════
+
+mod sort_by_recent {
+    use super::*;
+
+    /// alpha (Newest 3, Middle 2, Oldest 1) and beta (B new 3, B old 1), both in this window.
+    async fn two_projects(
+        cx: &mut TestAppContext,
+    ) -> (
+        Entity<Sidebar>,
+        [ThreadId; 3],
+        [ThreadId; 2],
+        &mut VisualTestContext,
+    ) {
+        let (fs, mw, sidebar, cx) = alpha_window(cx).await;
+        open_in_window(BETA, &fs, &mw, cx).await;
+        let newest = save_in("n", "Newest", ALPHA, 3, cx);
+        let middle = save_in("m", "Middle", ALPHA, 2, cx);
+        let oldest = save_in("o", "Oldest", ALPHA, 1, cx);
+        let b_new = save_in("bn", "B new", BETA, 3, cx);
+        let b_old = save_in("bo", "B old", BETA, 1, cx);
+        (sidebar, [newest, middle, oldest], [b_new, b_old], cx)
+    }
+
+    fn open_header_menu(sidebar: &Entity<Sidebar>, path: &str, cx: &mut VisualTestContext) {
+        let ix = header_ix(sidebar, path, cx);
+        right_click(entry_bounds(sidebar, ix, cx).center(), cx);
+        cx.debug_bounds("MENU_ITEM-Remove")
+            .expect("fixture: the project header menu is open");
+    }
+
+    fn close_menu(cx: &mut VisualTestContext) {
+        // A test window never runs the next frame that focuses the menu, so "escape" cannot
+        // reach it; a click outside dismisses it through its mouse-down-out handler instead.
+        let size = cx.update(|window, _| window.viewport_size());
+        let outside = point(size.width - px(2.), size.height - px(2.));
+        cx.simulate_mouse_down(outside, MouseButton::Left, Modifiers::none());
+        cx.simulate_mouse_up(outside, MouseButton::Left, Modifiers::none());
+        cx.run_until_parked();
+    }
+
+    // R3.2 (hostile first), R3.1: offered only in Manual, read when the menu opens.
+    #[gpui::test]
+    async fn the_entry_follows_the_mode_at_menu_open_time(cx: &mut TestAppContext) {
+        let (sidebar, [newest, middle, oldest], _beta, cx) = two_projects(cx).await;
+
+        open_header_menu(&sidebar, ALPHA, cx);
+        assert!(
+            cx.debug_bounds("MENU_ITEM-Sort by Recent").is_none(),
+            "a Recent project is not offered Sort by Recent"
+        );
+        close_menu(cx);
+
+        // Turned Manual after the sidebar rendered: the next open must see it.
+        record_order(ALPHA, &[oldest, newest, middle], cx);
+        open_header_menu(&sidebar, ALPHA, cx);
+        assert!(
+            cx.debug_bounds("MENU_ITEM-Sort by Recent").is_some(),
+            "a Manual project is offered Sort by Recent"
+        );
+        close_menu(cx);
+
+        open_header_menu(&sidebar, BETA, cx);
+        assert!(
+            cx.debug_bounds("MENU_ITEM-Sort by Recent").is_none(),
+            "beta, still Recent, is not offered it"
+        );
+    }
+
+    // R3.3, R3.4 (hostile: beta keeps Manual and its slots), through the real menu entry.
+    #[gpui::test]
+    async fn sort_by_recent_returns_only_that_project(cx: &mut TestAppContext) {
+        let (sidebar, [newest, middle, oldest], [b_new, b_old], cx) = two_projects(cx).await;
+        record_order(ALPHA, &[oldest, newest, middle], cx);
+        record_order(BETA, &[b_old, b_new], cx);
+        save_thread("o", "Oldest", ALPHA, Some(1), 23, cx);
+        touch(oldest, 23, cx);
+
+        open_header_menu(&sidebar, ALPHA, cx);
+        let item = cx
+            .debug_bounds("MENU_ITEM-Sort by Recent")
+            .expect("a Manual project is offered Sort by Recent");
+        cx.simulate_click(item.center(), Modifiers::none());
+        cx.run_until_parked();
+
+        assert_eq!(mode(BETA, cx), SortMode::Manual, "beta stays Manual");
+        assert_eq!(
+            (slot(b_old, BETA, cx), slot(b_new, BETA, cx)),
+            (Some(0), Some(1))
+        );
+        assert_eq!(mode(ALPHA, cx), SortMode::Recent);
+        assert_eq!(
+            (
+                slot(oldest, ALPHA, cx),
+                slot(newest, ALPHA, cx),
+                slot(middle, ALPHA, cx)
+            ),
+            (None, None, None),
+            "alpha's places are discarded"
+        );
+        assert_eq!(
+            lines(&sidebar, cx),
+            vec![
+                "v [beta]",
+                "  B old",
+                "  B new",
+                "v [alpha]",
+                "  Oldest",
+                "  Newest",
+                "  Middle"
+            ],
+            "alpha by most recent activity (Oldest was just active), beta in its recorded order"
+        );
+        assert!(
+            cx.debug_bounds("MENU_ITEM-Sort by Recent").is_none(),
+            "the menu was dismissed"
+        );
+    }
+
+    // R3.5: chosen in window A, the project is Recent in window B.
+    #[gpui::test]
+    async fn sort_by_recent_shows_in_every_window(cx: &mut TestAppContext) {
+        let (_fs, project_a) = init_org_test(true, cx).await;
+        let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
+        let project_b = project::Project::test(fs, [ALPHA.as_ref()], cx).await;
+        let window_a = cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+        let window_b = cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
+        let mw_a = window_a.root(cx).unwrap();
+        let mw_b = window_b.root(cx).unwrap();
+        let cx_b = &mut VisualTestContext::from_window(window_b.into(), cx);
+        let sidebar_b = setup_sidebar(&mw_b, cx_b);
+        let cx_a = &mut VisualTestContext::from_window(window_a.into(), cx);
+        let sidebar_a = setup_sidebar(&mw_a, cx_a);
+        let newest = save_in("n", "Newest", ALPHA, 3, cx_a);
+        let oldest = save_in("o", "Oldest", ALPHA, 1, cx_a);
+        record_order(ALPHA, &[oldest, newest], cx_a);
+        cx_b.run_until_parked();
+        assert_eq!(
+            lines(&sidebar_b, cx_b),
+            vec!["v [alpha]", "  Oldest", "  Newest"],
+            "fixture"
+        );
+
+        sidebar_a.update(cx_a, |sidebar, cx| {
+            sidebar.sort_project_by_recent(&local_key(ALPHA), cx)
+        });
+        cx_a.run_until_parked();
+        cx_b.run_until_parked();
+
+        assert_eq!(
+            lines(&sidebar_b, cx_b),
+            vec!["v [alpha]", "  Newest", "  Oldest"]
+        );
+    }
+
+    // R3.3 on a project already Recent: nothing changes and nothing is logged as a refusal.
+    #[gpui::test]
+    async fn sort_by_recent_on_a_recent_project_is_quiet(cx: &mut TestAppContext) {
+        let (sidebar, _alpha, _beta, cx) = two_projects(cx).await;
+        let before = lines(&sidebar, cx);
+        capture_logs();
+
+        sidebar.update(cx, |sidebar, cx| {
+            sidebar.sort_project_by_recent(&local_key(ALPHA), cx)
+        });
+        cx.run_until_parked();
+
+        assert_eq!(lines(&sidebar, cx), before);
+        assert_eq!(mode(ALPHA, cx), SortMode::Recent);
+        assert_eq!(warn_lines(), Vec::<String>::new());
+    }
+}
diff --git a/crates/sidebar/src/sidebar_pinned_tests.rs b/crates/sidebar/src/sidebar_pinned_tests.rs
index fb66b88..e9d55f8 100644
--- a/crates/sidebar/src/sidebar_pinned_tests.rs
+++ b/crates/sidebar/src/sidebar_pinned_tests.rs
@@ -382,7 +382,7 @@ static INSTALL: Once = Once::new();
 
 /// Starts capturing log lines on this thread. Panics when another logger owns the process,
 /// rather than letting a log assertion pass or fail for the wrong reason.
-fn capture_logs() {
+pub(super) fn capture_logs() {
     INSTALL.call_once(|| {
         log::set_boxed_logger(Box::new(CaptureLogger))
             .expect("another logger is installed; the log capture cannot run");
@@ -391,7 +391,7 @@ fn capture_logs() {
     CAPTURED.with(|captured| captured.borrow_mut().clear());
 }
 
-fn warn_lines() -> Vec<String> {
+pub(super) fn warn_lines() -> Vec<String> {
     CAPTURED.with(|captured| {
         captured
             .borrow()
