From fae494c00ebf9bed7e29ca7bac4c4b2257264b24 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 9 Oct 2026 01:54:09 +0800 Subject: [PATCH 1/6] feat: pin threads and arrange the pinned and active lists on the host --- crates/core/src/project.rs | 12 + crates/core/src/settlement.rs | 6 + crates/core/src/thread_sort.rs | 281 ++++++++++++++++++- crates/protocol/src/command.rs | 22 ++ crates/protocol/src/lib.rs | 2 + crates/runtime/src/app/authorization.rs | 4 + crates/runtime/src/app/command_validation.rs | 40 ++- crates/runtime/src/app/sessions.rs | 77 +++++ crates/runtime/src/app/tests.rs | 170 +++++++++++ crates/runtime/src/pipe.rs | 14 + 10 files changed, 616 insertions(+), 12 deletions(-) diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index 893ad93bd..4bcc172fc 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -113,6 +113,15 @@ pub struct SessionMeta { pub unsettled_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_settle_disabled_at: Option, + /// A pinned thread is exempt from automatic settlement. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pinned_at: Option, + /// Fractional order keys ([`crate::thread_sort::order_key_between`]); a + /// row without one sorts by time within its section. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pin_order: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_order: Option, /// Dedicated-worktree mode metadata, when the session runs in its own git /// worktree instead of the project checkout. Absent = local checkout. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -276,6 +285,9 @@ impl SessionMeta { settled_override: None, unsettled_at: None, auto_settle_disabled_at: None, + pinned_at: None, + pin_order: None, + active_order: None, worktree: None, resume_cursor: None, pending_fork: false, diff --git a/crates/core/src/settlement.rs b/crates/core/src/settlement.rs index 4e9907150..f22c2c8e6 100644 --- a/crates/core/src/settlement.rs +++ b/crates/core/src/settlement.rs @@ -210,6 +210,7 @@ pub fn automatic_settlement_at( || meta.settled_override.is_some() || meta.is_settled() || meta.auto_settle_disabled_at.is_some() + || meta.pinned_at.is_some() || meta.parent_session_id.is_some() || blockers.any() || activity.queued_turn_start(now_ms) @@ -276,6 +277,7 @@ mod tests { "active", "settled", "disabled", + "pinned", "child", "input", "run", @@ -289,6 +291,7 @@ mod tests { "active" => blocked.settled_override = Some(SettledOverride::Active), "settled" => blocked.settled_override = Some(SettledOverride::Settled), "disabled" => blocked.auto_settle_disabled_at = Some(1), + "pinned" => blocked.pinned_at = Some(1), "child" => blocked.parent_session_id = Some("lead".into()), _ => {} } @@ -445,6 +448,9 @@ mod tests { worked_at, "inactivity still settles" ); + let mut pinned = merged.clone(); + pinned.pinned_at = Some(1); + assert_eq!(settle_at(&pinned, &records, NOW, true), None, "pinned"); let closed = thread(&[Some((Closed, Some(NOW - 3 * HOUR)))]); assert_eq!(settle_at(&closed, &records, NOW, false), worked_at); assert_eq!( diff --git a/crates/core/src/thread_sort.rs b/crates/core/src/thread_sort.rs index 6f49f5222..e1b00e8bd 100644 --- a/crates/core/src/thread_sort.rs +++ b/crates/core/src/thread_sort.rs @@ -1,9 +1,11 @@ //! Thread sections and static lifecycle ordering, independent of client layout. use crate::project::SessionMeta; use std::cmp::Ordering; +use std::collections::HashSet; -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ThreadSection { + Pinned, Active, Settled, } @@ -17,6 +19,8 @@ pub fn in_roster(meta: &SessionMeta) -> bool { pub fn thread_section(meta: &SessionMeta) -> ThreadSection { if meta.is_settled() { ThreadSection::Settled + } else if meta.pinned_at.is_some() { + ThreadSection::Pinned } else { ThreadSection::Active } @@ -27,18 +31,40 @@ pub fn settled_timestamp(meta: &SessionMeta) -> u64 { meta.settled_at.unwrap_or(meta.updated_at) } -/// Active threads newest created or reopened first; settled threads newest -/// settled first. Activity never reorders a row. +/// The key a thread is arranged by within its section, if it has one. +pub fn order_key(meta: &SessionMeta) -> Option<&str> { + match thread_section(meta) { + ThreadSection::Pinned => meta.pin_order.as_deref(), + ThreadSection::Active => meta.active_order.as_deref(), + ThreadSection::Settled => None, + } +} + +/// Pinned threads by key, then keyless ones newest created first. Active +/// threads without a key (new and reopened ones) newest created or reopened +/// first, then arranged ones by key. Settled threads newest settled first. +/// Activity never reorders a row. pub fn compare_threads(a: &SessionMeta, b: &SessionMeta) -> Ordering { let section = thread_section(a); section .cmp(&thread_section(b)) .then_with(|| match section { ThreadSection::Settled => settled_timestamp(b).cmp(&settled_timestamp(a)), - ThreadSection::Active => b - .created_at - .max(b.unsettled_at.unwrap_or(0)) - .cmp(&a.created_at.max(a.unsettled_at.unwrap_or(0))), + ThreadSection::Pinned => match (&a.pin_order, &b.pin_order) { + (Some(a), Some(b)) => a.cmp(b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => b.created_at.cmp(&a.created_at), + }, + ThreadSection::Active => match (&a.active_order, &b.active_order) { + (Some(a), Some(b)) => a.cmp(b), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => b + .created_at + .max(b.unsettled_at.unwrap_or(0)) + .cmp(&a.created_at.max(a.unsettled_at.unwrap_or(0))), + }, }) .then_with(|| a.id.cmp(&b.id)) } @@ -47,9 +73,242 @@ pub fn sort_threads(sessions: &mut [SessionMeta]) { sessions.sort_by(compare_threads); } -pub fn partition_threads(sessions: &[SessionMeta]) -> (Vec, Vec) { - sessions +/// Threads split by section, each keeping the order it was given in. +#[derive(Debug, Default)] +pub struct ThreadSections { + pub pinned: Vec, + pub active: Vec, + pub settled: Vec, +} + +impl ThreadSections { + pub fn section(&self, section: ThreadSection) -> &Vec { + match section { + ThreadSection::Pinned => &self.pinned, + ThreadSection::Active => &self.active, + ThreadSection::Settled => &self.settled, + } + } +} + +pub fn partition_threads<'a>( + sessions: impl IntoIterator, +) -> ThreadSections<&'a SessionMeta> { + let mut sections = ThreadSections { + pinned: vec![], + active: vec![], + settled: vec![], + }; + for meta in sessions { + match thread_section(meta) { + ThreadSection::Pinned => sections.pinned.push(meta), + ThreadSection::Active => sections.active.push(meta), + ThreadSection::Settled => sections.settled.push(meta), + } + } + sections +} + +// Order keys are base-26 fractions in (0, 1) written with the digits a–z and +// compared as plain strings, so a move writes one key to one thread and every +// client converges on the same order. A trailing `a` would leave no key +// immediately before it; generators never produce one. +const DIGITS: &[u8; 26] = b"abcdefghijklmnopqrstuvwxyz"; + +fn valid_order_key(key: &str) -> bool { + !key.is_empty() && key.bytes().all(|byte| byte.is_ascii_lowercase()) && !key.ends_with('a') +} + +/// The midpoint of two digit strings; an empty string is the open bound on +/// either side. Requires `a < b`. +fn midpoint(a: &[u8], b: &[u8]) -> Vec { + if !b.is_empty() { + // Past the longest common prefix; the shorter side pads with `a`. + let mut n = 0; + while b.get(n) == Some(&a.get(n).copied().unwrap_or(b'a')) { + n += 1; + } + if n > 0 { + let mut key = b[..n].to_vec(); + key.extend(midpoint(a.get(n..).unwrap_or_default(), &b[n..])); + return key; + } + } + let digit_a = a.first().map_or(0, |digit| usize::from(digit - b'a')); + let digit_b = b + .first() + .map_or(DIGITS.len(), |digit| usize::from(digit - b'a')); + if digit_b - digit_a > 1 { + return vec![DIGITS[(digit_a + digit_b).div_ceil(2)]]; + } + // Consecutive leading digits: shorten into `b` when it has more digits, + // or else extend `a`. + if b.len() > 1 { + return vec![b[0]]; + } + let mut key = vec![DIGITS[digit_a]]; + key.extend(midpoint(a.get(1..).unwrap_or_default(), &[])); + key +} + +/// A key that sorts strictly between two neighbours; `None` bounds are the +/// ends of the section. `None` when a bound is corrupt or the bounds are +/// reversed: the caller spreads fresh keys instead ([`plan_reorder`]). +pub fn order_key_between(before: Option<&str>, after: Option<&str>) -> Option { + let a = before.unwrap_or_default(); + let b = after.unwrap_or_default(); + if (!a.is_empty() && !valid_order_key(a)) + || (!b.is_empty() && !valid_order_key(b)) + || (!b.is_empty() && a >= b) + { + return None; + } + String::from_utf8(midpoint(a.as_bytes(), b.as_bytes())).ok() +} + +/// `count` evenly spaced keys in ascending order, wide enough that a key +/// still fits between any two of them. +pub fn spread_order_keys(count: usize) -> Vec { + let base = DIGITS.len() as u64; + let mut width = 2; + let mut space = base * base; + while space <= (count as u64 + 1) * 2 { + width += 1; + space *= base; + } + let step = space as f64 / (count + 1) as f64; + (1..=count) + .map(|index| { + let mut value = (step * index as f64).round() as u64; + if value % base == 0 { + value += 1; + } + let mut key = vec![b'a'; width]; + for slot in key.iter_mut().rev() { + *slot = DIGITS[(value % base) as usize]; + value /= base; + } + String::from_utf8(key).expect("ASCII digits") + }) + .collect() +} + +/// The key writes that put a section in `ordered` (thread id and current +/// key, after the move) once `moved` has moved. Between keyed neighbours this +/// is one write to `moved`; next to a keyless neighbour, or between corrupt +/// keys, every listed thread gets a fresh spread key. `hidden` holds the keys +/// of the section's threads that are not listed: they are never written and +/// never reused, so those threads keep their places. +pub fn plan_reorder( + ordered: &[(&str, Option<&str>)], + hidden: &[&str], + moved: &str, +) -> Vec<(String, String)> { + let reserved: HashSet<&str> = hidden.iter().copied().collect(); + let Some(index) = ordered.iter().position(|(id, _)| *id == moved) else { + return vec![]; + }; + let before = index.checked_sub(1).map(|index| ordered[index].1); + let after = ordered.get(index + 1).map(|(_, key)| *key); + if before.is_none_or(|key| key.is_some()) && after.is_none_or(|key| key.is_some()) { + let after = after.flatten(); + let mut key = order_key_between(before.flatten(), after); + while let Some(found) = key.as_deref().filter(|key| reserved.contains(key)) { + key = order_key_between(Some(found), after); + } + if let Some(key) = key { + return vec![(moved.to_owned(), key)]; + } + } + let keys = spread_order_keys(ordered.len() + reserved.len()) + .into_iter() + .filter(|key| !reserved.contains(key.as_str())); + ordered .iter() - .cloned() - .partition(|meta| thread_section(meta) == ThreadSection::Active) + .zip(keys) + .filter(|((_, current), key)| *current != Some(key.as_str())) + .map(|((id, _), key)| ((*id).to_owned(), key)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_fit_between_any_neighbours_and_refuse_corrupt_bounds() { + let between = |a: Option<&str>, b: Option<&str>| order_key_between(a, b).unwrap(); + for (a, b) in [ + (None, None), + (None, Some("b")), + (Some("z"), None), + (Some("b"), Some("c")), + (Some("b"), Some("bb")), + (Some("bz"), Some("c")), + (Some("mzzz"), Some("n")), + ] { + let key = between(a, b); + assert!(valid_order_key(&key), "{a:?} {b:?} gave {key}"); + assert!(a.is_none_or(|a| a < key.as_str()), "{a:?} < {key}"); + assert!(b.is_none_or(|b| key.as_str() < b), "{key} < {b:?}"); + } + for (a, b) in [ + (Some("c"), Some("b")), + (Some("b"), Some("b")), + (Some("ba"), None), + (None, Some("B")), + ] { + assert_eq!(order_key_between(a, b), None, "{a:?} {b:?}"); + } + } + + #[test] + fn spread_keys_stay_ordered_and_insertable() { + for count in [0, 1, 650, 676, 2_000] { + let keys = spread_order_keys(count); + assert_eq!(keys.len(), count); + assert!(keys.windows(2).all(|pair| pair[0] < pair[1]), "{count}"); + for (index, key) in keys.iter().enumerate() { + assert!(valid_order_key(key), "{key}"); + let before = index.checked_sub(1).map(|index| keys[index].as_str()); + assert!(order_key_between(before, Some(key)).is_some(), "{key}"); + } + } + } + + #[test] + fn a_move_writes_one_key_unless_a_neighbour_has_none() { + let hidden = order_key_between(Some("f"), Some("t")).unwrap(); + let writes = plan_reorder( + &[("a", Some("f")), ("moved", Some("z")), ("b", Some("t"))], + &[&hidden], + "moved", + ); + assert_eq!(writes.len(), 1); + let (id, key) = &writes[0]; + assert_eq!(id, "moved"); + assert!("f" < key.as_str() && key.as_str() < "t" && *key != hidden); + + let reserved = spread_order_keys(6); + let reserved: Vec<_> = reserved.iter().map(String::as_str).collect(); + let writes = plan_reorder( + &[("c", None), ("a", None), ("b", Some("m"))], + &reserved, + "c", + ); + let ids: Vec<_> = writes.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["c", "a", "b"]); + let keys: Vec<_> = writes.iter().map(|(_, key)| key.as_str()).collect(); + assert!(keys.windows(2).all(|pair| pair[0] < pair[1])); + assert!(keys.iter().all(|key| !reserved.contains(key))); + + let corrupt = plan_reorder( + &[("a", Some("t")), ("moved", None), ("b", Some("f"))], + &[], + "moved", + ); + let keys: Vec<_> = corrupt.iter().map(|(_, key)| key.as_str()).collect(); + assert_eq!(corrupt.len(), 3, "reversed neighbours respread the section"); + assert!(keys.windows(2).all(|pair| pair[0] < pair[1])); + } } diff --git a/crates/protocol/src/command.rs b/crates/protocol/src/command.rs index 8ae7ffb50..8b390bd35 100644 --- a/crates/protocol/src/command.rs +++ b/crates/protocol/src/command.rs @@ -324,6 +324,24 @@ pub enum Command { UnsettleSession { session_id: String, }, + /// Pin a thread, promoting it out of Settled. A thread pinned for the + /// first time takes `order_key`; one already pinned keeps its own. + PinSession { + session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + order_key: Option, + }, + UnpinSession { + session_id: String, + }, + ReorderPinned { + session_id: String, + order_key: String, + }, + ReorderActive { + session_id: String, + order_key: String, + }, ArchiveSession { session_id: String, }, @@ -491,6 +509,10 @@ impl Command { | Self::SettleSession { session_id, .. } | Self::CancelAgent { session_id } | Self::UnsettleSession { session_id, .. } + | Self::PinSession { session_id, .. } + | Self::UnpinSession { session_id } + | Self::ReorderPinned { session_id, .. } + | Self::ReorderActive { session_id, .. } | Self::SetAutoSettle { session_id, .. } | Self::ArchiveSession { session_id, .. } | Self::UnarchiveSession { session_id, .. } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index dcd826188..ec07110cb 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -97,6 +97,8 @@ pub use wire::{ // OrchestrateArchiveOnComplete) are removed. // Unreleased: the Preview topic no longer names a session, so a subscriber // answers automation for threads it is not viewing. +// Unreleased: thread metadata carries pinned_at, pin_order and active_order; +// adds the PinSession, UnpinSession, ReorderPinned and ReorderActive commands. pub const PROTOCOL_VERSION: u32 = 10; #[cfg(test)] diff --git a/crates/runtime/src/app/authorization.rs b/crates/runtime/src/app/authorization.rs index dcfaa7a39..2be9e58a3 100644 --- a/crates/runtime/src/app/authorization.rs +++ b/crates/runtime/src/app/authorization.rs @@ -257,6 +257,10 @@ impl AppState { | Command::CancelAgent { session_id } | Command::UnsettleSession { session_id } | Command::SetAutoSettle { session_id, .. } + | Command::PinSession { session_id, .. } + | Command::UnpinSession { session_id } + | Command::ReorderPinned { session_id, .. } + | Command::ReorderActive { session_id, .. } | Command::RewindTurn { session_id, .. } | Command::AddReviewComment { session_id, .. } | Command::RemoveReviewComment { session_id, .. } diff --git a/crates/runtime/src/app/command_validation.rs b/crates/runtime/src/app/command_validation.rs index 0b1e9b49e..b706cf273 100644 --- a/crates/runtime/src/app/command_validation.rs +++ b/crates/runtime/src/app/command_validation.rs @@ -1,5 +1,6 @@ use super::*; use tcode_core::settings::SettingsPatch; +use tcode_core::thread_sort::ThreadSection; impl AppState { pub(super) fn native_rewind_blocked(&self, active: &ActiveSession) -> bool { @@ -183,7 +184,12 @@ impl AppState { }; if matches!( command, - Command::SettleSession { .. } | Command::UnsettleSession { .. } + Command::SettleSession { .. } + | Command::UnsettleSession { .. } + | Command::PinSession { .. } + | Command::UnpinSession { .. } + | Command::ReorderPinned { .. } + | Command::ReorderActive { .. } ) && self .find_meta(session_id) .is_some_and(|meta| meta.archived_at.is_some()) @@ -193,6 +199,34 @@ impl AppState { "Unarchive this thread before changing its lifecycle.", )); } + if let Command::PinSession { + order_key: Some(order_key), + .. + } + | Command::ReorderPinned { order_key, .. } + | Command::ReorderActive { order_key, .. } = command + && order_key.is_empty() + { + return Err(error("invalid_order_key", "An order key cannot be empty.")); + } + // Only a thread in the section has a place in its order, so a reorder + // raced by an unpin or a settle never pins or reopens the thread again. + let section = self + .find_meta(session_id) + .map(|meta| tcode_core::thread_sort::thread_section(&meta)); + match command { + Command::ReorderPinned { .. } + if section.is_some_and(|section| section != ThreadSection::Pinned) => + { + return Err(error("not_pinned", "This thread is not pinned.")); + } + Command::ReorderActive { .. } + if section.is_some_and(|section| section != ThreadSection::Active) => + { + return Err(error("not_active", "This thread is not in Active.")); + } + _ => {} + } if matches!(command, Command::SettleSession { .. }) && self.settle_thread_busy(session_id) { return Err(error( "thread_busy", @@ -206,6 +240,10 @@ impl AppState { | Command::CancelAgent { .. } | Command::UnsettleSession { .. } | Command::SetAutoSettle { .. } + | Command::PinSession { .. } + | Command::UnpinSession { .. } + | Command::ReorderPinned { .. } + | Command::ReorderActive { .. } | Command::ArchiveSession { .. } | Command::UnarchiveSession { .. } | Command::RenameSession { .. } diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 1fa044987..ed60e7b50 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -1,4 +1,5 @@ use super::*; +use tcode_core::thread_sort::ThreadSection; /// Sessions viewed by clients and sessions retained for in-flight work or re-adoption. #[derive(Default)] @@ -1045,6 +1046,9 @@ impl AppState { meta.settled_override = Some(SettledOverride::Settled); meta.unsettled_at = None; self.end_pull_request_watches(&mut meta); + meta.pinned_at = None; + meta.pin_order = None; + meta.active_order = None; self.detach_provider_to_idle(id, cx); if let Some(session) = self.resident_mut(id) { session @@ -1074,6 +1078,79 @@ impl AppState { self.persist_meta(&meta, cx); } + /// Pinning promotes a settled thread to Active without stamping + /// `unsettled_at`. A thread already pinned keeps its time and key, so a + /// raced duplicate never moves a thread the user placed. + pub fn pin_session(&mut self, id: &str, order_key: Option, cx: &mut HostCx) { + let Some(mut meta) = self.find_meta(id).filter(|meta| meta.archived_at.is_none()) else { + return; + }; + let promotes = meta.is_settled(); + if meta.pinned_at.is_some() && !promotes { + return; + } + if meta.pinned_at.is_none() { + meta.pinned_at = Some(now_secs()); + meta.pin_order = order_key; + } + if promotes { + meta.settled_override = Some(SettledOverride::Active); + meta.settled_at = None; + } + meta.updated_at = now_secs(); + if let Some(session) = self.resident_mut(id) { + session.meta = meta.clone(); + } + self.persist_meta(&meta, cx); + } + + /// Unpinning clears the key too: pinning again is a fresh slot unless + /// the client supplies the old one. The thread stays in Active. + pub fn unpin_session(&mut self, id: &str, cx: &mut HostCx) { + let Some(mut meta) = self.find_meta(id).filter(|meta| meta.archived_at.is_none()) else { + return; + }; + if meta.pinned_at.is_none() { + return; + } + meta.pinned_at = None; + meta.pin_order = None; + meta.updated_at = now_secs(); + if let Some(session) = self.resident_mut(id) { + session.meta = meta.clone(); + } + self.persist_meta(&meta, cx); + } + + pub fn reorder_session( + &mut self, + id: &str, + section: ThreadSection, + order_key: String, + cx: &mut HostCx, + ) { + let Some(mut meta) = self.find_meta(id).filter(|meta| meta.archived_at.is_none()) else { + return; + }; + let slot = match section { + ThreadSection::Pinned => &mut meta.pin_order, + ThreadSection::Active => &mut meta.active_order, + ThreadSection::Settled => return, + }; + if slot.as_deref() == Some(order_key.as_str()) { + return; + } + *slot = Some(order_key); + // Arranging the active list is not thread activity. + if section == ThreadSection::Pinned { + meta.updated_at = now_secs(); + } + if let Some(session) = self.resident_mut(id) { + session.meta = meta.clone(); + } + self.persist_meta(&meta, cx); + } + pub fn set_auto_settle(&mut self, id: &str, enabled: bool, cx: &mut HostCx) { let Some(mut meta) = self.find_meta(id).filter(|meta| meta.archived_at.is_none()) else { return; diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 9fe5ba561..957b6ba1c 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -10992,6 +10992,176 @@ fn settling_detaches_only_its_own_provider_and_releases_its_parent() { }); } +/// A pin promotes a settled thread without reopening it, holds an idle +/// thread out of automatic settlement, and a settle removes the pin and the +/// thread's place in the active order. +#[test] +fn pinning_promotes_and_exempts_until_a_settle_unpins() { + use tcode_core::project::SettledOverride; + let cx = &mut TestAppContext::default(); + let store = TestStore::new("pinned-threads"); + let stamp = now_millis() - 10 * 86_400_000; + for (id, settled) in [("parked", true), ("idle", false), ("arranged", false)] { + let mut meta = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); + meta.id = id.into(); + meta.created_at = stamp / 1000; + meta.settled_at = settled.then_some(stamp / 1000); + meta.settled_override = settled.then_some(SettledOverride::Settled); + store.upsert_meta(&meta).unwrap(); + store + .append_event( + id, + stamp, + &AgentEvent::ItemCompleted(ThreadItem { + id: "old-request".into(), + parent_item_id: None, + content: ItemContent::UserMessage { + text: "Earlier work".into(), + context_len: None, + attachments: vec![], + }, + }), + ) + .unwrap(); + } + let state = cx.new_entity(TestClientState::new((*store).clone())); + state.update(cx, |state, _| state.settings.auto_settle_after_days = None); + let command = |state: &TestEntity, cx: &mut TestAppContext, id, command| { + through_pipe( + state, + cx, + id, + tcode_protocol::ClientPayload::Command(command), + ) + .into_iter() + .find_map(|message| match message { + HostMessage::Ack { id: acked, result } if acked == id => Some(result), + _ => None, + }) + .unwrap() + .map(|_| ()) + .map_err(|error| error.code) + }; + + command( + &state, + cx, + 1, + Command::PinSession { + session_id: "parked".into(), + order_key: Some("n".into()), + }, + ) + .unwrap(); + state.read(|state| { + let parked = state.find_meta("parked").unwrap(); + assert!(!parked.is_settled()); + assert_eq!(parked.settled_override, Some(SettledOverride::Active)); + assert_eq!(parked.unsettled_at, None, "a pin is not a reopen"); + assert_eq!(parked.pin_order.as_deref(), Some("n")); + }); + command( + &state, + cx, + 2, + Command::PinSession { + session_id: "idle".into(), + order_key: None, + }, + ) + .unwrap(); + assert_eq!( + command( + &state, + cx, + 3, + Command::ReorderActive { + session_id: "idle".into(), + order_key: "m".into(), + }, + ), + Err("not_active".into()) + ); + assert_eq!( + command( + &state, + cx, + 4, + Command::ReorderPinned { + session_id: "arranged".into(), + order_key: "m".into(), + }, + ), + Err("not_pinned".into()) + ); + command( + &state, + cx, + 5, + Command::ReorderActive { + session_id: "arranged".into(), + order_key: "m".into(), + }, + ) + .unwrap(); + + state.dispatch_command( + cx, + 6, + Command::PatchSettings { + patch: SettingsPatch::AutoSettleAfterDays(Some(3.0)), + }, + ); + cx.run_until(|state| { + state + .find_meta("arranged") + .is_some_and(|meta| meta.is_settled()) + }); + state.read(|state| { + let arranged = state.find_meta("arranged").unwrap(); + assert_eq!( + arranged.active_order, None, + "settling clears the arrangement" + ); + let idle = state.find_meta("idle").unwrap(); + assert!(!idle.is_settled(), "a pinned thread is exempt"); + assert!(!state.find_meta("parked").unwrap().is_settled()); + }); + + command( + &state, + cx, + 7, + Command::UnpinSession { + session_id: "parked".into(), + }, + ) + .unwrap(); + state.read(|state| { + let parked = state.find_meta("parked").unwrap(); + assert_eq!((parked.pinned_at, parked.pin_order), (None, None)); + assert_eq!( + parked.settled_override, + Some(SettledOverride::Active), + "unpinning keeps the thread active" + ); + }); + command( + &state, + cx, + 8, + Command::SettleSession { + session_id: "idle".into(), + }, + ) + .unwrap(); + state.read(|state| { + let idle = state.find_meta("idle").unwrap(); + assert!(idle.is_settled()); + assert_eq!(idle.pinned_at, None, "settling unpins"); + }); +} + #[test] fn github_secret_command_persists_separately_and_settings_never_replicate_it() { let cx = &mut TestAppContext::default(); diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index b45500882..814297284 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, OnceLock}; use tcode_client::HostLink; +use tcode_core::thread_sort::ThreadSection; use tcode_protocol::{ ClientMessage, ClientPayload, Command, CommandResponse, HostMessage, Principal, ProtocolError, Query, QueryResponse, decode_client_line, @@ -684,6 +685,19 @@ fn dispatch_command(app: &mut AppState, cx: &mut HostCx, command: Command) -> Co enabled, } => app.set_auto_settle(&session_id, enabled, cx), Command::UnsettleSession { session_id } => app.unsettle_session(&session_id, cx), + Command::PinSession { + session_id, + order_key, + } => app.pin_session(&session_id, order_key, cx), + Command::UnpinSession { session_id } => app.unpin_session(&session_id, cx), + Command::ReorderPinned { + session_id, + order_key, + } => app.reorder_session(&session_id, ThreadSection::Pinned, order_key, cx), + Command::ReorderActive { + session_id, + order_key, + } => app.reorder_session(&session_id, ThreadSection::Active, order_key, cx), Command::ArchiveSession { session_id } => app.archive_session(&session_id, cx), Command::UnarchiveSession { session_id } => app.unarchive_session(&session_id, cx), Command::RenameSession { session_id, title } => app.rename_session(&session_id, &title, cx), From a7b770d95db71329c63953497e09e8a0683bd676 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 9 Oct 2026 02:29:24 +0800 Subject: [PATCH 2/6] feat: pin, drag and arrange threads in the sidebar and on the phone --- crates/core/src/thread_sort.rs | 10 +- crates/ui/src/assets.rs | 6 + crates/ui/src/shell.rs | 39 ++ crates/ui/src/sidebar.rs | 1002 +++++++++++++++++++------- crates/ui/src/sidebar/arrange.rs | 1131 ++++++++++++++++++++++++++++++ crates/ui/src/window_state.rs | 7 +- locales/en.yml | 24 + locales/zh-CN.yml | 24 + 8 files changed, 1999 insertions(+), 244 deletions(-) create mode 100644 crates/ui/src/sidebar/arrange.rs diff --git a/crates/core/src/thread_sort.rs b/crates/core/src/thread_sort.rs index e1b00e8bd..928add125 100644 --- a/crates/core/src/thread_sort.rs +++ b/crates/core/src/thread_sort.rs @@ -89,6 +89,14 @@ impl ThreadSections { ThreadSection::Settled => &self.settled, } } + + pub fn section_mut(&mut self, section: ThreadSection) -> &mut Vec { + match section { + ThreadSection::Pinned => &mut self.pinned, + ThreadSection::Active => &mut self.active, + ThreadSection::Settled => &mut self.settled, + } + } } pub fn partition_threads<'a>( @@ -180,7 +188,7 @@ pub fn spread_order_keys(count: usize) -> Vec { (1..=count) .map(|index| { let mut value = (step * index as f64).round() as u64; - if value % base == 0 { + if value.is_multiple_of(base) { value += 1; } let mut key = vec![b'a'; width]; diff --git a/crates/ui/src/assets.rs b/crates/ui/src/assets.rs index c64ac4c6c..3103f9382 100644 --- a/crates/ui/src/assets.rs +++ b/crates/ui/src/assets.rs @@ -23,6 +23,8 @@ gpui_component_assets::icon_assets!( ] ); +gpui_component_assets::icon_assets!(ThreadArrangeAssets, [Pin, PinOff, GripVertical]); + pub const DM_SANS: &[u8] = include_bytes!("../../../assets/fonts/DMSans[wght].ttf"); #[cfg(not(any(target_os = "macos", target_os = "windows")))] pub const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"); @@ -180,6 +182,9 @@ impl AssetSource for Assets { if let Some(bytes) = PullRequestAssets.load(path)? { return Ok(Some(bytes)); } + if let Some(bytes) = ThreadArrangeAssets.load(path)? { + return Ok(Some(bytes)); + } #[cfg(not(target_arch = "wasm32"))] { ComponentAssets.load(path) @@ -203,6 +208,7 @@ impl AssetSource for Assets { paths.push(DM_SANS_PATH.into()); } paths.extend(PullRequestAssets.list(path)?); + paths.extend(ThreadArrangeAssets.list(path)?); for (name, _) in EXTRA_ICONS { if name.starts_with(path) { paths.push((*name).into()); diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 7b79c055d..99f3d5518 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -157,6 +157,7 @@ impl Render for DestinationView { Destination::SettingsSection | Destination::SettingsThreadRules => { shell.render_settings_page(true, window, cx) } + Destination::ArrangeThreads => shell.render_arrange_page(window, cx), }) }) .unwrap_or_else(|| div().into_any_element()) @@ -1686,6 +1687,44 @@ impl AppShell { .into_any_element() } + fn render_arrange_page(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let Some(sidebar) = self + .attachment + .as_ref() + .map(|attachment| attachment.sidebar.clone()) + else { + return div().into_any_element(); + }; + use crate::widgets::button::{Button, ButtonVariants as _}; + let done = div() + // A control press must not arm the ancestor window drag region. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + Button::new("arrange-done") + .ghost() + .label(crate::tr!("sidebar.arrange_done")) + .on_click(cx.listener(|this, _, window, cx| { + this.back(window, cx); + })), + ) + .into_any_element(); + let body = sidebar.update(cx, |sidebar, cx| sidebar.render_arrange(window, cx)); + v_flex() + .debug_selector(|| "compact-arrange-page".into()) + .size_full() + .bg(crate::material::content_surface(cx)) + .child(nav_bar( + None, + crate::tr!("sidebar.arrange_title").into_owned().into(), + None, + vec![done], + window, + cx, + )) + .child(body) + .into_any_element() + } + fn render_threads_page(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { let back = self.back_control(cx); let Some(attachment) = &self.attachment else { diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index dc613c32c..2e73e3d3d 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -26,6 +26,7 @@ use gpui::{ use gpui_base::{Scrollbar, StyledExt as _, h_flex, v_flex}; use serde::Deserialize; use tcode_core::settlement::AgentDelivery; +use tcode_core::thread_sort::{ThreadSection, ThreadSections, partition_threads}; use tcode_protocol::{Command, ThreadExportFormat}; use tcode_core::{ @@ -39,6 +40,9 @@ use crate::time::{humanize_ago, now_secs}; use crate::window_drag_area; use crate::window_state::{Destination, Route, WindowState}; +mod arrange; +use arrange::{DraggedThread, DropZone, ThreadDrag}; + /// The provider mark behind a thread row: its provider's glyph at this alpha, /// sized to the row height minus this vertical inset so it sits inside the row. const PROVIDER_MARK_ALPHA: f32 = 0.32; @@ -168,23 +172,19 @@ fn waiting_reason(store: &WorkspaceStore, session_id: &str, watching: &[u64]) -> parts.join(" · ") } -fn partition_settled(sessions: &[SessionMeta]) -> (Vec, Vec) { - tcode_core::thread_sort::partition_threads(sessions) -} - -/// The rows a list shows, narrowed to one project when a filter is set. -fn project_threads<'a>(sessions: &'a [SessionMeta], project: Option<&str>) -> Vec<&'a SessionMeta> { - sessions - .iter() - .filter(|meta| project.is_none_or(|id| meta.project_id.as_deref() == Some(id))) - .collect() +/// The rows a list shows by section, narrowed to one project when a filter is set. +fn project_threads<'a>( + sessions: &'a [SessionMeta], + project: Option<&str>, +) -> ThreadSections<&'a SessionMeta> { + partition_threads( + sessions + .iter() + .filter(|meta| project.is_none_or(|id| meta.project_id.as_deref() == Some(id))), + ) } -fn animate_flat_thread_position( - row: gpui::Div, - session_id: &str, - target_top: f32, -) -> impl IntoElement + use<> { +fn animate_flat_thread_position(row: gpui::Div, session_id: &str, target_top: f32) -> gpui::Div { // `list` positions each item root explicitly during prepaint, which // overrides relative offsets applied to that root. Keep an unanimated // outer item for the list to position and move the row inside it instead. @@ -246,17 +246,33 @@ struct ThreadAutoSettle(String, bool); #[action(namespace = tcode, no_json)] pub(crate) struct ThreadUndo; +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_thread, no_json)] +struct ThreadPin(String); +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_thread, no_json)] +struct ThreadUnpin(String); +/// Move a thread one place up (`false`) or down (`true`) within its section. +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_thread, no_json)] +struct ThreadMove(String, bool); +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_thread, no_json)] +struct ThreadArrange(String); + #[derive(Clone, Copy, PartialEq, Eq)] enum UndoKind { Settle, + Unpin, Archive, } struct LifecycleUndo { kind: UndoKind, entries: Vec, } +/// What reverses one undoable action, sent in order. struct UndoEntry { - command: Command, + commands: Vec, reopen: Option, } #[derive(Action, Clone, PartialEq, Eq, Deserialize)] @@ -369,6 +385,7 @@ struct CompactProjectRow { #[derive(Clone)] enum CompactListRow { Project(CompactProjectRow), + Caption { key: String, label: SharedString }, Settled { key: String, count: usize }, More { key: String, count: usize }, Empty { key: String }, @@ -380,6 +397,7 @@ impl CompactListRow { fn key(&self) -> &str { match self { Self::Project(row) => &row.row_id, + Self::Caption { key, .. } => key, Self::Settled { key, .. } => key, Self::More { key, .. } => key, Self::Empty { key } => key, @@ -391,6 +409,9 @@ impl CompactListRow { enum FlatListRow { Thread(Box, f32), + /// The start of Pinned or Active, drawn only while dragging; whether the + /// section is empty. + Boundary(ThreadSection, bool), Settled, More(usize), Empty, @@ -399,11 +420,28 @@ enum FlatListRow { /// A row of the desktop grouped list. Project-scoped rows index into the /// frame's `grouped_sessions`. enum GroupedListRow { - Project { group: usize, collapsed: bool }, + Project { + group: usize, + collapsed: bool, + }, + /// The start of a group's Pinned or Active rows, drawn only while dragging. + Boundary { + group: usize, + section: ThreadSection, + empty: bool, + }, Thread(Box), - More { group: usize, count: usize }, - Empty { group: usize }, - Settled { group: usize, count: usize }, + More { + group: usize, + count: usize, + }, + Empty { + group: usize, + }, + Settled { + group: usize, + count: usize, + }, } impl GroupedListRow { @@ -411,6 +449,12 @@ impl GroupedListRow { let project = |group: &usize| groups[*group].project.id.as_str(); match self { Self::Project { group, .. } => ("project", project(group)), + Self::Boundary { + group, + section: ThreadSection::Pinned, + .. + } => ("pinned", project(group)), + Self::Boundary { group, .. } => ("active", project(group)), Self::Thread(meta) => ("thread", &meta.id), Self::More { group, .. } => ("more", project(group)), Self::Empty { group } => ("empty", project(group)), @@ -434,6 +478,12 @@ pub struct SessionsSidebar { expanded_settled: HashSet, settled_limits: HashMap, lifecycle_undo: Option, + drag: Option, + /// The project the Arrange threads page shows; `None` is every project. + arrange_scope: Option, + arrange_settled_expanded: bool, + arrange_list_state: ListState, + arrange_row_keys: Vec, last_selected: Option, /// Optional project id filter for the session-local flat list. project_filter: Option, @@ -458,9 +508,9 @@ pub struct SessionsSidebar { /// Included desktop rows plus the pre-disclosure counts used by list controls. struct ThreadRows<'a> { + pinned: Vec<&'a SessionMeta>, active: Vec<&'a SessionMeta>, settled: Vec<&'a SessionMeta>, - active_count: usize, settled_count: usize, settled_hidden_count: usize, } @@ -485,45 +535,30 @@ fn session_flags(sessions: &[SessionMeta], store: &WorkspaceStore) -> HashMap( + /// The rows `scope` shows, as a drag over them would leave them. + fn thread_rows<'a>( &self, - active: &'a [SessionMeta], - settled: &'a [SessionMeta], + sessions: &'a [SessionMeta], + scope: Option<&str>, + key: &str, ) -> ThreadRows<'a> { - let active = project_threads(active, self.project_filter.as_deref()); - let mut settled = project_threads(settled, self.project_filter.as_deref()); - let active_count = active.len(); - let settled_count = settled.len(); - self.limit_settled_rows("recent", &mut settled); - let settled_hidden_count = settled_count - settled.len(); - ThreadRows { + let sections = project_threads(sessions, scope); + let ThreadSections { + pinned, active, - settled, - active_count, - settled_count, - settled_hidden_count, - } - } - - fn group_thread_rows<'a>( - &self, - project_id: &str, - active: &'a [SessionMeta], - settled: &'a [SessionMeta], - ) -> ThreadRows<'a> { - let active = project_threads(active, None); - let active_count = active.len(); + mut settled, + } = match &self.drag { + Some(drag) => drag.preview(scope, sections), + None => sections, + }; let settled_count = settled.len(); - let mut settled = project_threads(settled, None); - let eligible = settled.len(); - self.limit_settled_rows(project_id, &mut settled); - let settled_hidden_count = eligible - settled.len(); + self.limit_settled_rows(key, &mut settled); ThreadRows { + pinned, active, + settled_hidden_count: settled_count - settled.len(), settled, - active_count, settled_count, - settled_hidden_count, } } @@ -536,28 +571,29 @@ impl SessionsSidebar { if collapsed { continue; } - let (active, settled) = partition_settled(&project.sessions); - let threads = self.group_thread_rows(project_id, &active, &settled); - rows.extend( - threads - .active - .into_iter() - .map(|meta| GroupedListRow::Thread(Box::new(meta.clone()))), - ); + let threads = self.thread_rows(&project.sessions, Some(project_id), project_id); + let thread = |meta: &&SessionMeta| GroupedListRow::Thread(Box::new((*meta).clone())); + rows.push(GroupedListRow::Boundary { + group, + section: ThreadSection::Pinned, + empty: threads.pinned.is_empty(), + }); + rows.extend(threads.pinned.iter().map(thread)); + rows.push(GroupedListRow::Boundary { + group, + section: ThreadSection::Active, + empty: threads.active.is_empty(), + }); + rows.extend(threads.active.iter().map(thread)); if threads.settled_count > 0 { - if threads.active_count == 0 { + if threads.pinned.is_empty() && threads.active.is_empty() { rows.push(GroupedListRow::Empty { group }); } rows.push(GroupedListRow::Settled { group, count: threads.settled_count, }); - rows.extend( - threads - .settled - .into_iter() - .map(|meta| GroupedListRow::Thread(Box::new(meta.clone()))), - ); + rows.extend(threads.settled.iter().map(thread)); if let Some(count) = self.settled_more_count(project_id, threads.settled_hidden_count) { @@ -588,11 +624,11 @@ impl SessionsSidebar { match store.sidebar_layout() { SidebarLayout::Flat => { let sessions = store.flat_sessions(); - let (active, settled) = partition_settled(&sessions); - let rows = self.flat_thread_rows(&active, &settled); + let rows = self.thread_rows(&sessions, self.project_filter.as_deref(), "recent"); ids.extend( - rows.active + rows.pinned .into_iter() + .chain(rows.active) .chain(rows.settled) .map(|meta| meta.id.clone()), ); @@ -686,6 +722,11 @@ impl SessionsSidebar { expanded_settled: HashSet::new(), settled_limits: HashMap::new(), lifecycle_undo: None, + drag: None, + arrange_scope: None, + arrange_settled_expanded: false, + arrange_list_state: ListState::new(0, ListAlignment::Top, px(120.)), + arrange_row_keys: Vec::new(), last_selected: None, project_filter: None, settled_scope: None, @@ -950,51 +991,82 @@ impl SessionsSidebar { } fn on_settle(&mut self, action: &ThreadSettle, window: &mut Window, cx: &mut Context) { + self.settle_thread(&action.0, window, cx); + } + + fn find_session(&self, id: &str, cx: &App) -> Option { + self.store + .read(cx) + .sidebar_sessions() + .into_iter() + .find(|meta| meta.id == id) + } + + /// Settle a thread; its undo also restores a pin it loses. + fn settle_thread(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + let pin = self + .find_session(id, cx) + .filter(|meta| meta.pinned_at.is_some()) + .map(|meta| Command::PinSession { + session_id: id.to_owned(), + order_key: meta.pin_order, + }); + let undo = std::iter::once(Command::UnsettleSession { + session_id: id.to_owned(), + }) + .chain(pin) + .collect(); self.perform_lifecycle( - Command::SettleSession { - session_id: action.0.clone(), - }, - Command::UnsettleSession { - session_id: action.0.clone(), - }, + vec![Command::SettleSession { + session_id: id.to_owned(), + }], + Some((UndoKind::Settle, undo)), window, cx, ); } + /// Send `commands` in order and stop at the first refusal; once every + /// one is accepted, offer `undo` in the shared toast. fn perform_lifecycle( &mut self, - command: Command, - reverse: Command, + commands: Vec, + undo: Option<(UndoKind, Vec)>, window: &mut Window, cx: &mut Context, ) { - let reopen = match &command { - Command::ArchiveSession { session_id } + let reopen = match commands.first() { + Some(Command::ArchiveSession { session_id }) if self.store.read(cx).active_session_id().as_ref() == Some(session_id) => { Some(session_id.clone()) } _ => None, }; - let settling = matches!(command, Command::SettleSession { .. }); - let request = self - .store - .update(cx, |store, cx| store.command(command, cx)); - cx.spawn_in(window, async move |this, cx| match request.await { - Ok(_) => { - let _ = this.update_in(cx, |this, window, cx| { - this.push_lifecycle_undo(reverse, reopen, window, cx); - }); + let store = self.store.clone(); + cx.spawn_in(window, async move |this, cx| { + for command in commands { + let settling = matches!(command, Command::SettleSession { .. }); + let Ok(request) = + cx.update(|_, cx| store.update(cx, |store, cx| store.command(command, cx))) + else { + return; + }; + if let Err(error) = request.await { + let _ = this.update_in(cx, |_, window, cx| { + let message = if settling && error.code == "thread_busy" { + crate::tr!("sidebar.settle_refused").into_owned() + } else { + error.message + }; + window.push_notification(Notification::warning(message), cx) + }); + return; + } } - Err(error) => { - let _ = this.update_in(cx, |_, window, cx| { - let message = if settling && error.code == "thread_busy" { - crate::tr!("sidebar.settle_refused").into_owned() - } else { - error.message - }; - window.push_notification(Notification::warning(message), cx) + if let Some((kind, commands)) = undo { + let _ = this.update_in(cx, |this, window, cx| { + this.push_lifecycle_undo(kind, UndoEntry { commands, reopen }, window, cx); }); } }) @@ -1003,16 +1075,11 @@ impl SessionsSidebar { fn push_lifecycle_undo( &mut self, - command: Command, - reopen: Option, + kind: UndoKind, + entry: UndoEntry, window: &mut Window, cx: &mut Context, ) { - let kind = if matches!(command, Command::UnsettleSession { .. }) { - UndoKind::Settle - } else { - UndoKind::Archive - }; let live = window.has_notification::(cx); if !live || self @@ -1025,16 +1092,14 @@ impl SessionsSidebar { entries: vec![], }); } - self.lifecycle_undo - .as_mut() - .unwrap() - .entries - .push(UndoEntry { command, reopen }); + self.lifecycle_undo.as_mut().unwrap().entries.push(entry); let count = self.lifecycle_undo.as_ref().unwrap().entries.len(); let title = match (kind, count) { (UndoKind::Settle, 1) => crate::tr!("sidebar.undo_settled_one"), + (UndoKind::Unpin, 1) => crate::tr!("sidebar.undo_unpinned_one"), (UndoKind::Archive, 1) => crate::tr!("sidebar.undo_archived_one"), (UndoKind::Settle, _) => crate::tr!("sidebar.undo_settled", count = count), + (UndoKind::Unpin, _) => crate::tr!("sidebar.undo_unpinned", count = count), (UndoKind::Archive, _) => crate::tr!("sidebar.undo_archived", count = count), } .into_owned(); @@ -1082,23 +1147,24 @@ impl SessionsSidebar { let store = self.store.clone(); cx.spawn_in(window, async move |_, cx| { for entry in undo.entries.into_iter().rev() { - let command = entry.command; - let Ok(request) = - cx.update(|_, cx| store.update(cx, |store, cx| store.command(command, cx))) - else { - return; - }; - if let Err(error) = request.await { - let _ = cx.update(|window, cx| { - window.push_notification( - Notification::error(crate::tr!( - "sidebar.undo_failed", - reason = error.message - )), - cx, - ) - }); - return; + for command in entry.commands { + let Ok(request) = + cx.update(|_, cx| store.update(cx, |store, cx| store.command(command, cx))) + else { + return; + }; + if let Err(error) = request.await { + let _ = cx.update(|window, cx| { + window.push_notification( + Notification::error(crate::tr!( + "sidebar.undo_failed", + reason = error.message + )), + cx, + ) + }); + return; + } } if let Some(id) = entry.reopen { store.update(cx, |store, _| store.select_session(id)); @@ -1207,9 +1273,15 @@ impl SessionsSidebar { &self, key: &str, count: usize, + scope: Option<&str>, cx: &mut Context, ) -> gpui::AnyElement { let expanded = self.expanded_settled.contains(key); + let color = match &self.drag { + None => cx.theme().muted_foreground, + Some(drag) if drag.target(scope) == Some(ThreadSection::Settled) => cx.theme().primary, + Some(_) => cx.theme().sidebar_foreground, + }; let key = key.to_string(); crate::material::accessible_clickable( h_flex(), @@ -1232,7 +1304,7 @@ impl SessionsSidebar { .gap_2() .px_3() .text_size(px(12.)) - .text_color(cx.theme().muted_foreground) + .text_color(color) .cursor_pointer() .on_click(cx.listener(move |this, _, _, cx| { if !this.expanded_settled.remove(&key) { @@ -1243,7 +1315,7 @@ impl SessionsSidebar { this.compact_model_dirty = true; cx.notify(); })) - .child(collapse_chevron(!expanded, cx)) + .child(collapse_chevron(!expanded, cx).text_color(color)) .child(crate::tr!("sidebar.settled")) .child(count.to_string()) .into_any_element() @@ -1458,10 +1530,13 @@ impl SessionsSidebar { let session_id = session_id.to_string(); if store.read(cx).settings().skip_delete_confirmation { self.perform_lifecycle( - Command::ArchiveSession { + vec![Command::ArchiveSession { session_id: session_id.clone(), - }, - Command::UnarchiveSession { session_id }, + }], + Some(( + UndoKind::Archive, + vec![Command::UnarchiveSession { session_id }], + )), window, cx, ); @@ -1484,12 +1559,15 @@ impl SessionsSidebar { .on_ok(move |_, window, cx| { let _ = sidebar.update(cx, |this, cx| { this.perform_lifecycle( - Command::ArchiveSession { + vec![Command::ArchiveSession { session_id: session_id.clone(), - }, - Command::UnarchiveSession { - session_id: session_id.clone(), - }, + }], + Some(( + UndoKind::Archive, + vec![Command::UnarchiveSession { + session_id: session_id.clone(), + }], + )), window, cx, ) @@ -2081,30 +2159,67 @@ impl SessionsSidebar { active_id: Option<&str>, cx: &mut Context, ) -> gpui::AnyElement { - let element = match row { - GroupedListRow::Project { group, collapsed } => { - self.render_group_header(&groups[*group], flags, *collapsed, cx) + let project = |group: &usize| Some(groups[*group].project.id.clone()); + let item = v_flex().w_full().px_2().when( + index > 0 && matches!(row, GroupedListRow::Project { .. }), + |item| item.pt(px(2.)), + ); + match row { + GroupedListRow::Project { group, collapsed } => item + .child(self.render_group_header(&groups[*group], flags, *collapsed, cx)) + .into_any_element(), + GroupedListRow::Boundary { + group, + section, + empty, + } => item + .child(self.render_drag_boundary(*section, *empty, project(group), cx)) + .into_any_element(), + GroupedListRow::Thread(meta) => { + let item = if self + .drag + .as_ref() + .is_some_and(|drag| drag.session_id == meta.id) + { + item.child(Self::render_drag_gap(GROUPED_ROW_HEIGHT)) + } else { + item.child(self.render_thread( + meta, + flags, + active_id == Some(meta.id.as_str()), + cx, + )) + }; + let zone = DropZone::Row { + id: meta.id.clone(), + section: tcode_core::thread_sort::thread_section(meta), + }; + self.drop_zone(item, zone, meta.project_id.clone(), 8., cx) + .into_any_element() } - GroupedListRow::Thread(meta) => self - .render_thread(meta, flags, active_id == Some(meta.id.as_str()), cx) + GroupedListRow::More { group, count } => item + .child(self.render_settled_more(&groups[*group].project.id, *count, cx)) .into_any_element(), - GroupedListRow::More { group, count } => { - self.render_settled_more(&groups[*group].project.id, *count, cx) + GroupedListRow::Empty { .. } => { + item.child(self.render_active_empty(cx)).into_any_element() } - GroupedListRow::Empty { .. } => self.render_active_empty(cx), GroupedListRow::Settled { group, count } => { - self.render_settled_header(&groups[*group].project.id, *count, cx) + let header = self.render_settled_header( + &groups[*group].project.id, + *count, + project(group).as_deref(), + cx, + ); + self.drop_zone( + item.child(header), + DropZone::Settled, + project(group), + 8., + cx, + ) + .into_any_element() } - }; - v_flex() - .w_full() - .px_2() - .when( - index > 0 && matches!(row, GroupedListRow::Project { .. }), - |item| item.pt(px(2.)), - ) - .child(element) - .into_any_element() + } } fn thread_row_state( @@ -2190,7 +2305,7 @@ impl SessionsSidebar { cx: &mut Context, ) -> gpui::Stateful { let session_id = state.session_id.clone(); - crate::material::accessible_clickable( + let row = crate::material::accessible_clickable( base, row_id, Role::Button, @@ -2218,8 +2333,15 @@ impl SessionsSidebar { this.window_state .update(cx, |state, cx| state.open_thread(cx)); cx.notify(); - })) - .when(state.waiting_for_approval, |row| { + })); + let row = match self + .dragged_thread(meta, state, cx) + .filter(|_| !self.compact(cx)) + { + Some(dragged) => Self::drag_source(row, dragged, cx), + None => row, + }; + row.when(state.waiting_for_approval, |row| { row.tooltip(|window, cx| { Tooltip::new(crate::tr!("sidebar.waiting_approval_tooltip").into_owned()) .build(window, cx) @@ -2281,6 +2403,57 @@ impl SessionsSidebar { .into_any_element() } + /// A pinned row's pin; on row hover it becomes the Unpin button. + fn pin_glyph( + &self, + meta: &SessionMeta, + row_key: &str, + cx: &mut Context, + ) -> Option { + if meta.pinned_at.is_none() || meta.is_settled() { + return None; + } + let id = meta.id.clone(); + let label = crate::tr!("sidebar.unpin_tooltip"); + Some( + div() + .relative() + .flex_none() + .size(px(20.)) + .flex() + .items_center() + .justify_center() + .child( + div() + .group_hover(row_key.to_owned(), |glyph| glyph.invisible()) + .child( + Icon::new(IconName::Pin) + .size(px(12.)) + .text_color(cx.theme().muted_foreground), + ), + ) + .child( + Button::new(SharedString::from(format!("unpin-thread-{id}"))) + .ghost() + .xsmall() + .icon(Icon::new(IconName::PinOff).text_color(cx.theme().muted_foreground)) + .aria_label(label.clone()) + .tooltip(label) + .absolute() + .top_0() + .left_0() + // Opacity keeps the button a tab stop, as the settle button does. + .opacity(0.) + .group_hover(row_key.to_owned(), |button| button.opacity(1.)) + .focus_visible(|button| button.opacity(1.)) + .on_click(cx.listener(move |this, _, window, cx| { + crate::widgets::stop_click_propagation(window, cx); + this.unpin_thread(&id, window, cx); + })), + ), + ) + } + /// Status text and colour shared by every thread row shape, and whether /// it is the Waiting status. fn thread_status_label( @@ -2368,15 +2541,19 @@ impl SessionsSidebar { } fn thread_context_menu( + &self, row: gpui::Stateful, + meta: &SessionMeta, state: &ThreadRowState, running: bool, - settled: bool, compact: bool, - share: Option, - scope: &crate::store::WorkspaceScope, + cx: &mut Context, ) -> gpui::AnyElement { - let scope = scope.clone(); + let section = tcode_core::thread_sort::thread_section(meta); + let settled = section == ThreadSection::Settled; + let share = self.share_target(meta.project_id.as_deref(), cx); + let scope = self.store.read(cx).scope().clone(); + let sidebar = cx.entity().downgrade(); let session_id = state.session_id.clone(); let can_fork = state.menu_can_fork; let is_worktree = state.is_worktree; @@ -2385,6 +2562,11 @@ impl SessionsSidebar { let auto_settle_enabled = state.auto_settle_enabled; row.context_menu(move |menu, _window, cx| { let id = session_id.clone(); + // Move up / Move down stay within the section the list shows. + let moves = sidebar + .upgrade() + .filter(|_| !settled && scope.is_full()) + .and_then(|sidebar| sidebar.read(cx).move_bounds(&id, cx)); menu.menu( crate::tr!("sidebar.ctx_rename").into_owned(), Box::new(ThreadRename(id.clone())), @@ -2419,6 +2601,18 @@ impl SessionsSidebar { Box::new(ThreadMarkUnread(id.clone())), ) .separator() + .menu( + if section == ThreadSection::Pinned { + crate::tr!("sidebar.ctx_unpin").into_owned() + } else { + crate::tr!("sidebar.ctx_pin").into_owned() + }, + if section == ThreadSection::Pinned { + Box::new(ThreadUnpin(id.clone())) as Box + } else { + Box::new(ThreadPin(id.clone())) + }, + ) .menu_with_enable( if settled { crate::tr!("sidebar.ctx_unsettle").into_owned() @@ -2432,6 +2626,24 @@ impl SessionsSidebar { }, settled || (!running && !blocked), ) + .when_some(moves, |menu, (up, down)| { + menu.menu_with_enable( + crate::tr!("sidebar.ctx_move_up").into_owned(), + Box::new(ThreadMove(id.clone(), false)), + up, + ) + .menu_with_enable( + crate::tr!("sidebar.ctx_move_down").into_owned(), + Box::new(ThreadMove(id.clone(), true)), + down, + ) + }) + .when(compact, |menu| { + menu.menu( + crate::tr!("sidebar.ctx_arrange").into_owned(), + Box::new(ThreadArrange(id.clone())), + ) + }) .separator() .label(crate::tr!("sidebar.ctx_auto_settle")) .menu_with_check( @@ -2544,6 +2756,7 @@ impl SessionsSidebar { ) }) .child(self.thread_title_or_input(meta, &state, false, cx)) + .children(self.pin_glyph(meta, &row_key, cx)) .when_some( (state.renaming.is_none()) .then(|| { @@ -2562,16 +2775,7 @@ impl SessionsSidebar { }) }; - let share = self.share_target(meta.project_id.as_deref(), cx); - Self::thread_context_menu( - row, - &state, - working, - meta.is_settled(), - false, - share, - self.store.read(cx).scope(), - ) + self.thread_context_menu(row, meta, &state, working, false, cx) } fn render_flat_thread_right_slot( @@ -2718,7 +2922,7 @@ impl SessionsSidebar { }) .child(title_or_input) .when(!renaming, |line| { - line.child( + line.children(self.pin_glyph(meta, &row_key, cx)).child( self.render_flat_thread_right_slot(meta, &row_key, waiting, !working, cx), ) }); @@ -2790,16 +2994,7 @@ impl SessionsSidebar { row.child(line_one).child(line_two) }; - let share = self.share_target(meta.project_id.as_deref(), cx); - Self::thread_context_menu( - row, - &state, - working, - meta.is_settled(), - false, - share, - self.store.read(cx).scope(), - ) + self.thread_context_menu(row, meta, &state, working, false, cx) } fn render_footer(&self, cx: &mut Context) -> impl IntoElement { @@ -3062,10 +3257,27 @@ impl SessionsSidebar { rows }; let grouped_rows = |project: Option<&str>, recent: bool, key: &str| { - let (active, settled) = partition_settled(&sessions); - let active = project_threads(&active, project); - let settled = project_threads(&settled, project); - let mut rows = thread_rows(active, recent); + let ThreadSections { + pinned, + active, + settled, + } = project_threads(&sessions, project); + let mut rows = Vec::new(); + // A phone has no hover to reveal section labels, so they stay. + if !pinned.is_empty() { + rows.push(CompactListRow::Caption { + key: format!("{key}-pinned"), + label: crate::tr!("sidebar.pinned_caption").into_owned().into(), + }); + rows.extend(thread_rows(pinned, recent)); + if !active.is_empty() { + rows.push(CompactListRow::Caption { + key: format!("{key}-active"), + label: crate::tr!("sidebar.active_caption").into_owned().into(), + }); + } + } + rows.extend(thread_rows(active, recent)); if rows.is_empty() && !settled.is_empty() { rows.push(CompactListRow::Empty { key: format!("{key}-empty"), @@ -3094,7 +3306,10 @@ impl SessionsSidebar { rows.extend(grouped_rows(None, true, "recent")); } else { for group in &groups { - let count = project_threads(&sessions, Some(&group.project.id)).len(); + let count = sessions + .iter() + .filter(|meta| meta.project_id.as_deref() == Some(&group.project.id)) + .count(); let collapsed = groups.len() > 1 && collapsed_projects.contains(&group.project.id); let start = rows.len(); @@ -3285,7 +3500,10 @@ impl SessionsSidebar { self.compact_list_state.clone(), cx.processor(move |this, index: usize, _, cx| match &model.rows[index] { CompactListRow::Settled { key, count } => { - this.render_settled_header(key, *count, cx) + this.render_settled_header(key, *count, None, cx) + } + CompactListRow::Caption { label, .. } => { + crate::material::list_caption(label.clone(), cx).into_any_element() } CompactListRow::More { key, count } => { this.render_settled_more(key.trim_end_matches("-more"), *count, cx) @@ -3354,6 +3572,10 @@ impl SessionsSidebar { .on_action(cx.listener(Self::on_settle)) .on_action(cx.listener(Self::on_auto_settle)) .on_action(cx.listener(Self::on_make_active)) + .on_action(cx.listener(Self::on_pin)) + .on_action(cx.listener(Self::on_unpin)) + .on_action(cx.listener(Self::on_move)) + .on_action(cx.listener(Self::on_arrange)) .on_action(cx.listener(Self::on_archive)) .on_action(cx.listener(Self::on_delete)) .child(self.render_compact_search(cx)) @@ -3581,6 +3803,14 @@ impl SessionsSidebar { }) .child(cached.title.clone()), ) + .when(meta.pinned_at.is_some() && !meta.is_settled(), |row| { + row.child( + Icon::new(IconName::Pin) + .size(px(14.)) + .flex_none() + .text_color(cx.theme().muted_foreground), + ) + }) .when(state.title_generating, |row| { row.child(div().flex_none().child( Spinner::new().xsmall().color(cx.theme().muted_foreground), @@ -3619,16 +3849,7 @@ impl SessionsSidebar { .child(div().flex_none().child(cached.relative_time.clone())), ), ); - let share = self.share_target(meta.project_id.as_deref(), cx); - Self::thread_context_menu( - row, - state, - working, - meta.is_settled(), - true, - share, - self.store.read(cx).scope(), - ) + self.thread_context_menu(row, meta, state, working, true, cx) } } @@ -3760,6 +3981,7 @@ impl Render for SessionsSidebar { self.compact_model_dirty = true; } self.reveal_selected_settled(cx); + self.clear_finished_drag(cx); let spaces = spaces::for_store(&self.store, cx); self.spaces_observer.watch(spaces.as_ref(), cx); if self.compact(cx) { @@ -3841,9 +4063,18 @@ impl Render for SessionsSidebar { .min_h_0() .pb_2(); v_flex() + .id("grouped-thread-list") .flex_1() .min_h_0() .relative() + .on_drop(cx.listener(|this, _: &DraggedThread, window, cx| { + this.drop_thread(window, cx) + })) + .on_drag_move(cx.listener( + |this, event: &gpui::DragMoveEvent, _, cx| { + this.drag_auto_scroll(event, cx) + }, + )) .child(crate::scroll::page_viewport( "grouped-thread-bounce", crate::wheel_easing::Handle::List(self.grouped_list_state.clone()), @@ -3864,15 +4095,15 @@ impl Render for SessionsSidebar { ) } SidebarLayout::Flat => { - let (active, settled) = partition_settled(&flat_sessions); + let scope = self.project_filter.clone(); let ThreadRows { - active: visible, + pinned, + active, settled: settled_visible, settled_count, settled_hidden_count, - .. - } = self.flat_thread_rows(&active, &settled); - if visible.is_empty() && settled_count == 0 { + } = self.thread_rows(&flat_sessions, scope.as_deref(), "recent"); + if pinned.is_empty() && active.is_empty() && settled_count == 0 { // An active project filter can empty the list while threads // exist; that state gets its own hint, not the no-projects one. let hint = if !self.store.read(cx).scope().is_full() && flat_sessions.is_empty() @@ -3904,27 +4135,32 @@ impl Render for SessionsSidebar { .into_any_element(); (self.render_flat_header(cx).into_any_element(), thread_list) } else { - let top_offsets = (0..).map(|index| index as f32 * FLAT_ROW_HEIGHT); - let settled_top = - visible.len() as f32 * FLAT_ROW_HEIGHT + SETTLED_HEADER_HEIGHT; - let mut visible = visible - .into_iter() - .cloned() - .zip(top_offsets.clone()) - .map(|(meta, offset)| FlatListRow::Thread(Box::new(meta), offset)) - .collect::>(); + // Each row's resting top, so a row moved by a drag or a + // section change springs from where it was. + let mut top = 0.; + let mut visible = Vec::new(); + for (section, rows) in [ + (ThreadSection::Pinned, &pinned), + (ThreadSection::Active, &active), + ] { + let empty = rows.is_empty(); + visible.push(FlatListRow::Boundary(section, empty)); + top += self.drag_boundary_height(section, empty, scope.as_deref()); + for meta in rows.iter() { + visible.push(FlatListRow::Thread(Box::new((*meta).clone()), top)); + top += FLAT_ROW_HEIGHT; + } + } if settled_count > 0 { - if visible.is_empty() { + if pinned.is_empty() && active.is_empty() { visible.push(FlatListRow::Empty); } visible.push(FlatListRow::Settled); - visible.extend( - settled_visible - .into_iter() - .cloned() - .zip(top_offsets.map(|offset| offset + settled_top)) - .map(|(meta, offset)| FlatListRow::Thread(Box::new(meta), offset)), - ); + top += SETTLED_HEADER_HEIGHT; + for meta in settled_visible { + visible.push(FlatListRow::Thread(Box::new(meta.clone()), top)); + top += FLAT_ROW_HEIGHT; + } } if let Some(count) = self.settled_more_count("recent", settled_hidden_count) { visible.push(FlatListRow::More(count)); @@ -3945,8 +4181,34 @@ impl Render for SessionsSidebar { }; let (meta, target_top) = match row { FlatListRow::Thread(meta, top) => (meta, top), + FlatListRow::Boundary(section, empty) => { + return div() + .w_full() + .px_2() + .child(this.render_drag_boundary( + *section, + *empty, + scope.clone(), + cx, + )) + .into_any_element(); + } FlatListRow::Settled => { - return this.render_settled_header("recent", settled_count, cx); + let header = this.render_settled_header( + "recent", + settled_count, + scope.as_deref(), + cx, + ); + return this + .drop_zone( + div().w_full().child(header), + DropZone::Settled, + scope.clone(), + 0., + cx, + ) + .into_any_element(); } FlatListRow::More(count) => { return this.render_settled_more("recent", *count, cx); @@ -3954,26 +4216,40 @@ impl Render for SessionsSidebar { FlatListRow::Empty => return this.render_active_empty(cx), }; let target_top = *target_top; - let project_name = meta - .project_id + let row = div().w_full().px_2().pb(px(2.)); + let row = if this + .drag .as_ref() - .and_then(|project_id| project_names.get(project_id)) - .cloned(); - let is_active = active_id.as_deref() == Some(meta.id.as_str()); - let row = - div() - .w_full() - .px_2() - .pb(px(2.)) - .child(this.render_flat_thread( - meta, - &flags, - project_name, - is_active, - cx, - )); - animate_flat_thread_position(row, &meta.id, target_top) - .into_any_element() + .is_some_and(|drag| drag.session_id == meta.id) + { + row.child(Self::render_drag_gap(FLAT_ROW_INNER_HEIGHT)) + } else { + let project_name = meta + .project_id + .as_ref() + .and_then(|project_id| project_names.get(project_id)) + .cloned(); + let is_active = active_id.as_deref() == Some(meta.id.as_str()); + row.child(this.render_flat_thread( + meta, + &flags, + project_name, + is_active, + cx, + )) + }; + let zone = DropZone::Row { + id: meta.id.clone(), + section: tcode_core::thread_sort::thread_section(meta), + }; + this.drop_zone( + animate_flat_thread_position(row, &meta.id, target_top), + zone, + scope.clone(), + 8., + cx, + ) + .into_any_element() }), ) .flex_1() @@ -3982,9 +4258,18 @@ impl Render for SessionsSidebar { ( self.render_flat_header(cx).into_any_element(), v_flex() + .id("flat-thread-list") .flex_1() .min_h_0() .relative() + .on_drop(cx.listener(|this, _: &DraggedThread, window, cx| { + this.drop_thread(window, cx) + })) + .on_drag_move(cx.listener( + |this, event: &gpui::DragMoveEvent, _, cx| { + this.drag_auto_scroll(event, cx) + }, + )) .child(crate::scroll::page_viewport( "flat-thread-bounce", crate::wheel_easing::Handle::List(self.flat_list_state.clone()), @@ -4020,6 +4305,10 @@ impl Render for SessionsSidebar { .on_action(cx.listener(Self::on_settle)) .on_action(cx.listener(Self::on_auto_settle)) .on_action(cx.listener(Self::on_make_active)) + .on_action(cx.listener(Self::on_pin)) + .on_action(cx.listener(Self::on_unpin)) + .on_action(cx.listener(Self::on_move)) + .on_action(cx.listener(Self::on_arrange)) .on_action(cx.listener(Self::on_archive)) .on_action(cx.listener(Self::on_delete)) .on_action(cx.listener(Self::on_project_archive_all)) @@ -5821,4 +6110,235 @@ mod tests { } } } + + /// Pinned rows lead in key order with keyless pins after them, new and + /// reopened rows lead Active, and activity moves nothing. Dragging an + /// active row between two pins pins it with a key between theirs; Escape + /// cancels a drag; settling a pinned thread undoes with its old key. + #[gpui::test] + fn pinned_rows_lead_and_a_drag_or_undo_writes_their_keys(cx: &mut TestAppContext) { + use tcode_protocol::{ + ClientPayload, CommandResponse, EventEnvelope, HostMessage, IndexSnapshot, ServerEvent, + Topic, decode_client_line, encode_line, + }; + let _locale_guard = crate::settings::TestLocaleGuard::acquire(); + crate::settings::apply_locale(Some(crate::LANGUAGE_ENGLISH)); + cx.update(crate::theme::init); + cx.update(|cx| cx.set_reduce_motion(true)); + let (to_host, outgoing) = async_channel::unbounded(); + let (incoming, from_host) = async_channel::unbounded(); + let mut project = Project::from_root(PathBuf::from("/sample")); + project.id = "sample".into(); + let thread = |id: &str, created: u64| { + let mut meta = session(id, None); + meta.project_id = Some("sample".into()); + meta.created_at = created; + meta.updated_at = created; + meta + }; + let pinned = |id: &str, created: u64, key: Option<&str>| { + let mut meta = thread(id, created); + meta.pinned_at = Some(created); + meta.pin_order = key.map(str::to_owned); + meta + }; + let mut reopened = thread("reopened", 10); + reopened.unsettled_at = Some(99); + let mut busy = thread("busy", 50); + busy.updated_at = 1_000; + let mut arranged = thread("arranged", 100); + arranged.active_order = Some("m".into()); + let mut settled = thread("settled", 60); + settled.settled_at = Some(70); + let sessions = vec![ + settled, + arranged, + busy, + reopened, + thread("fresh", 95), + pinned("pin-old", 80, None), + pinned("pin-new", 90, None), + pinned("pin-b", 1, Some("t")), + pinned("pin-a", 2, Some("f")), + ]; + for (topic, event) in [ + ( + Topic::Settings, + ServerEvent::SettingsSnapshot(Default::default()), + ), + ( + Topic::Index, + ServerEvent::IndexSnapshot(IndexSnapshot { + sessions, + projects: vec![project], + summary: Default::default(), + }), + ), + ] { + incoming + .try_send( + encode_line(&HostMessage::Event(EventEnvelope { + request_id: None, + topic, + event, + })) + .unwrap(), + ) + .unwrap(); + } + let deferred = std::iter::from_fn(|| from_host.try_recv().ok()).collect(); + let link = tcode_client::HostLink::new(to_host, from_host); + let store = cx.new(|cx| { + WorkspaceStore::new_attached( + link.clone(), + crate::store::WorkspaceAttachment::Local, + None, + None, + false, + cx, + ) + }); + crate::store::tests::seed_full_scope(&store, &incoming, deferred, cx); + store.update(cx, |store, _| store.select_session("arranged".into())); + // A test store blocks on each command's acknowledgement, so the host + // side answers from its own threads: one pumps the link, one accepts + // and records every lifecycle command. + std::thread::spawn(move || smol::block_on(link.pump())); + let recorded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let host_recorded = recorded.clone(); + let host_incoming = incoming.clone(); + std::thread::spawn(move || { + while let Ok(line) = outgoing.recv_blocking() { + let message = decode_client_line(&line).unwrap(); + if let ClientPayload::Command(command) = message.payload { + if matches!( + command, + Command::PinSession { .. } + | Command::UnpinSession { .. } + | Command::ReorderPinned { .. } + | Command::ReorderActive { .. } + | Command::SettleSession { .. } + | Command::UnsettleSession { .. } + ) { + host_recorded.lock().unwrap().push(command); + } + let ack = HostMessage::Ack { + id: message.id, + result: Ok(CommandResponse::Unit), + }; + let _ = host_incoming.send_blocking(encode_line(&ack).unwrap()); + } + } + }); + while store.read_with(cx, |store, _| store.flat_sessions().is_empty()) { + std::thread::yield_now(); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + } + let window_state = cx.new(|_| WindowState::new(false)); + let mut sidebar = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| SessionsSidebar::new(store.clone(), window_state.clone(), cx)); + sidebar = Some(view.clone()); + gpui_base::Root::new(view, window, cx) + }); + let sidebar = sidebar.unwrap(); + cx.simulate_resize(size(px(393.), px(2000.))); + // The lifecycle commands accepted since the last call. + let sent = |cx: &mut VisualTestContext| { + cx.run_until_parked(); + std::mem::take(&mut *recorded.lock().unwrap()) + }; + sent(cx); + draw(cx); + let order = [ + "pin-a", "pin-b", "pin-new", "pin-old", "reopened", "fresh", "busy", "arranged", + ]; + for compact in [false, true] { + window_state.update(cx, |state, cx| { + state.compact = compact; + cx.notify(); + }); + sidebar.update(cx, |sidebar, _| sidebar.compact_model_dirty = true); + draw(cx); + let ids = sidebar.update(cx, |sidebar, cx| sidebar.navigation_threads(cx)); + assert_eq!(ids, order, "compact={compact}"); + let mut previous = None; + for id in order { + let selector = if compact { + format!("compact-row-{id}") + } else { + format!("sidebar-thread-{id}") + }; + let top = cx.debug_bounds(selector.leak()).unwrap().top(); + assert!(previous.is_none_or(|previous| top > previous), "{id}"); + previous = Some(top); + } + } + window_state.update(cx, |state, cx| { + state.compact = false; + cx.notify(); + }); + draw(cx); + + let left = gpui::MouseButton::Left; + let none = gpui::Modifiers::default(); + let row = |cx: &mut VisualTestContext, id: &str| { + cx.debug_bounds(format!("sidebar-thread-{id}").leak()) + .unwrap() + }; + let start = row(cx, "fresh").center(); + cx.simulate_mouse_down(start, left, none); + cx.simulate_mouse_move(start + gpui::point(px(0.), px(8.)), left, none); + draw(cx); + let target = row(cx, "pin-b"); + let over = gpui::point(target.center().x, target.top() + px(4.)); + cx.simulate_mouse_move(over, left, none); + draw(cx); + cx.simulate_mouse_up(over, left, none); + let commands = sent(cx); + let [ + Command::PinSession { + session_id, + order_key: Some(key), + }, + ] = commands.as_slice() + else { + panic!("{commands:?}"); + }; + assert_eq!(session_id, "fresh"); + assert!("f" < key.as_str() && key.as_str() < "t", "{key}"); + + let start = row(cx, "busy").center(); + cx.simulate_mouse_down(start, left, none); + cx.simulate_mouse_move(start + gpui::point(px(0.), px(8.)), left, none); + draw(cx); + let target = row(cx, "pin-a"); + cx.simulate_mouse_move(target.center(), left, none); + cx.simulate_keystrokes("escape"); + cx.simulate_mouse_up(target.center(), left, none); + assert_eq!(sent(cx), vec![], "Escape cancels the drag"); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.settle_thread("pin-b", window, cx) + }); + assert_eq!( + sent(cx), + vec![Command::SettleSession { + session_id: "pin-b".into() + }] + ); + sidebar.update_in(cx, |sidebar, window, cx| sidebar.undo_lifecycle(window, cx)); + assert_eq!( + sent(cx), + vec![ + Command::UnsettleSession { + session_id: "pin-b".into() + }, + Command::PinSession { + session_id: "pin-b".into(), + order_key: Some("t".into()), + }, + ] + ); + } } diff --git a/crates/ui/src/sidebar/arrange.rs b/crates/ui/src/sidebar/arrange.rs new file mode 100644 index 000000000..c3dc963a8 --- /dev/null +++ b/crates/ui/src/sidebar/arrange.rs @@ -0,0 +1,1131 @@ +//! Pinning and arranging threads. Desktop rows drag within and between the +//! Pinned, Active and Settled sections; the phone's Arrange threads page drags +//! by handle; Move up / Move down write the same keys without a drag. +//! +//! gpui-base has no sortable-list primitive (its dock drag is specific to tab +//! groups), so the drag composes GPUI's `on_drag`, `on_drag_move` and +//! `on_drop`, and GPUI does not cancel a drag on Escape by itself. + +use super::*; +use gpui::{DragMoveEvent, KeystrokeEvent, MouseButton, Pixels, WeakEntity}; +use tcode_core::thread_sort::{order_key_between, plan_reorder, thread_section}; + +const BOUNDARY_LABEL_HEIGHT: f32 = 24.; +const EMPTY_TARGET_HEIGHT: f32 = 36.; +const ARRANGE_HANDLE_SIZE: f32 = 44.; + +/// The drag payload; the sidebar keeps the drag's state in [`ThreadDrag`]. +pub(super) struct DraggedThread { + session_id: String, + from: ThreadSection, + scope: Option, + title: SharedString, + can_settle: bool, + compact: bool, +} + +pub(super) struct ThreadDrag { + pub(super) session_id: String, + from: ThreadSection, + /// The project the drag stays inside; `None` is every project. + scope: Option, + /// Where the thread lands in Pinned or Active, as an index among that + /// section's other rows; `None` leaves it where it was. + gap: Option<(ThreadSection, usize)>, + over: Option, + can_settle: bool, + width: Pixels, + auto_scroll: gpui_base::AutoScroll, + _escape: Subscription, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DragAction { + Pin, + Unpin, + Settle, + Unsettle, +} + +impl DragAction { + fn icon(self) -> IconName { + match self { + Self::Pin => IconName::Pin, + Self::Unpin => IconName::PinOff, + Self::Settle => IconName::CircleCheck, + Self::Unsettle => IconName::Undo2, + } + } + + fn label(self) -> Cow<'static, str> { + match self { + Self::Pin => crate::tr!("sidebar.pin"), + Self::Unpin => crate::tr!("sidebar.unpin"), + Self::Settle => crate::tr!("sidebar.settle"), + Self::Unsettle => crate::tr!("sidebar.unsettle"), + } + } +} + +/// What a row, a section boundary or the Settled shelf is to a drag over it. +#[derive(Clone)] +pub(super) enum DropZone { + Row { + id: String, + section: ThreadSection, + }, + /// The start of Pinned or Active: its label, or its empty target. + Start(ThreadSection), + Settled, +} + +impl ThreadDrag { + fn action(&self) -> Option { + use ThreadSection::*; + match (self.from, self.over?) { + (Settled, Settled) => None, + (_, Settled) => self.can_settle.then_some(DragAction::Settle), + (Pinned, Pinned) | (Active, Active) => None, + (_, Pinned) => Some(DragAction::Pin), + (Pinned, Active) => Some(DragAction::Unpin), + (Settled, Active) => Some(DragAction::Unsettle), + } + } + + /// The sections as they would be after the drop, when the drag is over + /// a list showing `scope`. + pub(super) fn preview<'a>( + &self, + scope: Option<&str>, + mut sections: ThreadSections<&'a SessionMeta>, + ) -> ThreadSections<&'a SessionMeta> { + let Some((section, index)) = self.gap.filter(|_| self.scope.as_deref() == scope) else { + return sections; + }; + let from = sections.section_mut(self.from); + let Some(position) = from.iter().position(|meta| meta.id == self.session_id) else { + return sections; + }; + let meta = from.remove(position); + let to = sections.section_mut(section); + to.insert(index.min(to.len()), meta); + sections + } + + /// The section a list showing `scope` marks as the drop target. + pub(super) fn target(&self, scope: Option<&str>) -> Option { + self.over.filter(|over| { + self.scope.as_deref() == scope && (*over != ThreadSection::Settled || self.can_settle) + }) + } +} + +/// The lifted row that follows the pointer. +pub(super) struct ThreadDragView { + sidebar: WeakEntity, + title: SharedString, + compact: bool, +} + +impl Render for ThreadDragView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (action, width) = self + .sidebar + .upgrade() + .and_then(|sidebar| { + let drag = sidebar.read(cx).drag.as_ref()?; + Some((drag.action(), drag.width)) + }) + .unwrap_or((None, px(240.))); + if self.compact { + return v_flex() + .w(width) + .min_h(px(crate::material::LIST_ROW_MIN_HEIGHT)) + .justify_center() + .px(px(crate::material::COMPACT_PAGE_INSET)) + .py(px(8.)) + .rounded(cx.theme().tokens.radius.lg) + .bg(cx.theme().list_active) + .shadow_lg() + .child( + div() + .text_size(px(15.)) + .line_clamp(2) + .text_color(cx.theme().foreground) + .child(self.title.clone()), + ) + .when_some(action, |card, action| { + card.child( + div() + .text_size(px(13.)) + .text_color(cx.theme().primary) + .child(action.label()), + ) + }) + .into_any_element(); + } + h_flex() + .w(width) + .h(px(FLAT_ROW_INNER_HEIGHT)) + .px(px(THREAD_ROW_PADDING_X)) + .gap_2() + .items_center() + .rounded(cx.theme().tokens.radius.sm) + .bg(cx.theme().list_active) + .shadow_lg() + .text_color(cx.theme().sidebar_foreground) + .child( + truncated_sidebar_label() + .text_size(px(13.)) + .child(self.title.clone()), + ) + .when_some(action, |row, action| { + row.child( + h_flex() + .id("thread-drag-badge") + .role(Role::Status) + .aria_label(action.label()) + .flex_none() + .h(px(20.)) + .px(px(6.)) + .gap_1() + .items_center() + .rounded(cx.theme().tokens.radius.sm) + .border_1() + .border_color(cx.theme().primary.opacity(0.4)) + .bg(cx.theme().primary.opacity(0.1)) + .text_size(px(11.)) + .font_medium() + .text_color(cx.theme().primary) + .child(Icon::new(action.icon()).size(px(12.))) + .child(action.label()), + ) + }) + .into_any_element() + } +} + +impl SessionsSidebar { + /// The project a list shows a thread under: its group in Grouped, the + /// filter in the desktop's flat list, every project otherwise. + pub(super) fn list_scope(&self, meta: &SessionMeta, cx: &App) -> Option { + if self.window_state.read(cx).destination() == Destination::ArrangeThreads { + self.arrange_scope.clone() + } else if self.store.read(cx).sidebar_layout() == SidebarLayout::Grouped { + meta.project_id.clone() + } else if self.compact(cx) { + None + } else { + self.project_filter.clone() + } + } + + /// Every listed thread by section, in display order, and whether each is + /// inside `scope`. + fn scoped_sections( + &self, + scope: Option<&str>, + cx: &App, + ) -> ThreadSections<(SessionMeta, bool)> { + let sessions = self.store.read(cx).flat_sessions(); + let sections = partition_threads(&sessions); + let scoped = |rows: &Vec<&SessionMeta>| { + rows.iter() + .map(|meta| { + let inside = scope.is_none_or(|id| meta.project_id.as_deref() == Some(id)); + ((*meta).clone(), inside) + }) + .collect() + }; + ThreadSections { + pinned: scoped(§ions.pinned), + active: scoped(§ions.active), + settled: scoped(§ions.settled), + } + } + + /// The payload a row starts a drag with, or `None` where rows do not drag: + /// in member scope and while the row is being renamed. + pub(super) fn dragged_thread( + &self, + meta: &SessionMeta, + state: &ThreadRowState, + cx: &App, + ) -> Option { + let store = self.store.read(cx); + (store.scope().is_full() && state.renaming.is_none()).then(|| DraggedThread { + session_id: meta.id.clone(), + from: thread_section(meta), + scope: self.list_scope(meta, cx), + title: meta.title.clone().into(), + can_settle: !store.turn_running_for(&meta.id) + && !store.pending_approval_for(&meta.id) + && !store.pending_user_input_for(&meta.id), + compact: self.compact(cx), + }) + } + + /// Make `element` start a drag of `dragged`. + pub(super) fn drag_source( + element: E, + dragged: DraggedThread, + cx: &Context, + ) -> E { + let sidebar = cx.entity().downgrade(); + element.on_drag(dragged, move |dragged, _, _, cx| { + Self::begin_drag(sidebar.clone(), dragged, cx) + }) + } + + fn begin_drag( + sidebar: WeakEntity, + dragged: &DraggedThread, + cx: &mut App, + ) -> Entity { + let escape = { + let sidebar = sidebar.clone(); + cx.intercept_keystrokes(move |event: &KeystrokeEvent, window, cx| { + if event.keystroke.key == "escape" + && sidebar + .update(cx, |this, cx| this.cancel_drag(window, cx)) + .unwrap_or(false) + { + cx.stop_propagation(); + } + }) + }; + let _ = sidebar.update(cx, |this, cx| { + let gap = (dragged.from != ThreadSection::Settled) + .then(|| { + let sections = this.scoped_sections(dragged.scope.as_deref(), cx); + sections + .section(dragged.from) + .iter() + .filter(|(_, inside)| *inside) + .position(|(meta, _)| meta.id == dragged.session_id) + .map(|index| (dragged.from, index)) + }) + .flatten(); + this.drag = Some(ThreadDrag { + session_id: dragged.session_id.clone(), + from: dragged.from, + scope: dragged.scope.clone(), + gap, + over: Some(dragged.from), + can_settle: dragged.can_settle, + width: px(240.), + auto_scroll: Default::default(), + _escape: escape, + }); + this.compact_model_dirty = true; + cx.notify(); + }); + cx.new(|_| ThreadDragView { + sidebar, + title: dragged.title.clone(), + compact: dragged.compact, + }) + } + + /// Escape: the row returns to its place. + fn cancel_drag(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.drag.take().is_none() { + return false; + } + cx.stop_active_drag(window); + cx.notify(); + true + } + + /// A drag released outside every drop target ends without a drop. + pub(super) fn clear_finished_drag(&mut self, cx: &App) { + if self.drag.is_some() && !cx.has_active_drag() { + self.drag = None; + } + } + + /// Track the pointer over `element`, the unanimated slot of `zone`, so a + /// row sliding through the pointer never moves the gap back. + pub(super) fn drop_zone( + &self, + element: gpui::Div, + zone: DropZone, + scope: Option, + inset: f32, + cx: &Context, + ) -> gpui::Div { + element.on_drag_move::(cx.listener( + move |this, event: &DragMoveEvent, _, cx| { + if event.bounds.contains(&event.event.position) { + let lower = event.event.position.y > event.bounds.center().y; + let width = event.bounds.size.width - px(inset * 2.); + this.drag_over(&zone, scope.as_deref(), lower, width, cx); + } + }, + )) + } + + fn drag_over( + &mut self, + zone: &DropZone, + scope: Option<&str>, + lower: bool, + width: Pixels, + cx: &mut Context, + ) { + let Some(drag) = self.drag.as_ref() else { + return; + }; + let (mut gap, mut over) = (drag.gap, drag.over); + if drag.scope.as_deref() != scope { + over = None; + } else { + match zone { + DropZone::Settled + | DropZone::Row { + section: ThreadSection::Settled, + .. + } => over = Some(ThreadSection::Settled), + DropZone::Start(section) => { + gap = Some((*section, 0)); + over = Some(*section); + } + DropZone::Row { id, .. } if *id == drag.session_id => { + over = Some(gap.map_or(drag.from, |(section, _)| section)); + } + DropZone::Row { id, section } => { + let sections = self.scoped_sections(scope, cx); + let index = sections + .section(*section) + .iter() + .filter(|(meta, inside)| *inside && meta.id != drag.session_id) + .position(|(meta, _)| meta.id == *id); + if let Some(index) = index { + gap = Some((*section, index + usize::from(lower))); + over = Some(*section); + } + } + } + } + let drag = self.drag.as_mut().expect("checked above"); + let row = matches!(zone, DropZone::Row { .. }); + if (drag.gap, drag.over) != (gap, over) || (row && drag.width != width) { + drag.gap = gap; + drag.over = over; + if row { + drag.width = width; + } + self.compact_model_dirty = true; + cx.notify(); + } + } + + /// Scroll the list under a drag held near its top or bottom edge. + pub(super) fn drag_auto_scroll( + &mut self, + event: &DragMoveEvent, + cx: &mut Context, + ) { + let Some(drag) = self.drag.as_mut() else { + return; + }; + let position = event.event.position; + let delta = (event.bounds.left() <= position.x && position.x <= event.bounds.right()) + .then(|| gpui_base::AutoScroll::compute_delta(position.y, event.bounds)) + .flatten(); + drag.auto_scroll.set(delta, cx, |delta, this, cx| { + if let Some(list) = this.drag_list(cx) { + list.scroll_by(delta); + cx.notify(); + } + }); + } + + fn drag_list(&self, cx: &App) -> Option { + self.drag.as_ref()?; + Some( + if self.window_state.read(cx).destination() == Destination::ArrangeThreads { + self.arrange_list_state.clone() + } else { + match self.store.read(cx).sidebar_layout() { + SidebarLayout::Flat => self.flat_list_state.clone(), + SidebarLayout::Grouped => self.grouped_list_state.clone(), + } + }, + ) + } + + pub(super) fn drop_thread(&mut self, window: &mut Window, cx: &mut Context) { + let Some(drag) = self.drag.take() else { + return; + }; + cx.notify(); + match (drag.over, drag.gap) { + (Some(ThreadSection::Settled), _) => { + if drag.action() == Some(DragAction::Settle) { + self.settle_thread(&drag.session_id, window, cx); + } + } + (Some(over), Some((section, index))) if over == section => self.place_thread( + &drag.session_id, + section, + index, + drag.scope.as_deref(), + window, + cx, + ), + _ => {} + } + } + + /// Put a thread at `index` among the other rows of `to` that `scope` + /// shows, pinning, unpinning or un-settling it on the way. Only the moved + /// thread's key is written unless its neighbours need fresh keys; rows + /// outside `scope` keep theirs. + fn place_thread( + &mut self, + id: &str, + to: ThreadSection, + index: usize, + scope: Option<&str>, + window: &mut Window, + cx: &mut Context, + ) { + let Some(meta) = self.find_session(id, cx) else { + return; + }; + let from = thread_section(&meta); + let key = |meta: &SessionMeta| match to { + ThreadSection::Pinned => meta.pin_order.clone(), + _ => meta.active_order.clone(), + }; + let sections = self.scoped_sections(scope, cx); + let rows = sections.section(to); + if from == to + && rows + .iter() + .filter(|(_, inside)| *inside) + .position(|(meta, _)| meta.id == id) + == Some(index) + { + return; + } + let others: Vec<_> = rows.iter().filter(|(meta, _)| meta.id != id).collect(); + let mut ordered: Vec<(String, Option)> = others + .iter() + .filter(|(_, inside)| *inside) + .map(|(meta, _)| (meta.id.clone(), key(meta))) + .collect(); + ordered.insert(index.min(ordered.len()), (id.to_owned(), key(&meta))); + let hidden: Vec = others + .iter() + .filter(|(_, inside)| !*inside) + .filter_map(|(meta, _)| key(meta)) + .collect(); + let ordered: Vec<_> = ordered + .iter() + .map(|(id, key)| (id.as_str(), key.as_deref())) + .collect(); + let hidden: Vec<_> = hidden.iter().map(String::as_str).collect(); + let reorder = |session_id: String, order_key: String| match to { + ThreadSection::Pinned => Command::ReorderPinned { + session_id, + order_key, + }, + _ => Command::ReorderActive { + session_id, + order_key, + }, + }; + let mut moved_key = None; + let mut neighbours = vec![]; + for (session_id, order_key) in plan_reorder(&ordered, &hidden, id) { + if session_id == id { + moved_key = Some(order_key); + } else { + neighbours.push(reorder(session_id, order_key)); + } + } + let session_id = id.to_owned(); + let (mut commands, undo) = match (from, to) { + (ThreadSection::Pinned, ThreadSection::Pinned) + | (ThreadSection::Active, ThreadSection::Active) => ( + moved_key + .map(|key| reorder(session_id.clone(), key)) + .into_iter() + .collect(), + None, + ), + (_, ThreadSection::Pinned) => ( + vec![Command::PinSession { + session_id, + order_key: moved_key, + }], + None, + ), + (from, _) => { + let first = if from == ThreadSection::Pinned { + Command::UnpinSession { + session_id: session_id.clone(), + } + } else { + Command::UnsettleSession { + session_id: session_id.clone(), + } + }; + let undo = (from == ThreadSection::Pinned).then(|| { + ( + UndoKind::Unpin, + vec![Command::PinSession { + session_id: session_id.clone(), + order_key: meta.pin_order.clone(), + }], + ) + }); + ( + std::iter::once(first) + .chain(moved_key.map(|key| reorder(session_id, key))) + .collect(), + undo, + ) + } + }; + commands.extend(neighbours); + if !commands.is_empty() { + self.perform_lifecycle(commands, undo, window, cx); + } + } + + /// Whether a thread can move up and down within its section, or `None` + /// for a settled thread. + pub(super) fn move_bounds(&self, id: &str, cx: &App) -> Option<(bool, bool)> { + let meta = self.find_session(id, cx)?; + let section = thread_section(&meta); + if section == ThreadSection::Settled { + return None; + } + let scope = self.list_scope(&meta, cx); + let sections = self.scoped_sections(scope.as_deref(), cx); + let rows: Vec<_> = sections + .section(section) + .iter() + .filter(|(_, inside)| *inside) + .collect(); + let index = rows.iter().position(|(other, _)| other.id == id)?; + Some((index > 0, index + 1 < rows.len())) + } + + pub(super) fn on_move( + &mut self, + action: &ThreadMove, + window: &mut Window, + cx: &mut Context, + ) { + let Some(meta) = self.find_session(&action.0, cx) else { + return; + }; + let section = thread_section(&meta); + if section == ThreadSection::Settled { + return; + } + let scope = self.list_scope(&meta, cx); + let sections = self.scoped_sections(scope.as_deref(), cx); + let Some(index) = sections + .section(section) + .iter() + .filter(|(_, inside)| *inside) + .position(|(other, _)| other.id == meta.id) + else { + return; + }; + let Some(index) = (if action.1 { + index.checked_add(1) + } else { + index.checked_sub(1) + }) else { + return; + }; + self.place_thread(&meta.id, section, index, scope.as_deref(), window, cx); + } + + /// A pin from the menu leads the pinned rows. + pub(super) fn on_pin( + &mut self, + action: &ThreadPin, + window: &mut Window, + cx: &mut Context, + ) { + let first = self + .store + .read(cx) + .flat_sessions() + .into_iter() + .filter(|meta| thread_section(meta) == ThreadSection::Pinned) + .filter_map(|meta| meta.pin_order) + .min(); + self.perform_lifecycle( + vec![Command::PinSession { + session_id: action.0.clone(), + order_key: order_key_between(None, first.as_deref()), + }], + None, + window, + cx, + ); + } + + pub(super) fn on_unpin( + &mut self, + action: &ThreadUnpin, + window: &mut Window, + cx: &mut Context, + ) { + self.unpin_thread(&action.0, window, cx); + } + + pub(super) fn unpin_thread(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + let Some(meta) = self.find_session(id, cx) else { + return; + }; + self.perform_lifecycle( + vec![Command::UnpinSession { + session_id: id.to_owned(), + }], + Some(( + UndoKind::Unpin, + vec![Command::PinSession { + session_id: id.to_owned(), + order_key: meta.pin_order, + }], + )), + window, + cx, + ); + } + + /// The Arrange threads page shows the opening thread's project in + /// Grouped and every thread in Flat. + pub(super) fn on_arrange( + &mut self, + action: &ThreadArrange, + _: &mut Window, + cx: &mut Context, + ) { + self.arrange_scope = (self.store.read(cx).sidebar_layout() == SidebarLayout::Grouped) + .then(|| self.find_session(&action.0, cx)) + .flatten() + .and_then(|meta| meta.project_id); + self.arrange_settled_expanded = false; + self.window_state + .update(cx, |state, cx| state.go(Destination::ArrangeThreads, cx)); + } + + /// The label above the first row of Pinned or Active while a drag is in + /// progress, or the target that stands in for an empty section. Nothing + /// at rest. + pub(super) fn render_drag_boundary( + &self, + section: ThreadSection, + empty: bool, + scope: Option, + cx: &mut Context, + ) -> gpui::AnyElement { + let Some(drag) = self.drag.as_ref().filter(|drag| drag.scope == scope) else { + return div().into_any_element(); + }; + if empty && section == ThreadSection::Active && drag.target(scope.as_deref()).is_none() { + return div().into_any_element(); + } + let targeted = drag.target(scope.as_deref()) == Some(section); + let color = if targeted { + cx.theme().primary + } else { + cx.theme().sidebar_foreground + }; + let label = match section { + ThreadSection::Pinned => crate::tr!("sidebar.pinned"), + _ => crate::tr!("sidebar.active"), + }; + let element = if empty { + div().w_full().py_1().child( + h_flex() + .h(px(EMPTY_TARGET_HEIGHT - 8.)) + .px_2() + .items_center() + .rounded(cx.theme().tokens.radius.sm) + .border_1() + .border_dashed() + .border_color(cx.theme().primary) + .text_size(px(12.)) + .font_medium() + .text_color(color) + .child(label), + ) + } else { + div().w_full().child( + h_flex() + .h(px(BOUNDARY_LABEL_HEIGHT)) + .px_2() + .gap_2() + .items_center() + .child( + div() + .flex_none() + .text_size(px(12.)) + .font_medium() + .text_color(color) + .child(label), + ) + .child(div().flex_1().h(px(1.)).bg(if targeted { + cx.theme().primary.opacity(0.5) + } else { + cx.theme().sidebar_foreground.opacity(0.25) + })), + ) + }; + self.drop_zone(element, DropZone::Start(section), scope, 0., cx) + .into_any_element() + } + + /// The height [`Self::render_drag_boundary`] takes, for row offsets. + pub(super) fn drag_boundary_height( + &self, + section: ThreadSection, + empty: bool, + scope: Option<&str>, + ) -> f32 { + match self + .drag + .as_ref() + .filter(|drag| drag.scope.as_deref() == scope) + { + None => 0., + Some(drag) + if empty && section == ThreadSection::Active && drag.target(scope).is_none() => + { + 0. + } + Some(_) if empty => EMPTY_TARGET_HEIGHT, + Some(_) => BOUNDARY_LABEL_HEIGHT, + } + } + + /// The slot a dragged row leaves while it follows the pointer. + pub(super) fn render_drag_gap(height: f32) -> gpui::AnyElement { + div().w_full().h(px(height)).into_any_element() + } + + pub(crate) fn render_arrange( + &mut self, + _: &mut Window, + cx: &mut Context, + ) -> gpui::AnyElement { + self.clear_finished_drag(cx); + let scope = self.arrange_scope.clone(); + let sessions = self.store.read(cx).flat_sessions(); + let sections = partition_threads(sessions.iter().filter(|meta| { + scope + .as_deref() + .is_none_or(|id| meta.project_id.as_deref() == Some(id)) + })); + let sections = match &self.drag { + Some(drag) => drag.preview(scope.as_deref(), sections), + None => sections, + }; + let mut rows: Vec = vec![ArrangeRow::Caption( + ThreadSection::Pinned, + sections.pinned.len(), + )]; + if sections.pinned.is_empty() { + rows.push(ArrangeRow::PinnedEmpty); + } + rows.extend( + sections + .pinned + .iter() + .map(|meta| ArrangeRow::Thread(Box::new((*meta).clone()))), + ); + rows.push(ArrangeRow::Caption( + ThreadSection::Active, + sections.active.len(), + )); + rows.extend( + sections + .active + .iter() + .map(|meta| ArrangeRow::Thread(Box::new((*meta).clone()))), + ); + if !sections.settled.is_empty() { + rows.push(ArrangeRow::Settled(sections.settled.len())); + if self.arrange_settled_expanded { + rows.extend( + sections + .settled + .iter() + .map(|meta| ArrangeRow::Thread(Box::new((*meta).clone()))), + ); + } + } + let keys: Vec = rows.iter().map(ArrangeRow::key).collect(); + if keys != self.arrange_row_keys { + replace_list_rows( + &self.arrange_list_state, + |index| self.arrange_row_keys.get(index).cloned(), + keys.iter().cloned(), + ); + self.arrange_row_keys = keys; + } + let projects: HashMap = self + .store + .read(cx) + .projects() + .into_iter() + .map(|project| (project.id, project.name)) + .collect(); + let rows = Rc::new(rows); + v_flex() + .size_full() + .child( + div() + .flex_none() + .px(px(COMPACT_PAGE_PADDING)) + .py(px(8.)) + .text_size(px(13.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("sidebar.arrange_hint")), + ) + .child( + div() + .id("arrange-thread-list") + .flex_1() + .min_h_0() + .on_drop(cx.listener(|this, _: &DraggedThread, window, cx| { + this.drop_thread(window, cx) + })) + .on_drag_move(cx.listener( + |this, event: &DragMoveEvent, _, cx| { + this.drag_auto_scroll(event, cx) + }, + )) + .child( + list( + self.arrange_list_state.clone(), + cx.processor(move |this, index: usize, _, cx| { + this.render_arrange_row(&rows[index], &projects, cx) + }), + ) + .size_full(), + ), + ) + .into_any_element() + } + + fn render_arrange_row( + &self, + row: &ArrangeRow, + projects: &HashMap, + cx: &mut Context, + ) -> gpui::AnyElement { + let scope = self.arrange_scope.clone(); + let target = self + .drag + .as_ref() + .and_then(|drag| drag.target(scope.as_deref())); + let caption_color = |section| { + if target == Some(section) { + cx.theme().primary + } else { + cx.theme().muted_foreground + } + }; + match row { + ArrangeRow::Caption(section, count) => { + let label = match section { + ThreadSection::Pinned => crate::tr!("sidebar.pinned_count", count = count), + _ => crate::tr!("sidebar.active_count", count = count), + }; + let caption = crate::material::list_caption(label.into_owned().into(), cx) + .text_color(caption_color(*section)); + self.drop_zone(caption, DropZone::Start(*section), scope, 0., cx) + .into_any_element() + } + ArrangeRow::PinnedEmpty => self + .drop_zone( + div() + .w_full() + .px(px(crate::material::COMPACT_PAGE_INSET)) + .py(px(12.)) + .text_size(px(13.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("sidebar.arrange_pinned_empty")), + DropZone::Start(ThreadSection::Pinned), + scope, + 0., + cx, + ) + .into_any_element(), + ArrangeRow::Settled(count) => { + let expanded = self.arrange_settled_expanded; + let header = crate::material::accessible_clickable( + h_flex(), + "arrange-settled", + Role::Button, + crate::tr!("sidebar.settled"), + cx, + ) + .aria_expanded(expanded) + .debug_selector(|| "arrange-settled".into()) + .w_full() + .h(px(44.)) + .gap_2() + .px(px(crate::material::COMPACT_PAGE_INSET)) + .items_center() + .text_size(px(13.)) + .font_medium() + .text_color(caption_color(ThreadSection::Settled)) + .cursor_pointer() + .on_click(cx.listener(|this, _, _, cx| { + this.arrange_settled_expanded = !this.arrange_settled_expanded; + cx.notify(); + })) + .child( + collapse_chevron(!expanded, cx) + .text_color(caption_color(ThreadSection::Settled)), + ) + .child(crate::tr!("sidebar.settled_count", count = count)); + self.drop_zone( + div().w_full().child(header), + DropZone::Settled, + scope, + 0., + cx, + ) + .into_any_element() + } + ArrangeRow::Thread(meta) => { + let zone = DropZone::Row { + id: meta.id.clone(), + section: thread_section(meta), + }; + let dragged = self + .drag + .as_ref() + .is_some_and(|drag| drag.session_id == meta.id); + let element = if dragged { + div() + .w_full() + .child(Self::render_drag_gap(crate::material::LIST_ROW_MIN_HEIGHT)) + } else { + div() + .w_full() + .child(self.render_arrange_thread(meta, projects, cx)) + }; + self.drop_zone(element, zone, scope, 0., cx) + .into_any_element() + } + } + } + + fn render_arrange_thread( + &self, + meta: &SessionMeta, + projects: &HashMap, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let project = meta + .project_id + .as_ref() + .and_then(|id| projects.get(id)) + .cloned(); + let state = self.thread_row_state( + meta, + &HashMap::new(), + format!("arrange-thread-{}", meta.id), + cx, + ); + let handle = crate::material::accessible_clickable( + div(), + SharedString::from(format!("arrange-handle-{}", meta.id)), + Role::Button, + crate::tr!("sidebar.arrange_handle", title = meta.title.clone()), + cx, + ) + .debug_selector({ + let id = meta.id.clone(); + move || format!("arrange-handle-{id}") + }) + .flex_none() + .size(px(ARRANGE_HANDLE_SIZE)) + .flex() + .items_center() + .justify_center() + .cursor_grab() + // The handle alone starts a drag, so a press must not reach the row. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + Icon::new(IconName::GripVertical) + .size(px(20.)) + .text_color(cx.theme().muted_foreground), + ); + let handle = match self.dragged_thread(meta, &state, cx) { + Some(dragged) => Self::drag_source(handle, dragged, cx), + None => handle, + }; + crate::material::list_row( + SharedString::from(format!("arrange-row-{}", meta.id)), + meta.title.clone().into(), + cx, + ) + .debug_selector({ + let id = meta.id.clone(); + move || format!("arrange-row-{id}") + }) + .pr(px(4.)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(2.)) + .child( + div() + .text_size(px(15.)) + .line_height(px(20.)) + .line_clamp(2) + .text_color(if meta.is_settled() { + cx.theme().muted_foreground + } else { + cx.theme().foreground + }) + .child(meta.title.clone()), + ) + .when_some(project, |column, project| { + column.child( + div() + .text_size(px(13.)) + .truncate() + .text_color(cx.theme().muted_foreground) + .child(project), + ) + }), + ) + .child(handle) + } +} + +enum ArrangeRow { + Caption(ThreadSection, usize), + PinnedEmpty, + Thread(Box), + Settled(usize), +} + +impl ArrangeRow { + fn key(&self) -> String { + match self { + Self::Caption(section, _) => format!("caption-{section:?}"), + Self::PinnedEmpty => "pinned-empty".into(), + Self::Thread(meta) => format!("thread-{}", meta.id), + Self::Settled(_) => "settled".into(), + } + } +} diff --git a/crates/ui/src/window_state.rs b/crates/ui/src/window_state.rs index a7a281fe0..c10086c9b 100644 --- a/crates/ui/src/window_state.rs +++ b/crates/ui/src/window_state.rs @@ -27,6 +27,8 @@ pub enum Destination { /// *that a detail is open* is navigation and belongs here. SettingsSection, SettingsThreadRules, + /// The phone's page for pinning, settling and reordering threads by handle. + ArrangeThreads, } /// The shell's client-local checkpoint, independent of the host's settings. @@ -74,6 +76,7 @@ impl NavigationSnapshot { Destination::Settings | Destination::SettingsSection | Destination::SettingsThreadRules + | Destination::ArrangeThreads | Destination::Pair ) }); @@ -114,7 +117,7 @@ impl Destination { match self { Self::Hosts | Self::Pair => Route::Hosts, Self::Settings | Self::SettingsSection | Self::SettingsThreadRules => Route::Settings, - Self::Threads | Self::Thread | Self::Panel => Route::Chat, + Self::Threads | Self::Thread | Self::Panel | Self::ArrangeThreads => Route::Chat, } } @@ -126,7 +129,7 @@ impl Destination { pub fn back_label(self) -> SharedString { crate::tr!(match self { Self::Hosts | Self::Pair => "hosts.title", - Self::Threads => "mobile.threads", + Self::Threads | Self::ArrangeThreads => "mobile.threads", Self::Thread => "mobile.thread", Self::Panel => "chat.panels", Self::Settings | Self::SettingsSection | Self::SettingsThreadRules => "settings.title", diff --git a/locales/en.yml b/locales/en.yml index fdf3e10e9..51874adf2 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -133,6 +133,30 @@ sidebar: undo_failed: "Could not undo: %{reason}" undo_settled_one: "Thread settled" undo_settled: "%{count} threads settled" + pinned: "Pinned" + active: "Active" + pinned_caption: "PINNED" + active_caption: "ACTIVE" + pinned_count: "Pinned (%{count})" + active_count: "Active (%{count})" + settled_count: "Settled (%{count})" + pin: "Pin" + unpin: "Unpin" + settle: "Settle" + unsettle: "Un-settle" + ctx_pin: "Pin thread" + ctx_unpin: "Unpin thread" + ctx_move_up: "Move up" + ctx_move_down: "Move down" + ctx_arrange: "Arrange threads…" + unpin_tooltip: "Unpin thread" + undo_unpinned_one: "Thread unpinned" + undo_unpinned: "%{count} threads unpinned" + arrange_title: "Arrange threads" + arrange_hint: "Drag a handle to reorder, pin or settle. Each drop saves." + arrange_done: "Done" + arrange_pinned_empty: "No pinned threads. Drag one here to pin it." + arrange_handle: "Reorder %{title}" undo_archived_one: "Thread archived" undo_archived: "%{count} threads archived" project_thread_rules: "Thread rules…" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index ac8ae8a62..aab45fe46 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -133,6 +133,30 @@ sidebar: undo_failed: "无法撤销:%{reason}" undo_settled_one: "对话已收尾" undo_settled: "已收尾 %{count} 个对话" + pinned: "已置顶" + active: "进行中" + pinned_caption: "已置顶" + active_caption: "进行中" + pinned_count: "已置顶(%{count})" + active_count: "进行中(%{count})" + settled_count: "已收尾(%{count})" + pin: "置顶" + unpin: "取消置顶" + settle: "收尾" + unsettle: "取消收尾" + ctx_pin: "置顶对话" + ctx_unpin: "取消置顶对话" + ctx_move_up: "上移" + ctx_move_down: "下移" + ctx_arrange: "整理对话…" + unpin_tooltip: "取消置顶" + undo_unpinned_one: "已取消置顶" + undo_unpinned: "已取消置顶 %{count} 个对话" + arrange_title: "整理对话" + arrange_hint: "拖动手柄可排序、置顶或收尾,松手即保存。" + arrange_done: "完成" + arrange_pinned_empty: "没有置顶的对话。拖到这里即可置顶。" + arrange_handle: "调整“%{title}”的位置" undo_archived_one: "对话已归档" undo_archived: "已归档 %{count} 个对话" project_thread_rules: "对话规则…" From cfbd6e49b2f4441bf092f79b4251713713064887 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 9 Oct 2026 06:14:50 +0800 Subject: [PATCH 3/6] fix(runtime): pinning and arranging a thread leave its updated time --- crates/runtime/src/app/sessions.rs | 6 ------ crates/runtime/src/app/tests.rs | 23 ++++++++++++++++++++++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index ed60e7b50..e7595ad28 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -1097,7 +1097,6 @@ impl AppState { meta.settled_override = Some(SettledOverride::Active); meta.settled_at = None; } - meta.updated_at = now_secs(); if let Some(session) = self.resident_mut(id) { session.meta = meta.clone(); } @@ -1115,7 +1114,6 @@ impl AppState { } meta.pinned_at = None; meta.pin_order = None; - meta.updated_at = now_secs(); if let Some(session) = self.resident_mut(id) { session.meta = meta.clone(); } @@ -1141,10 +1139,6 @@ impl AppState { return; } *slot = Some(order_key); - // Arranging the active list is not thread activity. - if section == ThreadSection::Pinned { - meta.updated_at = now_secs(); - } if let Some(session) = self.resident_mut(id) { session.meta = meta.clone(); } diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 957b6ba1c..9a3b2a7d6 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -11005,6 +11005,7 @@ fn pinning_promotes_and_exempts_until_a_settle_unpins() { let mut meta = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); meta.id = id.into(); meta.created_at = stamp / 1000; + meta.updated_at = stamp / 1000; meta.settled_at = settled.then_some(stamp / 1000); meta.settled_override = settled.then_some(SettledOverride::Settled); store.upsert_meta(&meta).unwrap(); @@ -11104,6 +11105,22 @@ fn pinning_promotes_and_exempts_until_a_settle_unpins() { }, ) .unwrap(); + command( + &state, + cx, + 9, + Command::ReorderPinned { + session_id: "idle".into(), + order_key: "p".into(), + }, + ) + .unwrap(); + state.read(|state| { + for id in ["idle", "arranged"] { + let meta = state.find_meta(id).unwrap(); + assert_eq!(meta.updated_at, stamp / 1000, "arranging is not activity"); + } + }); state.dispatch_command( cx, @@ -11125,7 +11142,6 @@ fn pinning_promotes_and_exempts_until_a_settle_unpins() { ); let idle = state.find_meta("idle").unwrap(); assert!(!idle.is_settled(), "a pinned thread is exempt"); - assert!(!state.find_meta("parked").unwrap().is_settled()); }); command( @@ -11145,6 +11161,11 @@ fn pinning_promotes_and_exempts_until_a_settle_unpins() { Some(SettledOverride::Active), "unpinning keeps the thread active" ); + assert_eq!( + parked.updated_at, + stamp / 1000, + "pinning and unpinning are not activity" + ); }); command( &state, From 6e595b3754b78cfae83553011428376f68f2a03b Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 9 Oct 2026 06:14:50 +0800 Subject: [PATCH 4/6] refactor(core): drop the unused order_key --- crates/core/src/thread_sort.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/core/src/thread_sort.rs b/crates/core/src/thread_sort.rs index 928add125..91371d9e7 100644 --- a/crates/core/src/thread_sort.rs +++ b/crates/core/src/thread_sort.rs @@ -31,15 +31,6 @@ pub fn settled_timestamp(meta: &SessionMeta) -> u64 { meta.settled_at.unwrap_or(meta.updated_at) } -/// The key a thread is arranged by within its section, if it has one. -pub fn order_key(meta: &SessionMeta) -> Option<&str> { - match thread_section(meta) { - ThreadSection::Pinned => meta.pin_order.as_deref(), - ThreadSection::Active => meta.active_order.as_deref(), - ThreadSection::Settled => None, - } -} - /// Pinned threads by key, then keyless ones newest created first. Active /// threads without a key (new and reopened ones) newest created or reopened /// first, then arranged ones by key. Settled threads newest settled first. From 830d0b57960077e4101471a6028d7a827cd6da38 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 9 Oct 2026 06:14:50 +0800 Subject: [PATCH 5/6] fix(ui): overlay the drag boundary labels and keep the empty Active target --- crates/ui/src/sidebar.rs | 60 ++++++++++++++---- crates/ui/src/sidebar/arrange.rs | 103 +++++++++++++++++++------------ 2 files changed, 109 insertions(+), 54 deletions(-) diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index 2e73e3d3d..d7898905f 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -994,22 +994,16 @@ impl SessionsSidebar { self.settle_thread(&action.0, window, cx); } - fn find_session(&self, id: &str, cx: &App) -> Option { - self.store - .read(cx) - .sidebar_sessions() - .into_iter() - .find(|meta| meta.id == id) - } - /// Settle a thread; its undo also restores a pin it loses. fn settle_thread(&mut self, id: &str, window: &mut Window, cx: &mut Context) { let pin = self - .find_session(id, cx) + .store + .read(cx) + .thread_meta(id) .filter(|meta| meta.pinned_at.is_some()) .map(|meta| Command::PinSession { session_id: id.to_owned(), - order_key: meta.pin_order, + order_key: meta.pin_order.clone(), }); let undo = std::iter::once(Command::UnsettleSession { session_id: id.to_owned(), @@ -4145,7 +4139,7 @@ impl Render for SessionsSidebar { ] { let empty = rows.is_empty(); visible.push(FlatListRow::Boundary(section, empty)); - top += self.drag_boundary_height(section, empty, scope.as_deref()); + top += self.drag_boundary_height(empty, scope.as_deref()); for meta in rows.iter() { visible.push(FlatListRow::Thread(Box::new((*meta).clone()), top)); top += FLAT_ROW_HEIGHT; @@ -6114,7 +6108,8 @@ mod tests { /// Pinned rows lead in key order with keyless pins after them, new and /// reopened rows lead Active, and activity moves nothing. Dragging an /// active row between two pins pins it with a key between theirs; Escape - /// cancels a drag; settling a pinned thread undoes with its old key. + /// cancels a drag; Move down writes only the moved key; unpinning or + /// settling a pinned thread undoes with its old key. #[gpui::test] fn pinned_rows_lead_and_a_drag_or_undo_writes_their_keys(cx: &mut TestAppContext) { use tcode_protocol::{ @@ -6158,6 +6153,7 @@ mod tests { thread("fresh", 95), pinned("pin-old", 80, None), pinned("pin-new", 90, None), + pinned("pin-c", 3, Some("w")), pinned("pin-b", 1, Some("t")), pinned("pin-a", 2, Some("f")), ]; @@ -6251,7 +6247,8 @@ mod tests { sent(cx); draw(cx); let order = [ - "pin-a", "pin-b", "pin-new", "pin-old", "reopened", "fresh", "busy", "arranged", + "pin-a", "pin-b", "pin-c", "pin-new", "pin-old", "reopened", "fresh", "busy", + "arranged", ]; for compact in [false, true] { window_state.update(cx, |state, cx| { @@ -6318,6 +6315,43 @@ mod tests { cx.simulate_mouse_up(target.center(), left, none); assert_eq!(sent(cx), vec![], "Escape cancels the drag"); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_unpin(&ThreadUnpin("pin-a".into()), window, cx) + }); + assert_eq!( + sent(cx), + vec![Command::UnpinSession { + session_id: "pin-a".into() + }] + ); + sidebar.update_in(cx, |sidebar, window, cx| sidebar.undo_lifecycle(window, cx)); + assert_eq!( + sent(cx), + vec![Command::PinSession { + session_id: "pin-a".into(), + order_key: Some("f".into()), + }] + ); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_move(&ThreadMove("pin-a".into(), true), window, cx) + }); + let commands = sent(cx); + let [ + Command::ReorderPinned { + session_id, + order_key, + }, + ] = commands.as_slice() + else { + panic!("{commands:?}"); + }; + assert_eq!(session_id, "pin-a"); + assert!( + "t" < order_key.as_str() && order_key.as_str() < "w", + "{order_key}" + ); + sidebar.update_in(cx, |sidebar, window, cx| { sidebar.settle_thread("pin-b", window, cx) }); diff --git a/crates/ui/src/sidebar/arrange.rs b/crates/ui/src/sidebar/arrange.rs index c3dc963a8..e78311a91 100644 --- a/crates/ui/src/sidebar/arrange.rs +++ b/crates/ui/src/sidebar/arrange.rs @@ -32,6 +32,9 @@ pub(super) struct ThreadDrag { /// Where the thread lands in Pinned or Active, as an index among that /// section's other rows; `None` leaves it where it was. gap: Option<(ThreadSection, usize)>, + /// The other rows of each section inside `scope` as the drag began, + /// which gap indexes count. + others: ThreadSections, over: Option, can_settle: bool, width: Pixels, @@ -295,9 +298,9 @@ impl SessionsSidebar { }) }; let _ = sidebar.update(cx, |this, cx| { + let sections = this.scoped_sections(dragged.scope.as_deref(), cx); let gap = (dragged.from != ThreadSection::Settled) .then(|| { - let sections = this.scoped_sections(dragged.scope.as_deref(), cx); sections .section(dragged.from) .iter() @@ -306,11 +309,24 @@ impl SessionsSidebar { .map(|index| (dragged.from, index)) }) .flatten(); + let others = |section| { + sections + .section(section) + .iter() + .filter(|(meta, inside)| *inside && meta.id != dragged.session_id) + .map(|(meta, _)| meta.id.clone()) + .collect() + }; this.drag = Some(ThreadDrag { session_id: dragged.session_id.clone(), from: dragged.from, scope: dragged.scope.clone(), gap, + others: ThreadSections { + pinned: others(ThreadSection::Pinned), + active: others(ThreadSection::Active), + settled: vec![], + }, over: Some(dragged.from), can_settle: dragged.can_settle, width: px(240.), @@ -394,12 +410,11 @@ impl SessionsSidebar { over = Some(gap.map_or(drag.from, |(section, _)| section)); } DropZone::Row { id, section } => { - let sections = self.scoped_sections(scope, cx); - let index = sections + let index = drag + .others .section(*section) .iter() - .filter(|(meta, inside)| *inside && meta.id != drag.session_id) - .position(|(meta, _)| meta.id == *id); + .position(|other| other == id); if let Some(index) = index { gap = Some((*section, index + usize::from(lower))); over = Some(*section); @@ -491,7 +506,7 @@ impl SessionsSidebar { window: &mut Window, cx: &mut Context, ) { - let Some(meta) = self.find_session(id, cx) else { + let Some(meta) = self.store.read(cx).thread_meta(id).cloned() else { return; }; let from = thread_section(&meta); @@ -599,7 +614,7 @@ impl SessionsSidebar { /// Whether a thread can move up and down within its section, or `None` /// for a settled thread. pub(super) fn move_bounds(&self, id: &str, cx: &App) -> Option<(bool, bool)> { - let meta = self.find_session(id, cx)?; + let meta = self.store.read(cx).thread_meta(id).cloned()?; let section = thread_section(&meta); if section == ThreadSection::Settled { return None; @@ -621,7 +636,7 @@ impl SessionsSidebar { window: &mut Window, cx: &mut Context, ) { - let Some(meta) = self.find_session(&action.0, cx) else { + let Some(meta) = self.store.read(cx).thread_meta(&action.0).cloned() else { return; }; let section = thread_section(&meta); @@ -684,7 +699,7 @@ impl SessionsSidebar { } pub(super) fn unpin_thread(&mut self, id: &str, window: &mut Window, cx: &mut Context) { - let Some(meta) = self.find_session(id, cx) else { + let Some(meta) = self.store.read(cx).thread_meta(id).cloned() else { return; }; self.perform_lifecycle( @@ -712,7 +727,7 @@ impl SessionsSidebar { cx: &mut Context, ) { self.arrange_scope = (self.store.read(cx).sidebar_layout() == SidebarLayout::Grouped) - .then(|| self.find_session(&action.0, cx)) + .then(|| self.store.read(cx).thread_meta(&action.0).cloned()) .flatten() .and_then(|meta| meta.project_id); self.arrange_settled_expanded = false; @@ -720,9 +735,9 @@ impl SessionsSidebar { .update(cx, |state, cx| state.go(Destination::ArrangeThreads, cx)); } - /// The label above the first row of Pinned or Active while a drag is in - /// progress, or the target that stands in for an empty section. Nothing - /// at rest. + /// While a drag is in progress, the label at the start of Pinned or + /// Active, overlaying the rows so they never move, or the target that + /// stands in for an empty section. Nothing at rest. pub(super) fn render_drag_boundary( &self, section: ThreadSection, @@ -733,9 +748,6 @@ impl SessionsSidebar { let Some(drag) = self.drag.as_ref().filter(|drag| drag.scope == scope) else { return div().into_any_element(); }; - if empty && section == ThreadSection::Active && drag.target(scope.as_deref()).is_none() { - return div().into_any_element(); - } let targeted = drag.target(scope.as_deref()) == Some(section); let color = if targeted { cx.theme().primary @@ -746,8 +758,8 @@ impl SessionsSidebar { ThreadSection::Pinned => crate::tr!("sidebar.pinned"), _ => crate::tr!("sidebar.active"), }; - let element = if empty { - div().w_full().py_1().child( + if empty { + let target = div().w_full().py_1().child( h_flex() .h(px(EMPTY_TARGET_HEIGHT - 8.)) .px_2() @@ -760,10 +772,27 @@ impl SessionsSidebar { .font_medium() .text_color(color) .child(label), - ) - } else { - div().w_full().child( + ); + return self + .drop_zone(target, DropZone::Start(section), scope, 0., cx) + .into_any_element(); + } + // The first section starts the list, so its label sits inside the + // first row; the second straddles the seam between the sections. + let top = match section { + ThreadSection::Pinned => 0., + _ => -BOUNDARY_LABEL_HEIGHT / 2., + }; + div() + .relative() + .w_full() + .h_0() + .child( h_flex() + .absolute() + .top(px(top)) + .left_0() + .right_0() .h(px(BOUNDARY_LABEL_HEIGHT)) .px_2() .gap_2() @@ -771,6 +800,9 @@ impl SessionsSidebar { .child( div() .flex_none() + .px_1() + .rounded(cx.theme().tokens.radius.sm) + .bg(cx.theme().sidebar) .text_size(px(12.)) .font_medium() .text_color(color) @@ -782,31 +814,20 @@ impl SessionsSidebar { cx.theme().sidebar_foreground.opacity(0.25) })), ) - }; - self.drop_zone(element, DropZone::Start(section), scope, 0., cx) .into_any_element() } - /// The height [`Self::render_drag_boundary`] takes, for row offsets. - pub(super) fn drag_boundary_height( - &self, - section: ThreadSection, - empty: bool, - scope: Option<&str>, - ) -> f32 { - match self + /// The height of the target [`Self::render_drag_boundary`] shows for an + /// empty section, for row offsets; a label takes none. + pub(super) fn drag_boundary_height(&self, empty: bool, scope: Option<&str>) -> f32 { + let dragging = self .drag .as_ref() - .filter(|drag| drag.scope.as_deref() == scope) - { - None => 0., - Some(drag) - if empty && section == ThreadSection::Active && drag.target(scope).is_none() => - { - 0. - } - Some(_) if empty => EMPTY_TARGET_HEIGHT, - Some(_) => BOUNDARY_LABEL_HEIGHT, + .is_some_and(|drag| drag.scope.as_deref() == scope); + if dragging && empty { + EMPTY_TARGET_HEIGHT + } else { + 0. } } From ced3da7c759ed5997763541b41aacea6b4c0601b Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 9 Oct 2026 09:06:46 +0800 Subject: [PATCH 6/6] fix: paint the drag section labels above the rows --- crates/ui/src/sidebar/arrange.rs | 49 +++++++++++++++++--------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/crates/ui/src/sidebar/arrange.rs b/crates/ui/src/sidebar/arrange.rs index e78311a91..5a4748764 100644 --- a/crates/ui/src/sidebar/arrange.rs +++ b/crates/ui/src/sidebar/arrange.rs @@ -7,10 +7,10 @@ //! `on_drop`, and GPUI does not cancel a drag on Escape by itself. use super::*; -use gpui::{DragMoveEvent, KeystrokeEvent, MouseButton, Pixels, WeakEntity}; +use gpui::{DragMoveEvent, KeystrokeEvent, MouseButton, Pixels, WeakEntity, deferred}; use tcode_core::thread_sort::{order_key_between, plan_reorder, thread_section}; -const BOUNDARY_LABEL_HEIGHT: f32 = 24.; +const BOUNDARY_LABEL_HEIGHT: f32 = 18.; const EMPTY_TARGET_HEIGHT: f32 = 36.; const ARRANGE_HANDLE_SIZE: f32 = 44.; @@ -777,43 +777,46 @@ impl SessionsSidebar { .drop_zone(target, DropZone::Start(section), scope, 0., cx) .into_any_element(); } - // The first section starts the list, so its label sits inside the - // first row; the second straddles the seam between the sections. - let top = match section { - ThreadSection::Pinned => 0., - _ => -BOUNDARY_LABEL_HEIGHT / 2., + let rule = if targeted { + cx.theme().primary.opacity(0.5) + } else { + cx.theme().border }; + // Deferred so the label paints after the rows on both sides of the + // seam, centered on the seam rather than on a row's title line. The + // sidebar background is transparent, so the hairline stops at the pill + // instead of running under it. div() .relative() .w_full() .h_0() - .child( + .child(deferred( h_flex() .absolute() - .top(px(top)) + .top(px(-BOUNDARY_LABEL_HEIGHT / 2.)) .left_0() .right_0() .h(px(BOUNDARY_LABEL_HEIGHT)) - .px_2() - .gap_2() + .gap_1() .items_center() + .child(div().flex_none().w_2().h(px(1.)).bg(rule)) .child( div() .flex_none() - .px_1() - .rounded(cx.theme().tokens.radius.sm) - .bg(cx.theme().sidebar) - .text_size(px(12.)) - .font_medium() - .text_color(color) + .px_1p5() + .rounded_full() + .bg(cx.theme().muted) + .text_size(px(11.)) + .line_height(px(BOUNDARY_LABEL_HEIGHT)) + .text_color(if targeted { + cx.theme().primary + } else { + cx.theme().muted_foreground + }) .child(label), ) - .child(div().flex_1().h(px(1.)).bg(if targeted { - cx.theme().primary.opacity(0.5) - } else { - cx.theme().sidebar_foreground.opacity(0.25) - })), - ) + .child(div().flex_1().h(px(1.)).bg(rule)), + )) .into_any_element() }