diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 74c4be047..e1bc8e082 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -11,5 +11,7 @@ pub mod provider_status; pub mod relay; pub mod session; pub mod settings; +pub mod settlement; +pub mod thread_sort; pub mod ui; pub mod usage; diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index 4670bd448..a69522d10 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -61,6 +61,13 @@ pub struct WorktreeInfo { pub branch: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SettledOverride { + Settled, + Active, +} + /// Index entry describing one persisted session. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SessionMeta { @@ -84,9 +91,15 @@ pub struct SessionMeta { /// legacy files (defaults to "not archived"). #[serde(default, skip_serializing_if = "Option::is_none")] pub archived_at: Option, - /// Manually settled (unix secs), independently of archive state. + /// Lifecycle timestamps are Unix seconds; activity in the event stream is Unix milliseconds. #[serde(default, skip_serializing_if = "Option::is_none")] pub settled_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settled_override: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unsettled_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_settle_disabled_at: 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")] @@ -174,6 +187,17 @@ impl<'a> WorktreeSharing<'a> { } impl SessionMeta { + pub fn is_settled(&self) -> bool { + self.settled_override == Some(SettledOverride::Settled) + || (self.settled_override.is_none() && self.settled_at.is_some()) + } + + pub fn migrate_lifecycle(&mut self) { + if self.settled_override.is_none() && self.settled_at.is_some() { + self.settled_override = Some(SettledOverride::Settled); + } + } + /// Whether `other` works in the worktree this session owns. A fork keeps /// the source's cwd without the `worktree` ownership marker, so the cwd /// decides as well as the branch. @@ -222,6 +246,9 @@ impl SessionMeta { model, archived_at: None, settled_at: None, + settled_override: None, + unsettled_at: None, + auto_settle_disabled_at: None, worktree: None, resume_cursor: None, pending_fork: false, @@ -239,19 +266,6 @@ impl SessionMeta { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct AutoArchiveConfig { - pub max_idle_secs: u64, - pub keep_count: usize, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AutoArchiveExemptions { - pub working: HashSet, - pub unread: HashSet, - pub active: HashSet, -} - /// `root_id` and every thread under it, each parent before its children: /// the threads archive, unarchive and delete act on together. Empty when /// `root_id` is not among `sessions`. @@ -284,148 +298,6 @@ pub fn descendant_session_ids<'a>( output } -/// Return the cascade-closed ids eligible for auto-archive in one project. -/// `sessions` may contain only non-archived entries; archived entries are also -/// defensively ignored here so they cannot consume ranking slots. -pub fn auto_archive_candidates( - sessions: &[SessionMeta], - now: u64, - config: &AutoArchiveConfig, - exempt: &AutoArchiveExemptions, -) -> Vec { - let sessions: Vec<&SessionMeta> = sessions - .iter() - .filter(|session| session.archived_at.is_none()) - .collect(); - let ids: HashSet<&str> = sessions.iter().map(|session| session.id.as_str()).collect(); - let mut children: HashMap<&str, Vec<&SessionMeta>> = HashMap::new(); - let mut roots = Vec::new(); - for session in &sessions { - if let Some(parent) = session.parent_session_id.as_deref() - && ids.contains(parent) - { - children.entry(parent).or_default().push(session); - } else { - roots.push(*session); - } - } - roots.sort_by_key(|session| std::cmp::Reverse(session.updated_at)); - for siblings in children.values_mut() { - siblings.sort_by_key(|session| std::cmp::Reverse(session.updated_at)); - } - - fn has_exempt_descendant( - session_id: &str, - children: &HashMap<&str, Vec<&SessionMeta>>, - exempt: &AutoArchiveExemptions, - visiting: &mut HashSet, - ) -> bool { - if !visiting.insert(session_id.to_string()) { - return false; - } - let found = children.get(session_id).is_some_and(|descendants| { - descendants.iter().any(|child| { - child.settled_at.is_some() - || exempt.working.contains(&child.id) - || exempt.unread.contains(&child.id) - || exempt.active.contains(&child.id) - || has_exempt_descendant(&child.id, children, exempt, visiting) - }) - }); - visiting.remove(session_id); - found - } - - fn append_subtree( - session_id: &str, - children: &HashMap<&str, Vec<&SessionMeta>>, - archived: &mut HashSet, - output: &mut Vec, - ) { - if !archived.insert(session_id.to_string()) { - return; - } - output.push(session_id.to_string()); - if let Some(descendants) = children.get(session_id) { - for child in descendants { - append_subtree(&child.id, children, archived, output); - } - } - } - - struct WalkState { - archived: HashSet, - output: Vec, - visited: HashSet, - } - - fn visit_siblings( - siblings: &[&SessionMeta], - parent_id: Option<&str>, - children: &HashMap<&str, Vec<&SessionMeta>>, - now: u64, - config: &AutoArchiveConfig, - exempt: &AutoArchiveExemptions, - state: &mut WalkState, - ) { - for (rank, session) in siblings.iter().enumerate() { - if !state.visited.insert(session.id.clone()) || state.archived.contains(&session.id) { - continue; - } - let directly_exempt = session.settled_at.is_some() - || exempt.working.contains(&session.id) - || exempt.unread.contains(&session.id) - || exempt.active.contains(&session.id) - || parent_id.is_some_and(|parent| exempt.working.contains(parent)); - let exempt_descendant = - has_exempt_descendant(&session.id, children, exempt, &mut HashSet::new()); - let eligible = rank >= config.keep_count.max(1) - && now.saturating_sub(session.updated_at) > config.max_idle_secs - && !directly_exempt - && !exempt_descendant; - if eligible { - append_subtree( - &session.id, - children, - &mut state.archived, - &mut state.output, - ); - } else if let Some(descendants) = children.get(session.id.as_str()) { - visit_siblings( - descendants, - Some(&session.id), - children, - now, - config, - exempt, - state, - ); - } - } - } - - let mut state = WalkState { - archived: HashSet::new(), - output: Vec::new(), - visited: HashSet::new(), - }; - visit_siblings(&roots, None, &children, now, config, exempt, &mut state); - - // Malformed cycles have no root. Keep the function total and apply the same - // sibling rule to any remaining entries, mirroring the sidebar's defensive - // visibility behavior. - let mut remainder: Vec<_> = sessions - .iter() - .copied() - .filter(|session| { - !state.visited.contains(&session.id) && !state.archived.contains(&session.id) - }) - .collect(); - remainder.sort_by_key(|session| std::cmp::Reverse(session.updated_at)); - visit_siblings(&remainder, None, &children, now, config, exempt, &mut state); - state.output -} - /// A project and its sessions, ready for the sidebar (newest activity first). #[derive(Debug, Clone)] pub struct ProjectGroup { @@ -433,8 +305,8 @@ pub struct ProjectGroup { pub sessions: Vec, } -/// Group `sessions` under their `projects`, ordering sessions newest-activity -/// first within each group and groups per `sort`. +/// Group `sessions` under their `projects` and order projects using `sort`. +/// Callers choose the row order for their lifecycle or archive surface. pub fn group_sessions( projects: &[Project], sessions: &[SessionMeta], @@ -443,12 +315,11 @@ pub fn group_sessions( let mut groups: Vec = projects .iter() .map(|project| { - let mut sessions: Vec = sessions + let sessions: Vec = sessions .iter() .filter(|s| s.project_id.as_deref() == Some(project.id.as_str())) .cloned() .collect(); - sessions = order_sessions_with_children(sessions); ProjectGroup { project: project.clone(), sessions, @@ -481,59 +352,6 @@ pub fn group_sessions( groups } -/// Stable parent-first ordering for a session pool. Orphans are roots; each -/// parent's newest children follow it immediately. -pub fn order_sessions_with_children(sessions: Vec) -> Vec { - let ids: std::collections::HashSet<&str> = - sessions.iter().map(|session| session.id.as_str()).collect(); - let mut roots: Vec<&SessionMeta> = sessions - .iter() - .filter(|session| { - session - .parent_session_id - .as_deref() - .is_none_or(|parent| !ids.contains(parent)) - }) - .collect(); - roots.sort_by_key(|session| std::cmp::Reverse(session.updated_at)); - - fn append( - parent: &SessionMeta, - sessions: &[SessionMeta], - output: &mut Vec, - visited: &mut std::collections::HashSet, - ) { - if !visited.insert(parent.id.clone()) { - return; - } - output.push(parent.clone()); - let mut children: Vec<&SessionMeta> = sessions - .iter() - .filter(|session| session.parent_session_id.as_deref() == Some(parent.id.as_str())) - .collect(); - children.sort_by_key(|session| std::cmp::Reverse(session.updated_at)); - for child in children { - append(child, sessions, output, visited); - } - } - - let mut output = Vec::with_capacity(sessions.len()); - let mut visited = std::collections::HashSet::new(); - for root in roots { - append(root, &sessions, &mut output, &mut visited); - } - // Defensive cycle handling: malformed cyclic metadata stays visible. - let mut remainder: Vec<&SessionMeta> = sessions - .iter() - .filter(|session| !visited.contains(&session.id)) - .collect(); - remainder.sort_by_key(|session| std::cmp::Reverse(session.updated_at)); - for session in remainder { - append(session, &sessions, &mut output, &mut visited); - } - output -} - /// Every project and session in the store. Also the shape of the legacy /// `sessions.json` the store migrates from; older files were a bare /// `Vec`, and the migration tolerates both. @@ -597,13 +415,6 @@ mod tests { meta } - fn archive_session(id: &str, updated_at: u64, parent: Option<&str>) -> SessionMeta { - let mut meta = session_in("p", updated_at); - meta.id = id.to_string(); - meta.parent_session_id = parent.map(str::to_string); - meta - } - #[test] fn provider_color_key_prefers_user_profile_then_acp_agent_then_builtin() { let mut meta = SessionMeta::new(ProviderKind::ClaudeCode, PathBuf::from("/x"), None); @@ -647,28 +458,8 @@ mod tests { ); } - fn candidates( - sessions: &[SessionMeta], - now: u64, - max_idle_secs: u64, - keep_count: usize, - exempt: &AutoArchiveExemptions, - ) -> HashSet { - auto_archive_candidates( - sessions, - now, - &AutoArchiveConfig { - max_idle_secs, - keep_count, - }, - exempt, - ) - .into_iter() - .collect() - } - #[test] - fn group_sessions_orders_by_activity() { + fn group_sessions_orders_projects_by_activity_or_name() { let projects = vec![ Project { id: "p-old".into(), @@ -707,9 +498,6 @@ mod tests { assert_eq!(groups[0].project.id, "p-new"); assert_eq!(groups[1].project.id, "p-old"); assert_eq!(groups[2].project.id, "p-empty"); - // Within a group, newest session first. - assert_eq!(groups[0].sessions[0].updated_at, 100); - assert_eq!(groups[0].sessions[1].updated_at, 50); assert!(groups[2].sessions.is_empty()); // Name A-Z ordering ignores activity: Empty, New, Old (case-insensitive). @@ -719,48 +507,6 @@ mod tests { assert_eq!(by_name[2].project.name, "Old"); } - #[test] - fn group_sessions_places_children_after_their_parent() { - let projects = vec![Project { - id: "p".into(), - name: "Project".into(), - root: PathBuf::from("/p"), - icon_path: None, - permission_defaults: BTreeMap::new(), - created_at: 1, - }]; - let make = |id: &str, updated_at: u64, parent: Option<&str>| { - let mut meta = session_in("p", updated_at); - meta.id = id.into(); - meta.parent_session_id = parent.map(str::to_string); - meta - }; - let sessions = vec![ - make("child-old", 10, Some("parent-new")), - make("parent-old", 90, None), - make("orphan", 95, Some("deleted-parent")), - make("child-new", 500, Some("parent-new")), - make("parent-new", 100, None), - ]; - - let groups = group_sessions(&projects, &sessions, ProjectSort::RecentActivity); - let ids: Vec<_> = groups[0] - .sessions - .iter() - .map(|session| session.id.as_str()) - .collect(); - assert_eq!( - ids, - [ - "parent-new", - "child-new", - "child-old", - "orphan", - "parent-old" - ] - ); - } - #[test] fn session_metadata_preserves_legacy_defaults_and_persisted_options() { let legacy = serde_json::json!({ @@ -807,137 +553,4 @@ mod tests { assert!(json.get("checkpoints").is_none()); assert_eq!(serde_json::from_value::(json).unwrap(), meta); } - - #[test] - fn auto_archive_respects_idle_rank_exemptions_and_descendant_cascades() { - let make = |id: &str, updated, parent: Option<&str>| archive_session(id, updated, parent); - let mut settled = make("settled", 1, Some("parent")); - settled.settled_at = Some(2); - let mut archived = make("already-archived", 900, None); - archived.archived_at = Some(950); - let cases = [ - ( - "settled descendant", - vec![ - make("newest", 1000, None), - make("parent", 2, None), - settled, - make("old", 1, None), - ], - 1, - AutoArchiveExemptions::default(), - vec!["old"], - ), - ( - "idle boundary and future clock", - vec![ - make("future", 1100, None), - make("at-boundary", 900, None), - make("old", 899, None), - ], - 1, - AutoArchiveExemptions::default(), - vec!["old"], - ), - ( - "keep at least one", - vec![make("only", 1, None)], - 0, - AutoArchiveExemptions::default(), - vec![], - ), - ( - "archived does not consume rank", - vec![archived, make("keep", 2, None), make("old", 1, None)], - 1, - AutoArchiveExemptions::default(), - vec!["old"], - ), - ( - "exemptions consume rank", - vec![ - make("working", 40, None), - make("active", 30, None), - make("unread", 20, None), - make("old", 10, None), - ], - 3, - AutoArchiveExemptions { - working: HashSet::from(["working".into()]), - active: HashSet::from(["active".into()]), - unread: HashSet::from(["unread".into()]), - }, - vec!["old"], - ), - ( - "working descendant", - vec![ - make("new-root", 900, None), - make("root", 100, None), - make("child", 90, Some("root")), - make("worker", 80, Some("child")), - ], - 1, - AutoArchiveExemptions { - working: HashSet::from(["worker".into()]), - ..Default::default() - }, - vec![], - ), - ( - "working parent", - vec![ - make("parent", 900, None), - make("new-child", 80, Some("parent")), - make("old-child", 70, Some("parent")), - ], - 1, - AutoArchiveExemptions { - working: HashSet::from(["parent".into()]), - ..Default::default() - }, - vec![], - ), - ( - "children rank among siblings", - vec![ - make("parent", 1, None), - make("new-child", 90, Some("parent")), - make("old-child", 80, Some("parent")), - ], - 1, - AutoArchiveExemptions::default(), - vec!["old-child"], - ), - ( - "parent cascades even to recent descendants", - vec![ - make("new-root", 900, None), - make("root", 100, None), - make("child", 999, Some("root")), - make("grandchild", 999, Some("child")), - ], - 1, - AutoArchiveExemptions::default(), - vec!["root", "child", "grandchild"], - ), - ( - "orphan ranks as root", - vec![ - make("new-root", 900, None), - make("orphan", 10, Some("missing")), - ], - 1, - AutoArchiveExemptions::default(), - vec!["orphan"], - ), - ]; - for (label, sessions, keep, exempt, expected) in cases { - assert_eq!( - candidates(&sessions, 1000, 100, keep, &exempt), - expected.into_iter().map(str::to_string).collect(), - "{label}" - ); - } - } } diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index efa142014..58936ee4a 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -146,6 +146,8 @@ pub struct Author { /// envelope lines carry the recorded timestamp. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StoredEvent { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub author: Option, pub ts: Option, @@ -157,6 +159,20 @@ pub struct StoredEvent { pub elided: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessageOrigin { + Human, + Agent, + Server, +} + +impl MessageOrigin { + pub fn legacy(has_parent: bool) -> Self { + if has_parent { Self::Agent } else { Self::Human } + } +} + impl StoredEvent { /// The item an event creates or continues. pub fn item_id(&self) -> Option<&str> { @@ -173,6 +189,7 @@ impl StoredEvent { impl From for StoredEvent { fn from(event: AgentEvent) -> Self { StoredEvent { + origin: None, author: None, ts: None, event, @@ -2706,6 +2723,7 @@ mod tests { fn at(ts: u64, event: AgentEvent) -> StoredEvent { StoredEvent { + origin: None, author: None, ts: Some(ts), event, @@ -3070,6 +3088,7 @@ mod tests { started(1_000, running("a")), completed(2_000, ran("a")), StoredEvent { + origin: None, author: None, ts: first_end, event: turn_completed(), @@ -3162,12 +3181,14 @@ mod tests { let turn = |n: u64| { [ StoredEvent { + origin: None, author: None, ts: Some(n * 10), event: user_msg(&format!("user-{n}"), "go"), elided: None, }, StoredEvent { + origin: None, author: None, ts: Some(n * 10 + 1), event: AgentEvent::TurnStarted { @@ -3176,12 +3197,14 @@ mod tests { elided: None, }, StoredEvent { + origin: None, author: None, ts: Some(n * 10 + 2), event: assistant_delta("pi-assistant-0:0", &format!("text {n}")), elided: None, }, StoredEvent { + origin: None, author: None, ts: Some(n * 10 + 3), event: AgentEvent::TurnCompleted { @@ -3232,6 +3255,7 @@ mod tests { #[test] fn synthetic_entry_ids_do_not_depend_on_how_much_earlier_history_is_folded() { let error = |ts: u64| StoredEvent { + origin: None, author: None, ts: Some(ts), event: AgentEvent::Error { @@ -3242,6 +3266,7 @@ mod tests { }; let log = [ StoredEvent { + origin: None, author: None, ts: Some(1), event: user_msg("user-1", "go"), @@ -3249,6 +3274,7 @@ mod tests { }, error(2), StoredEvent { + origin: None, author: None, ts: Some(3), event: AgentEvent::TurnCompleted { @@ -3259,6 +3285,7 @@ mod tests { elided: None, }, StoredEvent { + origin: None, author: None, ts: Some(4), event: user_msg("user-2", "again"), diff --git a/crates/core/src/session/superseded.rs b/crates/core/src/session/superseded.rs index 8942f26d5..179d54663 100644 --- a/crates/core/src/session/superseded.rs +++ b/crates/core/src/session/superseded.rs @@ -69,6 +69,7 @@ mod tests { fn at(ts: u64, event: AgentEvent) -> StoredEvent { StoredEvent { + origin: None, author: None, ts: Some(ts), event, diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 9a17d9305..017aa62f9 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -791,10 +791,12 @@ pub enum SettingsPatch { AbortOnModelFallback(bool), ResumeOnLimitReset(bool), FallbackReviewAdvisor(bool), - AutoArchiveDisabled(bool), - AutoArchiveMaxIdleDays(u32), - AutoArchiveKeepCount(usize), - AutoArchiveNoticeShown(bool), + AutoSettleAfterDays(Option), + AutoSettleOnMerge(bool), + ProjectSettlement { + project_id: String, + value: Option, + }, OrchestrateDecisionModels(Vec), OrchestrateChildModels(Vec), OrchestrateChildApproval(ChildApprovalMode), @@ -930,19 +932,12 @@ pub struct Settings { pub resume_on_limit_reset: bool, #[serde(default)] pub fallback_review_advisor: bool, - /// Whether automatic archiving is DISABLED. Stored inverted so the feature - /// defaults to on even for legacy settings files that lack the field. - #[serde(default)] - pub auto_archive_disabled: bool, - /// Threads must be idle longer than this many days before auto-archive. - #[serde(default = "default_auto_archive_max_idle_days")] - pub auto_archive_max_idle_days: u32, - /// Newest siblings preserved regardless of age by auto-archive. - #[serde(default = "default_auto_archive_keep_count")] - pub auto_archive_keep_count: usize, - /// Whether the one-time first-auto-archive explanation has been shown. - #[serde(default)] - pub auto_archive_notice_shown: bool, + #[serde(default = "default_auto_settle_after_days")] + pub auto_settle_after_days: Option, + #[serde(default = "default_true")] + pub auto_settle_on_merge: bool, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub project_settlement_overrides: BTreeMap, /// Built-in orchestration identities and child-model routing table. #[serde(default, skip_serializing_if = "OrchestrateSettings::is_default")] pub orchestrate: OrchestrateSettings, @@ -1017,18 +1012,36 @@ pub struct Settings { pub unknown: serde_json::Map, } -/// Factory value for [`Settings::auto_archive_max_idle_days`]. Public so the -/// settings page can tell an overridden field from an untouched one. -pub const DEFAULT_AUTO_ARCHIVE_MAX_IDLE_DAYS: u32 = 7; -/// Factory value for [`Settings::auto_archive_keep_count`]. -pub const DEFAULT_AUTO_ARCHIVE_KEEP_COUNT: usize = 30; +const fn default_auto_settle_after_days() -> Option { + Some(3.0) +} + +/// Auto-settle after 1 to 90 days, or never. +fn auto_settle_days(days: Option) -> Result, &'static str> { + match days { + Some(days) if !(1.0..=90.0).contains(&days) => { + Err("Auto-settle days must be between 1 and 90.") + } + days => Ok(days), + } +} -const fn default_auto_archive_max_idle_days() -> u32 { - DEFAULT_AUTO_ARCHIVE_MAX_IDLE_DAYS +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ProjectSettlementSettings { + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_days_override" + )] + pub auto_settle_after_days: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_settle_on_merge: Option, } -const fn default_auto_archive_keep_count() -> usize { - DEFAULT_AUTO_ARCHIVE_KEEP_COUNT +fn deserialize_days_override<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + Option::::deserialize(deserializer).map(Some) } impl Default for Settings { @@ -1052,10 +1065,9 @@ impl Default for Settings { abort_on_model_fallback: true, resume_on_limit_reset: true, fallback_review_advisor: false, - auto_archive_disabled: false, - auto_archive_max_idle_days: default_auto_archive_max_idle_days(), - auto_archive_keep_count: default_auto_archive_keep_count(), - auto_archive_notice_shown: false, + auto_settle_after_days: default_auto_settle_after_days(), + auto_settle_on_merge: true, + project_settlement_overrides: BTreeMap::new(), orchestrate: OrchestrateSettings::default(), computer_use: ComputerUseSettings::default(), browser: BrowserSettings::default(), @@ -1079,8 +1091,9 @@ impl Default for Settings { } impl Settings { - /// Apply one field-scoped mutation without replacing sibling fields. - pub fn apply(&mut self, patch: SettingsPatch) { + /// Apply one field-scoped mutation without replacing sibling fields. A + /// value outside its field's range is refused and changes nothing. + pub fn apply(&mut self, patch: SettingsPatch) -> Result<(), &'static str> { match patch { SettingsPatch::GitHubHost { host, @@ -1127,15 +1140,19 @@ impl Settings { SettingsPatch::FallbackReviewAdvisor(value) => { self.fallback_review_advisor = value; } - SettingsPatch::AutoArchiveDisabled(value) => self.auto_archive_disabled = value, - SettingsPatch::AutoArchiveMaxIdleDays(value) => { - self.auto_archive_max_idle_days = value; - } - SettingsPatch::AutoArchiveKeepCount(value) => { - self.auto_archive_keep_count = value; + SettingsPatch::AutoSettleAfterDays(value) => { + self.auto_settle_after_days = auto_settle_days(value)?; } - SettingsPatch::AutoArchiveNoticeShown(value) => { - self.auto_archive_notice_shown = value; + SettingsPatch::AutoSettleOnMerge(value) => self.auto_settle_on_merge = value, + SettingsPatch::ProjectSettlement { project_id, value } => { + if let Some(mut value) = value { + if let Some(days) = value.auto_settle_after_days { + value.auto_settle_after_days = Some(auto_settle_days(days)?); + } + self.project_settlement_overrides.insert(project_id, value); + } else { + self.project_settlement_overrides.remove(&project_id); + } } SettingsPatch::OrchestrateDecisionModels(value) => { self.orchestrate.decision_models = value; @@ -1192,6 +1209,7 @@ impl Settings { SettingsPatch::RemoteHostName(value) => self.remote_host_name = value, SettingsPatch::LastProject(value) => self.last_project_id = value, } + Ok(()) } } @@ -1426,10 +1444,8 @@ mod tests { #[test] fn older_settings_preserve_access_policy_and_accept_partial_feature_blocks() { let legacy: Settings = serde_json::from_str(r#"{"theme_mode":"system"}"#).unwrap(); - assert!(!legacy.auto_archive_disabled); - assert_eq!(legacy.auto_archive_max_idle_days, 7); - assert_eq!(legacy.auto_archive_keep_count, 30); - assert!(!legacy.auto_archive_notice_shown); + assert_eq!(legacy.auto_settle_after_days, Some(3.0)); + assert!(legacy.auto_settle_on_merge); assert!(!legacy.sidebar_provider_marks); assert!(!legacy.sidebar_collapsed); assert!(!legacy.remote_hosting_enabled); @@ -1764,10 +1780,14 @@ mod tests { description: String::new(), bundled: None, }); - settings.apply(SettingsPatch::OrchestrateChildModels(children)); + settings + .apply(SettingsPatch::OrchestrateChildModels(children)) + .unwrap(); let mut decisions = settings.orchestrate.decision_models.clone(); decisions[0].description = "Mine.".into(); - settings.apply(SettingsPatch::OrchestrateDecisionModels(decisions)); + settings + .apply(SettingsPatch::OrchestrateDecisionModels(decisions)) + .unwrap(); let saved = serde_json::to_value(&settings.orchestrate).unwrap(); assert_eq!( saved["decision_models"], @@ -2121,7 +2141,9 @@ mod tests { duplicate.description = "must not overwrite".into(); let mut children = settings.orchestrate.child_models.clone(); children.push(duplicate); - settings.apply(SettingsPatch::OrchestrateChildModels(children)); + settings + .apply(SettingsPatch::OrchestrateChildModels(children)) + .unwrap(); assert_eq!(settings.orchestrate.child_models.len(), 2); assert_eq!(settings.orchestrate.child_models[0], executor); @@ -2130,7 +2152,9 @@ mod tests { duplicate.description = "must not overwrite".into(); let mut decisions = settings.orchestrate.decision_models.clone(); decisions.push(duplicate); - settings.apply(SettingsPatch::OrchestrateDecisionModels(decisions)); + settings + .apply(SettingsPatch::OrchestrateDecisionModels(decisions)) + .unwrap(); assert_eq!(settings.orchestrate.decision_models.len(), 2); assert_eq!(settings.orchestrate.decision_models[0], peer); assert_eq!(settings.orchestrate.child_models[0], executor); @@ -2286,7 +2310,9 @@ mod tests { assert_eq!(off.traverse, TraverseSetting::Off); let mut patched = Settings::default(); - patched.apply(SettingsPatch::Traverse(TraverseSetting::Off)); + patched + .apply(SettingsPatch::Traverse(TraverseSetting::Off)) + .unwrap(); assert_eq!( serde_json::to_value(&patched).unwrap().get("traverse"), Some(&serde_json::json!({"mode": "off"})) diff --git a/crates/core/src/settlement.rs b/crates/core/src/settlement.rs new file mode 100644 index 000000000..fe71625a7 --- /dev/null +++ b/crates/core/src/settlement.rs @@ -0,0 +1,206 @@ +//! Event-derived activity and thread lifecycle policy. All activity clocks use Unix milliseconds. + +use agent::{AgentEvent, ItemContent, ThreadItem, TurnStatus}; + +use crate::project::SessionMeta; +use crate::session::{MessageOrigin, StoredEvent}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ThreadActivity { + pub last_message_at: Option, + pub last_human_message_at: Option, + pub last_run_started_at: Option, + pub last_run_completed_at: Option, + /// The latest run ended in an error and nothing has run since. + pub failed: bool, +} + +impl ThreadActivity { + pub fn fold_stored<'a>( + records: impl IntoIterator, + has_parent: bool, + ) -> Self { + let mut activity = Self::default(); + for record in records { + activity.apply(record, has_parent); + } + activity + } + + /// Whether the record moves a clock: the latest of these, and what follows + /// it, is all the fold needs from a log. + pub fn moves_clock(event: &AgentEvent) -> bool { + matches!( + event, + AgentEvent::ItemCompleted(ThreadItem { + content: ItemContent::UserMessage { .. }, + .. + }) | AgentEvent::SteerRequested { .. } + | AgentEvent::TurnStarted { .. } + | AgentEvent::TurnCompleted { .. } + | AgentEvent::ProviderStartFailed { .. } + ) + } + + pub fn apply(&mut self, record: &StoredEvent, has_parent: bool) { + let Some(ts) = record.ts else { return }; + match &record.event { + AgentEvent::ItemCompleted(ThreadItem { + content: ItemContent::UserMessage { .. }, + .. + }) + | AgentEvent::SteerRequested { .. } => { + self.last_message_at = self.last_message_at.max(Some(ts)); + if record + .origin + .unwrap_or_else(|| MessageOrigin::legacy(has_parent)) + == MessageOrigin::Human + { + self.last_human_message_at = self.last_human_message_at.max(Some(ts)); + } + } + AgentEvent::TurnStarted { .. } => { + self.last_run_started_at = self.last_run_started_at.max(Some(ts)); + self.failed = false; + } + AgentEvent::TurnCompleted { status, .. } => { + self.last_run_completed_at = self.last_run_completed_at.max(Some(ts)); + self.failed = *status == TurnStatus::Failed; + } + AgentEvent::ProviderStartFailed { .. } => { + self.last_run_completed_at = self.last_run_completed_at.max(Some(ts)); + self.failed = true; + } + _ => {} + } + } + + pub fn last_activity_at(&self) -> Option { + [ + self.last_message_at, + self.last_run_started_at, + self.last_run_completed_at, + ] + .into_iter() + .flatten() + .max() + } +} + +/// Host facts that keep a thread out of automatic settlement. +#[derive(Debug, Clone, Copy, Default)] +pub struct SettlementBlockers { + pub pending_input: bool, + pub live_run: bool, + pub completion_holding_work: bool, + pub pending_human_message: bool, + pub scheduled_wake: bool, +} + +impl SettlementBlockers { + fn any(self) -> bool { + self.pending_input + || self.live_run + || self.completion_holding_work + || self.pending_human_message + || self.scheduled_wake + } +} + +/// The activity stamp, in milliseconds, to record as the settlement time when +/// the thread has been inactive for longer than `days`. +pub fn automatic_settlement_at( + meta: &SessionMeta, + activity: &ThreadActivity, + blockers: SettlementBlockers, + now_ms: u64, + days: Option, +) -> Option { + if meta.archived_at.is_some() + || meta.settled_override.is_some() + || meta.is_settled() + || meta.auto_settle_disabled_at.is_some() + || meta.parent_session_id.is_some() + || blockers.any() + { + return None; + } + let days = days?; + let last_activity = activity.last_activity_at()?; + ((last_activity as f64) < now_ms as f64 - days * 86_400_000.0).then_some(last_activity) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::project::SettledOverride; + + #[test] + fn inactivity_settles_at_the_last_activity_unless_blocked() { + let meta = SessionMeta::new(agent::ProviderKind::Codex, "/sample".into(), None); + let activity = ThreadActivity { + last_message_at: Some(100_000), + last_run_completed_at: Some(90_000), + ..Default::default() + }; + let three_days = 3 * 86_400_000; + let settle = |meta: &SessionMeta, activity: &ThreadActivity, blockers, now| { + automatic_settlement_at(meta, activity, blockers, now, Some(3.0)) + }; + let blockers = SettlementBlockers::default(); + assert_eq!( + settle(&meta, &activity, blockers, 100_000 + three_days + 1), + Some(100_000) + ); + assert_eq!( + settle(&meta, &activity, blockers, 100_000 + three_days), + None, + "strictly older than the window" + ); + let now = 100_000 + three_days + 1; + assert_eq!( + automatic_settlement_at(&meta, &activity, blockers, now, None), + None, + "never" + ); + assert_eq!( + settle(&meta, &ThreadActivity::default(), blockers, now), + None, + "no activity never ages" + ); + for blocker in [ + "archived", + "active", + "settled", + "disabled", + "child", + "input", + "run", + "background", + "human", + "wake", + ] { + let mut blocked = meta.clone(); + match blocker { + "archived" => blocked.archived_at = Some(1), + "active" => blocked.settled_override = Some(SettledOverride::Active), + "settled" => blocked.settled_override = Some(SettledOverride::Settled), + "disabled" => blocked.auto_settle_disabled_at = Some(1), + "child" => blocked.parent_session_id = Some("lead".into()), + _ => {} + } + let blockers = SettlementBlockers { + pending_input: blocker == "input", + live_run: blocker == "run", + completion_holding_work: blocker == "background", + pending_human_message: blocker == "human", + scheduled_wake: blocker == "wake", + }; + assert_eq!( + settle(&blocked, &activity, blockers, now), + None, + "{blocker}" + ); + } + } +} diff --git a/crates/core/src/thread_sort.rs b/crates/core/src/thread_sort.rs new file mode 100644 index 000000000..3c1b2c51b --- /dev/null +++ b/crates/core/src/thread_sort.rs @@ -0,0 +1,49 @@ +//! Thread sections and static lifecycle ordering, independent of client layout. +use crate::project::SessionMeta; +use std::cmp::Ordering; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ThreadSection { + Active, + Settled, +} + +pub fn thread_section(meta: &SessionMeta) -> ThreadSection { + if meta.is_settled() { + ThreadSection::Settled + } else { + ThreadSection::Active + } +} + +/// When a settled thread settled, in Unix seconds; the time it shows and sorts by. +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. +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))), + }) + .then_with(|| a.id.cmp(&b.id)) +} + +pub fn sort_threads(sessions: &mut [SessionMeta]) { + sessions.sort_by(compare_threads); +} + +pub fn partition_threads(sessions: &[SessionMeta]) -> (Vec, Vec) { + sessions + .iter() + .cloned() + .partition(|meta| thread_section(meta) == ThreadSection::Active) +} diff --git a/crates/protocol/src/command.rs b/crates/protocol/src/command.rs index 49e1ca347..9d72b6fb5 100644 --- a/crates/protocol/src/command.rs +++ b/crates/protocol/src/command.rs @@ -293,7 +293,11 @@ pub enum Command { SettleSession { session_id: String, }, - MakeSessionActive { + SetAutoSettle { + session_id: String, + enabled: bool, + }, + UnsettleSession { session_id: String, }, ArchiveSession { @@ -302,9 +306,6 @@ pub enum Command { UnarchiveSession { session_id: String, }, - AutoArchiveSweep { - project_id: String, - }, RenameSession { session_id: String, title: String, @@ -434,7 +435,6 @@ pub enum CommandResponse { section: Option, session_id: Option, }, - ArchivedCount(usize), ExternalImportStarted(bool), } @@ -458,7 +458,8 @@ impl Command { | Self::AddReviewComment { session_id, .. } | Self::RemoveReviewComment { session_id, .. } | Self::SettleSession { session_id, .. } - | Self::MakeSessionActive { session_id, .. } + | Self::UnsettleSession { session_id, .. } + | Self::SetAutoSettle { session_id, .. } | Self::ArchiveSession { session_id, .. } | Self::UnarchiveSession { session_id, .. } | Self::RenameSession { session_id, .. } diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index e4cbef0a1..58b3cb0ba 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -394,6 +394,8 @@ pub struct SessionActivity { pub waiting: bool, pub waiting_for_approval: bool, pub waiting_for_input: bool, + /// The latest run ended in an error and nothing has run since. + pub failed: bool, pub unread: bool, pub fork: ForkAvailability, } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 2aa96f819..1d45be16e 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -74,6 +74,13 @@ pub use wire::{ // also covers unfinished child threads; adds the CreateNewProject and // StartScratchDraft commands; adds per-host GitHub settings, credential source // discovery, SetGitHubToken and RefreshGitHubCredentials. +// Unreleased: Settled/Active overrides and lifecycle timestamps on thread +// metadata; UnsettleSession replaces MakeSessionActive, SetAutoSettle controls +// the durable per-thread disable; global and per-project settlement settings; +// SessionActivity.failed. Idle AutoArchiveSweep/ArchivedCount and the +// idle-auto-archive settings are removed. +// Unreleased: stored messages carry human/agent/server origin; absent origin +// uses the historical top-level human / dispatched child agent fallback. pub const PROTOCOL_VERSION: u32 = 10; #[cfg(test)] diff --git a/crates/protocol/src/tests.rs b/crates/protocol/src/tests.rs index b4d555a2e..a5d56f50c 100644 --- a/crates/protocol/src/tests.rs +++ b/crates/protocol/src/tests.rs @@ -159,6 +159,7 @@ fn event_envelopes_keep_stored_record_shape_and_optional_request_id() { session_id: "session-1".into(), }, event: ServerEvent::SessionEvent(SessionEventRecord { + origin: None, author: None, ts: Some(123), event: AgentEvent::TurnStarted { @@ -945,6 +946,7 @@ fn version_six_index_visits_output_and_elision_literal_json() { waiting: false, waiting_for_approval: false, waiting_for_input: false, + failed: false, unread: true, fork: ForkAvailability::Available, }, @@ -959,7 +961,8 @@ fn version_six_index_visits_output_and_elision_literal_json() { assert_eq!( serde_json::to_value(&index).unwrap(), json!({"activity": {"cold": {"working":false,"turn_running":false,"waiting":false, - "waiting_for_approval":false,"waiting_for_input":false,"unread":true,"fork":"available"}}, + "waiting_for_approval":false,"waiting_for_input":false,"failed":false,"unread":true, + "fork":"available"}}, "title_generating": [], "archived_counts": {"p": 2}, "worktree_shared": [], "archived_revision": 7, "sessions": [], "projects": []}) ); @@ -992,7 +995,7 @@ fn version_six_index_visits_output_and_elision_literal_json() { "review_comment_drafts":[], "terminals":[], "active_terminal_id":null, "terminal_splits":[], "terminal_contexts":[], "terminal_open":false, "terminal_height":240.0, "delivery_in_flight":3, "activity":{"working":true,"turn_running":false,"waiting":false,"waiting_for_approval":false, - "waiting_for_input":false,"unread":false,"fork":"available"}, + "waiting_for_input":false,"failed":false,"unread":false,"fork":"available"}, "stopping":false,"native_rewind_blocked":true,"checkout_blocked":false,"conversation_read_only":false, "terminal_limit_reached":false,"terminal_split_available":false,"usage":null,"context_window":200000, "running_turn":null,"pending_approvals":[],"pending_user_input":null,"supports_steering":true, @@ -1025,6 +1028,7 @@ fn version_six_index_visits_output_and_elision_literal_json() { turn_id: "t".into(), }; let record = SessionEventRecord { + origin: None, author: None, ts: Some(1), event: turn_started.clone(), diff --git a/crates/runtime/src/app/active_session.rs b/crates/runtime/src/app/active_session.rs index 5c0ea3b55..84ee0b746 100644 --- a/crates/runtime/src/app/active_session.rs +++ b/crates/runtime/src/app/active_session.rs @@ -14,6 +14,7 @@ pub struct QueuedMessage { pub id: u64, pub(super) delivery_key: Option, pub author: Option, + pub origin: MessageOrigin, pub text: String, /// Provider-only context for the first turn after a relay. The canonical /// user event continues to record only `text`. @@ -452,6 +453,7 @@ impl ActiveSession { self.queue.push(QueuedMessage { delivery_key: None, author: None, + origin: MessageOrigin::Human, id, text, relay_transcript: None, @@ -499,6 +501,7 @@ impl ActiveSession { self.queue.push(QueuedMessage { delivery_key: None, author: None, + origin: MessageOrigin::Agent, id, text, relay_transcript: None, diff --git a/crates/runtime/src/app/authorization.rs b/crates/runtime/src/app/authorization.rs index c2ecdfa2b..fe1928fb5 100644 --- a/crates/runtime/src/app/authorization.rs +++ b/crates/runtime/src/app/authorization.rs @@ -251,7 +251,8 @@ impl AppState { | Command::ArchiveSession { session_id } | Command::UnarchiveSession { session_id } | Command::SettleSession { session_id } - | Command::MakeSessionActive { session_id } + | Command::UnsettleSession { session_id } + | Command::SetAutoSettle { session_id, .. } | Command::RewindTurn { session_id, .. } | Command::AddReviewComment { session_id, .. } | Command::RemoveReviewComment { session_id, .. } @@ -287,7 +288,6 @@ impl AppState { | Command::ToggleFavoriteModel { .. } | Command::ToggleProjectCollapsed { .. } | Command::SetThreadCollapsed { .. } - | Command::AutoArchiveSweep { .. } | Command::PreviewReply { .. } | Command::ReloadProvider | Command::SetGitHubToken { .. } diff --git a/crates/runtime/src/app/command_validation.rs b/crates/runtime/src/app/command_validation.rs index cf6a1ae71..a532036fe 100644 --- a/crates/runtime/src/app/command_validation.rs +++ b/crates/runtime/src/app/command_validation.rs @@ -1,4 +1,5 @@ use super::*; +use tcode_core::settings::SettingsPatch; impl AppState { pub(super) fn native_rewind_blocked(&self, active: &ActiveSession) -> bool { @@ -36,6 +37,23 @@ impl AppState { message: message.into(), }; self.validate_plugin_command(command)?; + if let Command::PatchSettings { patch } = command { + if let SettingsPatch::ProjectSettlement { project_id, .. } = patch + && !self + .projects + .iter() + .any(|project| &project.id == project_id) + { + return Err(error( + "unknown_project", + "This project is no longer available.", + )); + } + self.settings + .clone() + .apply(patch.clone()) + .map_err(|message| error("invalid_settings", message))?; + } match command { Command::SetGitHubToken { host, .. } | Command::PatchSettings { @@ -151,17 +169,30 @@ impl AppState { let Some(session_id) = command.session_id() else { return Ok(()); }; - if matches!(command, Command::SettleSession { .. }) && self.settle_family_busy(session_id) { + if matches!( + command, + Command::SettleSession { .. } | Command::UnsettleSession { .. } + ) && self + .find_meta(session_id) + .is_some_and(|meta| meta.archived_at.is_some()) + { + return Err(error( + "archived_session", + "Unarchive this thread before changing its lifecycle.", + )); + } + if matches!(command, Command::SettleSession { .. }) && self.settle_thread_busy(session_id) { return Err(error( "thread_busy", - "Wait for this thread and its children to finish before settling.", + "Wait for this thread to finish before settling.", )); } // Index mutations operate on stored sessions, without requiring a live provider. if matches!( command, Command::SettleSession { .. } - | Command::MakeSessionActive { .. } + | Command::UnsettleSession { .. } + | Command::SetAutoSettle { .. } | Command::ArchiveSession { .. } | Command::UnarchiveSession { .. } | Command::RenameSession { .. } diff --git a/crates/runtime/src/app/events.rs b/crates/runtime/src/app/events.rs index 2ecf317df..8bca8d03f 100644 --- a/crates/runtime/src/app/events.rs +++ b/crates/runtime/src/app/events.rs @@ -3,6 +3,16 @@ use super::*; impl AppState { /// Handle one canonical event from the live provider. pub(super) fn on_event(&mut self, session_id: &str, event: AgentEvent, cx: &mut HostCx) { + if matches!( + &event, + AgentEvent::TurnCompleted { .. } + | AgentEvent::SessionClosed { .. } + | AgentEvent::ProviderStartFailed { .. } + | AgentEvent::BackgroundTasksChanged { count: 0 } + ) { + let id = session_id.to_owned(); + cx.enqueue(move |state, cx| state.evaluate_thread_settlement(&id, cx)); + } log::debug!( "event: {}", serde_json::to_string(&event).unwrap_or_else(|_| "".into()) @@ -320,6 +330,7 @@ impl AppState { Vec::new(), not_before, ); + resident.queue.last_mut().unwrap().origin = MessageOrigin::Server; true }); if scheduled { @@ -568,12 +579,40 @@ impl AppState { } _ => None, }; + // Messages the provider reports on its own carry no admission origin; + // the activity fold falls back to the thread's historical origin. + let origin = match event { + AgentEvent::ItemCompleted(ThreadItem { + content: ItemContent::UserMessage { .. }, + .. + }) + | AgentEvent::SteerRequested { .. } => cx.origin, + _ => None, + }; let record = SessionEventRecord { + origin, author: author.clone(), ts: Some(ts), event: event.clone(), elided: None, }; + if let Some(activity) = self.thread_activity.get_mut(session_id) { + let message = matches!( + event, + AgentEvent::ItemCompleted(ThreadItem { + content: ItemContent::UserMessage { .. }, + .. + }) | AgentEvent::SteerRequested { .. } + ); + let has_parent = message + && origin.is_none() + && self + .sessions + .iter() + .any(|meta| meta.id == session_id && meta.parent_session_id.is_some()); + activity.apply(&record, has_parent); + } + self.advance_decision_revision(session_id); let topic = Topic::SessionEvents { session_id: session_id.to_string(), }; @@ -604,6 +643,7 @@ impl AppState { id: session_id.to_string(), ts, author, + origin, event: Box::new(event.clone()), joined, }, diff --git a/crates/runtime/src/app/history.rs b/crates/runtime/src/app/history.rs index 15b73f435..0c6aae72e 100644 --- a/crates/runtime/src/app/history.rs +++ b/crates/runtime/src/app/history.rs @@ -582,7 +582,7 @@ pub(super) struct Tail { } /// Rows read per step while a tail is read backwards. -const TAIL_ROWS: u64 = 256; +pub(super) const TAIL_ROWS: u64 = 256; impl Tail { /// Read the rows before `end` backwards from it until they reach the @@ -856,6 +856,17 @@ impl AppState { for query in hydration.queries { query(Ok(&log)); } + if let Some(meta) = self.find_meta(&session_id) + && !self.thread_activity.contains_key(&session_id) + { + self.thread_activity.insert( + session_id.clone(), + tcode_core::settlement::ThreadActivity::fold_stored( + &log.records, + meta.parent_session_id.is_some(), + ), + ); + } // Queued after every append the log holds, so it covers all of them. self.enqueue_store_write( StoreWrite::SetTurnIndex { diff --git a/crates/runtime/src/app/lifecycle.rs b/crates/runtime/src/app/lifecycle.rs index a5d5771d2..ce7a81f97 100644 --- a/crates/runtime/src/app/lifecycle.rs +++ b/crates/runtime/src/app/lifecycle.rs @@ -161,8 +161,16 @@ impl AppState { let pump = HostCx::spawn_background(cx, async move { while let Ok(event) = events.recv().await { let event_session = pump_session.clone(); + let event_commands = pump_commands.clone(); pump_cx.enqueue(move |state, cx| { - state.on_event(&event_session, event, cx); + // A provider detached to idle may still have events + // queued; they must not reach a restarted one. + let current = state.resident(&event_session).is_some_and( + |session| matches!(&session.runtime, Runtime::Live(live) if live.same_channel(&event_commands)), + ); + if current { + state.on_event(&event_session, event, cx); + } }); } pump_cx.enqueue(move |state, cx| { @@ -261,6 +269,7 @@ impl AppState { } state.deliver_child_callback(&session_id, TurnStatus::Failed, cx); } + state.evaluate_thread_settlement(&session_id, cx); state.report_error( RuntimeError::ProviderStart { error: err.to_string(), @@ -318,6 +327,7 @@ impl AppState { } pub(super) fn persist_meta(&mut self, meta: &SessionMeta, cx: &mut HostCx) { + self.advance_decision_revision(&meta.id); self.enqueue_store_write( StoreWrite::UpsertMeta { meta: Box::new(meta.clone()), @@ -332,6 +342,36 @@ impl AppState { self.upsert_session_in_memory(meta.clone()); } + /// Close what this thread's provider leaves open before it goes away: the + /// persisted turn and requests, and its native subagent mirrors. Execution + /// children own independent providers and keep running. + pub(super) fn retire_provider_work(&mut self, id: &str, cx: &mut HostCx) { + let has_open_work = self.resident(id).is_some_and(|session| { + !session.draft + && !matches!(session.runtime, Runtime::Idle) + && (session.turn_in_flight + || session.delivery_in_flight.is_some() + || session.background_task_count > 0 + || session.timeline.turn_running + || !session.timeline.pending_approvals.is_empty() + || session.timeline.pending_user_input.is_some()) + }); + if has_open_work { + self.record_event(id, &AgentEvent::SessionClosed { reason: None }, cx); + } + self.interrupt_native_subagent_work(id, cx); + self.clear_approvals(id); + } + + /// Detach this thread's provider and keep the thread resident. Events the + /// old provider still has queued are dropped by the pump's channel check. + pub(super) fn detach_provider_to_idle(&mut self, id: &str, cx: &mut HostCx) { + self.retire_provider_work(id, cx); + if let Some(session) = self.resident_mut(id) { + session.shutdown_to_idle(); + } + } + pub(crate) fn shutdown_active(&mut self, target_id: &str, cx: &mut HostCx) { self.revoke_computer_use_registration(target_id); if let Some(session_id) = self diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index 2a11f8549..73e11b031 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -25,8 +25,7 @@ use tcode_core::acp::{AcpAgentPatch, InstalledAcpAgent as InstalledAgent}; use tcode_core::attachments::mime_from_path; use tcode_core::git::{GitAction, GitStatus, build_commit_prompt, sanitize_commit_message}; use tcode_core::project::{ - AutoArchiveConfig, AutoArchiveExemptions, Project, SessionMeta, WorktreeInfo, WorktreeSharing, - auto_archive_candidates, descendant_session_ids, + Project, SessionMeta, SettledOverride, WorktreeInfo, WorktreeSharing, descendant_session_ids, }; use tcode_core::provider_status::ProviderSnapshot; use tcode_core::relay::{ @@ -34,7 +33,7 @@ use tcode_core::relay::{ render_relay_transcript, }; use tcode_core::session::{ - Author, EntryContent, ReviewComment, Timeline, append_review_comments_to_prompt, + Author, EntryContent, MessageOrigin, ReviewComment, Timeline, append_review_comments_to_prompt, }; use tcode_core::settings::{ ChildApprovalMode, EnvVar, OrchestrateSettings, ProfileSettingsPatch, ProviderProfile, @@ -256,6 +255,7 @@ mod plugins; mod providers; mod send; mod sessions; +mod settlement; mod snapshots; mod store_write; mod subagents; @@ -340,6 +340,12 @@ pub struct AppState { store_failed: bool, pub sessions: Vec, archived_revision: u64, + settlement_sweep_running: bool, + settlement_sweep_pending: bool, + settlement_timer_generation: u64, + decision_revisions: HashMap, + callback_generations: HashMap, + thread_activity: HashMap, space_scopes: HashMap>, space_policy_revisions: HashMap, space_archives_revision: Option, @@ -551,6 +557,12 @@ impl AppState { sessions, projects, archived_revision: 0, + settlement_sweep_running: false, + settlement_sweep_pending: false, + settlement_timer_generation: 0, + decision_revisions: HashMap::new(), + callback_generations: HashMap::new(), + thread_activity: HashMap::new(), space_scopes: HashMap::new(), space_policy_revisions: HashMap::new(), space_archives_revision: None, diff --git a/crates/runtime/src/app/orchestrate.rs b/crates/runtime/src/app/orchestrate.rs index d6616d975..00204c0f6 100644 --- a/crates/runtime/src/app/orchestrate.rs +++ b/crates/runtime/src/app/orchestrate.rs @@ -405,6 +405,7 @@ impl AppState { child.meta = meta; child.draft = false; child.push_queued(brief, Vec::new()); + child.queue.last_mut().unwrap().origin = MessageOrigin::Agent; self.residents.parked.insert(id.clone(), child); self.reactivate_session(&id, cx); self.ensure_session_started(&id, cx); @@ -437,6 +438,10 @@ impl AppState { cx: &mut HostCx, ) { use orchestrate_mcp::OrchestrateOp; + let mut authored_cx = cx.clone(); + authored_cx.origin = Some(MessageOrigin::Agent); + authored_cx.author = None; + let cx = &mut authored_cx; match op { orchestrate_mcp::OrchestrateOp::Status { @@ -643,7 +648,6 @@ impl AppState { if archived { self.unarchive_session(&thread_id, cx); } - self.reactivate_session(&thread_id, cx); // A live turn accepts the message right away — same routing as // parent callbacks. Queueing a mid-turn correction until the // turn ends would deliver it after the work it was meant to @@ -664,9 +668,13 @@ impl AppState { // Provider channel gone: fall through so the text survives // in the queue for the wake-up path. } + if !can_steer { + self.reactivate_session(&thread_id, cx); + } if self.residents.live.contains_key(&thread_id) { let child = self.resident_mut(&thread_id).unwrap(); child.push_queued(message, Vec::new()); + child.queue.last_mut().unwrap().origin = MessageOrigin::Agent; let idle = matches!(child.runtime, Runtime::Idle); if self.dispatch_next_queued(&thread_id, cx).is_err() { return Err("child provider is unavailable".into()); @@ -679,6 +687,7 @@ impl AppState { self.ensure_child_loaded(&thread_id, cx)?; let child = self.resident_mut(&thread_id).unwrap(); child.push_queued(message, Vec::new()); + child.queue.last_mut().unwrap().origin = MessageOrigin::Agent; let idle = matches!(child.runtime, Runtime::Idle); if !idle && !child.turn_in_flight { self.on_background_turn_completed(&thread_id, cx); @@ -697,6 +706,7 @@ impl AppState { let result = (|| { self.require_child(&parent_id, &thread_id)?; self.clear_approvals(&thread_id); + self.invalidate_child_callback(&thread_id); if self.residents.live.contains_key(&thread_id) { if let Some(child) = self.resident_mut(&thread_id) { child.queue.clear(); @@ -1095,6 +1105,7 @@ impl AppState { // attention. let auto_archive = child.archive_on_complete && matches!(status, TurnStatus::Completed); let result_max_chars = child.result_max_chars; + let generation = self.callback_generation(&child_id); let fold = self.folded_log(&child_id, cx); let host_cx = cx.clone(); HostCx::spawn_detached(cx, async move { @@ -1112,11 +1123,18 @@ impl AppState { return; } }; - let child_still_exists = state.sessions.iter().any(|meta| { - meta.id == child_id - && meta.parent_session_id.as_deref() == Some(parent_id.as_str()) - }); - if !child_still_exists { + // New work for the child, or archiving or deleting either side, + // while its log was read makes this completion stale. + let current = state.callback_generation(&child_id) == generation + && state + .find_meta(&parent_id) + .is_some_and(|meta| meta.archived_at.is_none()) + && state.sessions.iter().any(|meta| { + meta.id == child_id + && meta.archived_at.is_none() + && meta.parent_session_id.as_deref() == Some(parent_id.as_str()) + }); + if !current { return; } let turn = timeline.turns.len(); @@ -1232,7 +1250,16 @@ impl AppState { text: String, cx: &mut HostCx, ) { - self.reactivate_session(parent_id, cx); + if !self + .find_meta(parent_id) + .is_some_and(|meta| meta.archived_at.is_none()) + { + return; + } + let mut callback_cx = cx.clone(); + callback_cx.origin = Some(MessageOrigin::Agent); + callback_cx.author = None; + let cx = &mut callback_cx; let can_steer = self .resident(parent_id) .is_some_and(|parent| parent.turn_in_flight && parent.can_steer()); @@ -1249,6 +1276,8 @@ impl AppState { return; } + self.reactivate_session(parent_id, cx); + if self.residents.live.contains_key(parent_id) { let parent = self.resident_mut(parent_id).unwrap(); parent.push_or_merge_orchestrate_callback(text); diff --git a/crates/runtime/src/app/plugins.rs b/crates/runtime/src/app/plugins.rs index 6a59dac52..b84652279 100644 --- a/crates/runtime/src/app/plugins.rs +++ b/crates/runtime/src/app/plugins.rs @@ -847,7 +847,7 @@ mod tests { fn host_with(fake: &FakeClaude, store: &TestStore) -> TestClientState { let mut state = unmanaged_host(fake, store); - state.settings.apply(claude_switch(true)); + state.settings.apply(claude_switch(true)).unwrap(); state } diff --git a/crates/runtime/src/app/send.rs b/crates/runtime/src/app/send.rs index fb86c33d4..1dc1e127b 100644 --- a/crates/runtime/src/app/send.rs +++ b/crates/runtime/src/app/send.rs @@ -47,6 +47,7 @@ impl AppState { if let Some(message) = active.queue.last_mut() { message.delivery_key = cx.delivery_key.clone(); message.author = cx.author.clone(); + message.origin = cx.origin.unwrap_or(message.origin); } let should_start = matches!(active.runtime, Runtime::Idle) && !(active.draft @@ -124,9 +125,11 @@ impl AppState { continue; }; let author = std::mem::replace(&mut cx.author, message.author); + let origin = cx.origin.replace(message.origin); let key = std::mem::replace(&mut cx.delivery_key, message.delivery_key); self.send_turn_assembled(&session_id, message.text, message.attachments, cx); cx.author = author; + cx.origin = origin; cx.delivery_key = key; } @@ -238,6 +241,7 @@ impl AppState { if let Some(message) = active.queue.last_mut() { message.delivery_key = cx.delivery_key.clone(); message.author = cx.author.clone(); + message.origin = cx.origin.unwrap_or(message.origin); } let model_changed = active.model_changed_while_live(); @@ -358,6 +362,7 @@ impl AppState { if let Some(message) = active.queue.last_mut() { message.delivery_key = cx.delivery_key.clone(); message.author = cx.author.clone(); + message.origin = cx.origin.unwrap_or(message.origin); } if let Some(message) = active.queue.last_mut() { message.relay_transcript = Some(transcript); @@ -402,6 +407,7 @@ impl AppState { return; }; let author = std::mem::replace(&mut cx.author, message.author.clone()); + let origin = cx.origin.replace(message.origin); self.record_user_message( session_id, &message.text, @@ -411,6 +417,7 @@ impl AppState { cx, ); cx.author = author; + cx.origin = origin; if let Some(window) = message.context_window_changed { self.record_event(session_id, &AgentEvent::ContextWindowChanged { window }, cx); } @@ -613,9 +620,11 @@ impl AppState { }; let command_key = std::mem::replace(&mut cx.delivery_key, message.delivery_key); let author = std::mem::replace(&mut cx.author, message.author); + let origin = cx.origin.replace(message.origin); self.steer_assembled(target_id, message.text, message.attachments, cx); cx.delivery_key = command_key; cx.author = author; + cx.origin = origin; self.reschedule_scheduled_wake(cx); } diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index b65de653c..f40e49222 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -31,6 +31,32 @@ impl ResidentSessions { } impl AppState { + /// Advanced by every record and metadata write for the thread, so a + /// decision read before an await can tell it is still current. + pub(super) fn advance_decision_revision(&mut self, id: &str) { + let revision = self.decision_revisions.entry(id.to_owned()).or_default(); + *revision = revision + .checked_add(1) + .expect("thread decision revision overflow"); + } + + pub(super) fn decision_revision(&self, id: &str) -> u64 { + self.decision_revisions.get(id).copied().unwrap_or(0) + } + + /// Advanced when a child's completion in flight stops being the one to + /// deliver: new work was admitted for it, or it was cancelled or archived. + pub(super) fn invalidate_child_callback(&mut self, id: &str) { + let generation = self.callback_generations.entry(id.to_owned()).or_default(); + *generation = generation + .checked_add(1) + .expect("callback generation overflow"); + } + + pub(super) fn callback_generation(&self, id: &str) -> u64 { + self.callback_generations.get(id).copied().unwrap_or(0) + } + pub(crate) fn subscribe( &mut self, subscription: &tcode_protocol::Subscription, @@ -671,6 +697,9 @@ impl AppState { } pub fn update_settings(&mut self, settings: Settings, cx: &mut HostCx) { + let settlement_changed = self.settings.auto_settle_after_days + != settings.auto_settle_after_days + || self.settings.project_settlement_overrides != settings.project_settlement_overrides; self.enqueue_settings(&settings, cx); if ProviderKind::NATIVE .iter() @@ -698,6 +727,9 @@ impl AppState { if github_changed { self.refresh_github_credentials(cx); } + if settlement_changed { + self.request_settlement_sweep(cx); + } self.forget_disabled_plugin_catalogs(cx); self.providers.provider_secret_names = provider_secret_names(&self.settings, &self.settings_store); @@ -712,8 +744,10 @@ impl AppState { pub fn patch_settings(&mut self, patch: tcode_protocol::SettingsPatch, cx: &mut HostCx) { let mut settings = self.settings.clone(); - settings.apply(patch); - self.update_settings(settings, cx); + // Command validation already refused a patch `apply` would refuse. + if settings.apply(patch).is_ok() { + self.update_settings(settings, cx); + } } /// Persist a restart-continuity marker naming the Settings page to reopen and @@ -752,71 +786,122 @@ impl AppState { (Some(marker.reopen_settings), session_id) } - pub(super) fn settle_family_busy(&self, session_id: &str) -> bool { - descendant_session_ids(&self.sessions, session_id) - .iter() - .any(|id| { - self.resident(id).is_some_and(|session| { - session.has_work() - || session.timeline.turn_running - || !session.timeline.pending_approvals.is_empty() - || session.timeline.pending_user_input.is_some() - }) - }) + pub(super) fn settle_thread_busy(&self, id: &str) -> bool { + self.resident(id).is_some_and(|session| { + session.preparing_worktree + || matches!(session.runtime, Runtime::Starting { .. }) + || session.turn_in_flight + || session.delivery_in_flight.is_some() + || session.timeline.turn_running + || !session.timeline.pending_approvals.is_empty() + || session + .timeline + .pending_user_input + .as_ref() + .is_some_and(|input| input.delivery.is_blocking()) + || session + .queue + .iter() + .any(|message| message.origin == MessageOrigin::Human) + }) } - /// Settle a whole descendant group without shutting down its resources. - pub fn settle_session(&mut self, session_id: &str, cx: &mut HostCx) { - if self.settle_family_busy(session_id) { + pub fn settle_session(&mut self, id: &str, cx: &mut HostCx) { + if !self.settle_thread_busy(id) { + self.settle_session_at(id, now_secs(), cx); + } + } + + /// Settle this thread only: detach its provider, cancel its automatic + /// queue and close its idle terminals. Children are separate threads. + pub(super) fn settle_session_at(&mut self, id: &str, timestamp: u64, cx: &mut HostCx) { + let Some(mut meta) = self.find_meta(id).filter(|meta| meta.archived_at.is_none()) else { return; + }; + if !meta.is_settled() { + meta.settled_at = Some(timestamp); + meta.updated_at = now_secs(); } - let timestamp = now_secs(); - for id in descendant_session_ids(&self.sessions, session_id) { - if let Some(mut meta) = self.find_meta(&id) - && meta.settled_at.is_none() - { - meta.settled_at = Some(timestamp); - self.persist_settled_meta(meta, cx); - } + if let Some(input) = self + .resident(id) + .and_then(|session| session.timeline.pending_user_input.as_ref()) + .filter(|input| !input.delivery.is_blocking()) + { + let request_id = input.request_id.clone(); + self.on_event( + id, + AgentEvent::UserInputResolved { + request_id, + answers: Default::default(), + }, + cx, + ); } + meta.settled_override = Some(SettledOverride::Settled); + meta.unsettled_at = None; + self.detach_provider_to_idle(id, cx); + if let Some(session) = self.resident_mut(id) { + session + .queue + .retain(|message| message.origin == MessageOrigin::Human); + session.meta = meta.clone(); + } + self.persist_meta(&meta, cx); + self.reschedule_scheduled_wake(cx); + self.close_settled_idle_terminals(id, cx); } - /// Restore the matching settle cascade, then expose all of its ancestors. - pub fn make_session_active(&mut self, session_id: &str, cx: &mut HostCx) { - if let Some(timestamp) = self.find_meta(session_id).and_then(|meta| meta.settled_at) { - for id in descendant_session_ids(&self.sessions, session_id) { - if let Some(mut meta) = self.find_meta(&id) - && meta.settled_at == Some(timestamp) - { - meta.settled_at = None; - self.persist_settled_meta(meta, cx); - } - } + pub fn unsettle_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.settled_override == Some(SettledOverride::Active) { + return; + } + meta.settled_override = Some(SettledOverride::Active); + meta.settled_at = None; + meta.unsettled_at = Some(now_secs()); + meta.updated_at = now_secs(); + if let Some(session) = self.resident_mut(id) { + session.meta = meta.clone(); } - self.reactivate_session(session_id, cx); + self.persist_meta(&meta, cx); } - /// Accepted input exposes this thread and its ancestors, leaving siblings settled. - pub(super) fn reactivate_session(&mut self, session_id: &str, cx: &mut HostCx) { - let mut next = Some(session_id.to_string()); - let mut visited = HashSet::new(); - while let Some(id) = next.take() { - if !visited.insert(id.clone()) { - break; - } - let Some(mut meta) = self.find_meta(&id) else { - break; - }; - next = meta.parent_session_id.clone(); - if meta.settled_at.take().is_some() { - self.persist_settled_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; + }; + if enabled == meta.auto_settle_disabled_at.is_none() { + return; + } + meta.auto_settle_disabled_at = (!enabled).then(now_secs); + meta.updated_at = now_secs(); + if let Some(session) = self.resident_mut(id) { + session.meta = meta.clone(); } + self.persist_meta(&meta, cx); + self.evaluate_thread_settlement(id, cx); } - fn persist_settled_meta(&mut self, meta: SessionMeta, cx: &mut HostCx) { - if let Some(session) = self.resident_mut(&meta.id) { - session.meta.settled_at = meta.settled_at; + /// A message accepted for this thread: reopen it and make any completion + /// already on its way from it stale. Its queued work blocks settlement + /// until the provider records it. + pub(super) fn reactivate_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; + }; + self.invalidate_child_callback(id); + if meta.settled_override.is_none() && meta.settled_at.is_none() { + return; + } + if meta.is_settled() { + meta.unsettled_at = Some(now_secs()); + } + meta.settled_override = None; + meta.settled_at = None; + if let Some(session) = self.resident_mut(id) { + session.meta = meta.clone(); } self.persist_meta(&meta, cx); } @@ -879,8 +964,7 @@ impl AppState { } /// Restore an archived thread (Settings → Archived Threads → Unarchive). - /// Restoring is activity: without it the auto-archive sweep would take an - /// idle thread straight back. A thread that was read stays read. + /// A thread that was read before archiving stays read. pub fn unarchive_session(&mut self, session_id: &str, cx: &mut HostCx) { let Some(archived_at) = self .sessions @@ -917,45 +1001,6 @@ impl AppState { } } - /// Sweep one project's visible sessions using the configured idle and - /// sibling keep windows. Returns the number of threads archived. - pub fn auto_archive_sweep(&mut self, project_id: &str, cx: &mut HostCx) -> usize { - if self.settings.auto_archive_disabled { - return 0; - } - let sessions: Vec<_> = self - .sessions - .iter() - .filter(|meta| { - meta.project_id.as_deref() == Some(project_id) && meta.archived_at.is_none() - }) - .cloned() - .collect(); - let exemptions = AutoArchiveExemptions { - working: sessions - .iter() - .filter(|meta| self.turn_running_for(&meta.id)) - .map(|meta| meta.id.clone()) - .collect(), - unread: sessions - .iter() - .filter(|meta| self.session_unread(meta)) - .map(|meta| meta.id.clone()) - .collect(), - active: self.residents.live.keys().cloned().collect(), - }; - let config = AutoArchiveConfig { - max_idle_secs: u64::from(self.settings.auto_archive_max_idle_days.max(1)) * 86_400, - keep_count: self.settings.auto_archive_keep_count.max(1), - }; - let ids = auto_archive_candidates(&sessions, now_secs(), &config, &exemptions); - let count = ids.len(); - if count > 0 { - self.archive_session_ids(&ids, now_secs(), cx); - } - count - } - pub(super) fn archive_session_ids( &mut self, ids: &[String], @@ -968,6 +1013,8 @@ impl AppState { .collect(); for id in &ids { + self.invalidate_child_callback(id); + self.retire_provider_work(id, cx); self.shutdown_active(id, cx); // An archived conversation must not leave an off-screen PTY running. self.terminal_workspaces @@ -1156,6 +1203,12 @@ impl AppState { } self.sessions .retain(|meta| !deleted.contains(meta.id.as_str())); + self.thread_activity + .retain(|id, _| !deleted.contains(id.as_str())); + self.decision_revisions + .retain(|id, _| !deleted.contains(id.as_str())); + self.callback_generations + .retain(|id, _| !deleted.contains(id.as_str())); let mut kept_worktrees = Vec::new(); let mut worktree_removals = Vec::new(); let sharing = self.worktree_sharing(); diff --git a/crates/runtime/src/app/settlement.rs b/crates/runtime/src/app/settlement.rs new file mode 100644 index 000000000..410c668ae --- /dev/null +++ b/crates/runtime/src/app/settlement.rs @@ -0,0 +1,230 @@ +use super::*; +use tcode_core::settlement::{SettlementBlockers, ThreadActivity, automatic_settlement_at}; + +impl AppState { + pub(crate) fn start_settlement_sweeps(&mut self, cx: &mut HostCx) { + self.request_settlement_sweep(cx); + } + + /// Evaluate every thread: project activity for threads that have none + /// from the tail of their logs, then settle the ones that are due. Repeats + /// one minute after it drains. + pub(super) fn request_settlement_sweep(&mut self, cx: &mut HostCx) { + if self.settlement_sweep_running { + self.settlement_sweep_pending = true; + return; + } + if self.store_failed { + return; + } + self.settlement_sweep_running = true; + self.settlement_timer_generation += 1; + let started = Instant::now(); + let missing: Vec<_> = self + .sessions + .iter() + .filter(|meta| { + meta.archived_at.is_none() && !self.thread_activity.contains_key(&meta.id) + }) + .map(|meta| { + ( + meta.id.clone(), + meta.parent_session_id.is_some(), + self.decision_revision(&meta.id), + ) + }) + .collect(); + let store = self.store.clone(); + let (flushed, flush) = smol::channel::bounded(1); + self.enqueue_store_write(StoreWrite::Flush(flushed), cx); + let host_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + let projections = if matches!(flush.recv().await, Ok(Ok(()))) { + host_cx + .unblock(move || { + missing + .into_iter() + .map(|(id, has_parent, revision)| { + let activity = read_activity_tail(&store, &id).map(|records| { + ThreadActivity::fold_stored(&records, has_parent) + }); + (id, revision, activity) + }) + .collect::>() + }) + .await + } else { + vec![] + }; + let projected = started.elapsed(); + host_cx.enqueue(move |state, cx| { + for (id, revision, activity) in projections { + if state.find_meta(&id).is_none() || state.thread_activity.contains_key(&id) { + continue; + } + // A record appended while the log was read is missing from it. + if state.decision_revision(&id) != revision { + state.settlement_sweep_pending = true; + continue; + } + match activity { + Ok(activity) => { + state.thread_activity.insert(id, activity); + } + Err(error) => log::warn!("settlement activity read for {id}: {error}"), + } + } + let holders = state.completion_holders(); + let now = now_millis(); + let due: Vec<_> = state + .sessions + .iter() + .filter_map(|meta| state.settlement_due(meta, &holders, now)) + .collect(); + for (id, at) in due { + state.settle_session_at(&id, at / 1000, cx); + } + log::info!( + "settlement sweep: {} threads, {} activity projections, read {} ms, total {} ms", + state.sessions.len(), + state.thread_activity.len(), + projected.as_millis(), + started.elapsed().as_millis() + ); + state.settlement_sweep_running = false; + if std::mem::take(&mut state.settlement_sweep_pending) { + state.request_settlement_sweep(cx); + return; + } + let generation = state.settlement_timer_generation; + let timer_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + smol::Timer::after(Duration::from_secs(60)).await; + timer_cx.enqueue(move |state, cx| { + if state.settlement_timer_generation == generation { + state.request_settlement_sweep(cx); + } + }); + }); + }); + }); + } + + /// Settle one thread now if it is due; for changes that concern only it. + pub(super) fn evaluate_thread_settlement(&mut self, id: &str, cx: &mut HostCx) { + let holders = self.completion_holders(); + let due = self + .sessions + .iter() + .find(|meta| meta.id == id) + .and_then(|meta| self.settlement_due(meta, &holders, now_millis())); + if let Some((id, at)) = due { + self.settle_session_at(&id, at / 1000, cx); + } + } + + fn settlement_due( + &self, + meta: &SessionMeta, + holders: &HashSet<&str>, + now: u64, + ) -> Option<(String, u64)> { + let activity = self.thread_activity.get(&meta.id)?; + let resident = self.resident(&meta.id); + let blockers = SettlementBlockers { + pending_input: resident.is_some_and(|session| { + !session.timeline.pending_approvals.is_empty() + || session.timeline.pending_user_input.is_some() + }), + live_run: resident.is_some_and(|session| { + session.preparing_worktree + || matches!(session.runtime, Runtime::Starting { .. }) + || session.turn_in_flight + || session.delivery_in_flight.is_some() + || session.timeline.turn_running + }), + completion_holding_work: holders.contains(meta.id.as_str()) + || resident.is_some_and(|session| session.background_task_count > 0), + pending_human_message: resident.is_some_and(|session| { + session + .queue + .iter() + .any(|message| message.origin == MessageOrigin::Human) + }), + scheduled_wake: resident.is_some_and(|session| { + session + .queue + .iter() + .any(|message| message.not_before.is_some()) + }), + }; + let days = meta + .project_id + .as_deref() + .and_then(|id| self.settings.project_settlement_overrides.get(id)) + .and_then(|settings| settings.auto_settle_after_days) + .unwrap_or(self.settings.auto_settle_after_days); + automatic_settlement_at(meta, activity, blockers, now, days).map(|at| (meta.id.clone(), at)) + } + + /// Threads whose completion waits on a child: an unsettled dispatched + /// child, or a running provider-native subagent, which also holds every + /// native mirror above it. + fn completion_holders(&self) -> HashSet<&str> { + let by_id: HashMap<_, _> = self + .sessions + .iter() + .map(|meta| (meta.id.as_str(), meta)) + .collect(); + let mut holders = HashSet::new(); + for child in self + .sessions + .iter() + .filter(|child| child.archived_at.is_none()) + { + let native = child.native_subagent.is_some(); + let holds = if native { + self.resident(&child.id) + .is_some_and(|session| session.timeline.turn_running) + } else { + !child.is_settled() + }; + if !holds { + continue; + } + let mut parent = child.parent_session_id.as_deref(); + while let Some(id) = parent { + if !holders.insert(id) { + break; + } + let owner = by_id.get(id); + if !native || owner.is_none_or(|meta| meta.native_subagent.is_none()) { + break; + } + parent = owner.and_then(|meta| meta.parent_session_id.as_deref()); + } + } + holders + } +} + +/// The end of a log from its latest record that moves an activity clock: +/// read backwards a page at a time until a page holds one. The clocks need +/// no earlier record. +fn read_activity_tail(store: &SessionStore, id: &str) -> std::io::Result> { + let mut low = store.next_row(id)?; + let mut records = Vec::new(); + while low > 0 { + let from = low.saturating_sub(history::TAIL_ROWS); + let page = store.read_rows(id, from..low)?.records; + let found = page + .iter() + .any(|record| ThreadActivity::moves_clock(&record.event)); + records.splice(0..0, page); + low = from; + if found { + break; + } + } + Ok(records) +} diff --git a/crates/runtime/src/app/snapshots.rs b/crates/runtime/src/app/snapshots.rs index c0f8a59ad..a0dd2602b 100644 --- a/crates/runtime/src/app/snapshots.rs +++ b/crates/runtime/src/app/snapshots.rs @@ -642,12 +642,26 @@ impl AppState { self.open_requests(&meta.id, session) }); SessionActivity { - working: resident.is_some_and(ActiveSession::has_work), + working: resident.is_some_and(|session| { + session.preparing_worktree + || matches!(session.runtime, Runtime::Starting { .. }) + || session.turn_in_flight + || session.delivery_in_flight.is_some() + || (meta.native_subagent.is_some() && session.timeline.turn_running) + || session + .queue + .iter() + .any(|message| message.origin == MessageOrigin::Human) + }), turn_running: resident.is_some_and(|session| session.turn_in_flight), waiting: resident.is_some_and(|session| session.background_task_count > 0) || self.children_unfinished(&meta.id), waiting_for_approval: !approvals.is_empty(), waiting_for_input: input.is_some(), + failed: self + .thread_activity + .get(&meta.id) + .is_some_and(|activity| activity.failed), unread: self.session_unread(meta), fork: Self::session_fork_availability(meta, resident), } diff --git a/crates/runtime/src/app/store_write.rs b/crates/runtime/src/app/store_write.rs index 125eb9b2c..debb557ae 100644 --- a/crates/runtime/src/app/store_write.rs +++ b/crates/runtime/src/app/store_write.rs @@ -15,6 +15,7 @@ pub(super) enum StoreWrite { id: String, ts: u64, author: Option, + origin: Option, event: Box, joined: Joined, }, @@ -111,11 +112,13 @@ impl StoreWrite { id, ts, author, + origin, event, joined, } => { let mut mutations = - match Mutation::append_authored_event(id, *ts, event, author.as_ref()) { + match Mutation::append_authored_event(id, *ts, event, author.as_ref(), *origin) + { Ok(mutation) => vec![mutation], Err(error) => return Some(Err(error.to_string())), }; diff --git a/crates/runtime/src/app/subagents.rs b/crates/runtime/src/app/subagents.rs index 054b7b41e..01837dd76 100644 --- a/crates/runtime/src/app/subagents.rs +++ b/crates/runtime/src/app/subagents.rs @@ -220,6 +220,16 @@ impl AppState { }, cx, ); + // The mirror no longer holds the thread whose provider runs it. + let mut owner = self.find_meta(mirror_id); + while let Some(meta) = owner.take_if(|meta| meta.native_subagent.is_some()) { + owner = meta + .parent_session_id + .and_then(|parent| self.find_meta(&parent)); + } + if let Some(owner) = owner { + self.evaluate_thread_settlement(&owner.id, cx); + } } } @@ -318,12 +328,30 @@ impl AppState { ) { self.nested_subagent_spawns .retain(|(session_id, _), _| session_id != parent_session_id); - let descendants = descendant_session_ids(&self.sessions, parent_session_id); + // Mirrors belong to this provider. An execution child has its own + // provider and remains independent when this one is intentionally detached. + let mut native_children: HashMap<&str, Vec<&str>> = HashMap::new(); + for meta in &self.sessions { + if meta.native_subagent.is_some() + && let Some(parent) = meta.parent_session_id.as_deref() + { + native_children.entry(parent).or_default().push(&meta.id); + } + } + let mut descendants = HashSet::new(); + let mut pending = vec![parent_session_id]; + while let Some(parent) = pending.pop() { + for child in native_children.get(parent).into_iter().flatten() { + if descendants.insert(*child) { + pending.push(child); + } + } + } let running: Vec<_> = self .sessions .iter() .filter(|meta| { - descendants.contains(&meta.id) + descendants.contains(meta.id.as_str()) && self.native_subagent_turns.get(&meta.id) == Some(&true) }) .filter_map(|meta| Some((meta.id.clone(), meta.native_subagent.clone()?))) diff --git a/crates/runtime/src/app/terminals.rs b/crates/runtime/src/app/terminals.rs index fede5f593..f25dfd79d 100644 --- a/crates/runtime/src/app/terminals.rs +++ b/crates/runtime/src/app/terminals.rs @@ -717,3 +717,60 @@ impl AppState { } } } + +impl AppState { + /// Close the thread's terminals that fresh process inspection proves are + /// at an idle shell, keeping their output. A terminal used since the + /// settle, one that cannot be inspected, and a thread re-engaged + /// meanwhile all keep their shells. + pub(super) fn close_settled_idle_terminals(&mut self, id: &str, cx: &mut HostCx) { + let terminals: Vec<_> = self + .resident(id) + .map(|session| &session.terminal_workspace) + .or_else(|| { + self.terminal_workspaces + .get(&ConversationDestination::Thread(id.to_owned())) + }) + .into_iter() + .flat_map(|workspace| &workspace.terminals) + .map(|entry| { + let terminal = entry.terminal.clone(); + (entry.id, terminal.activity_mark(), terminal) + }) + .collect(); + if terminals.is_empty() { + return; + } + let revision = self.decision_revision(id); + let id = id.to_owned(); + let host_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + let idle = host_cx + .unblock(move || { + terminals + .into_iter() + .filter(|(_, _, terminal)| terminal.idle_shell() == Some(true)) + .collect::>() + }) + .await; + host_cx.enqueue(move |state, _cx| { + if state.decision_revision(&id) != revision + || !state.find_meta(&id).is_some_and(|meta| meta.is_settled()) + { + return; + } + for (terminal_id, mark, terminal) in idle { + if terminal.activity_mark() == mark + && state + .terminal_handle(terminal_id) + .is_some_and(|current| Arc::ptr_eq(¤t, &terminal)) + { + // The tab and its emulator stay: an exited shell's + // output remains readable. + terminal.kill(); + } + } + }); + }); + } +} diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 864f0abbd..22780703d 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -836,7 +836,6 @@ fn reset_settings_clears_preferences_but_keeps_credentials_installs_and_unknown_ language: Some("zh-CN".into()), word_wrap_diffs: true, sidebar_provider_marks: true, - auto_archive_max_idle_days: 99, ..Settings::default() }; settings.browser.home_url = Some("https://example.com".into()); @@ -895,7 +894,6 @@ fn reset_settings_clears_preferences_but_keeps_credentials_installs_and_unknown_ assert_eq!(reset.language, None); assert!(!reset.word_wrap_diffs); assert!(!reset.sidebar_provider_marks); - assert_eq!(reset.auto_archive_max_idle_days, 7); assert_eq!(reset.browser.home_url, None); assert_eq!( @@ -1405,73 +1403,6 @@ fn deleting_a_thread_deletes_every_descendant_in_one_command() { reopened.close().unwrap(); } -#[test] -fn unarchived_thread_survives_the_next_auto_archive_sweep() { - let cx = &mut TestAppContext::default(); - let test_store = TestStore::new("tcode-unarchive-sweep-test"); - let root = test_store.root().clone(); - let store = (*test_store).clone(); - let idle_since = now_secs() - 30 * 86_400; - for (id, updated_at, archived_at) in [ - ("recent", now_secs(), None), - ("restored", idle_since, Some(idle_since + 1)), - ] { - let mut meta = SessionMeta::new(ProviderKind::Codex, root.clone(), None); - meta.id = id.into(); - meta.project_id = Some("project".into()); - meta.updated_at = updated_at; - meta.archived_at = archived_at; - store.upsert_meta(&meta).unwrap(); - } - let state = cx.new_entity(TestClientState::new(store.clone())); - state.update(cx, |state, _| { - state.settings.auto_archive_keep_count = 1; - state.settings.auto_archive_max_idle_days = 1; - state - .settings - .last_visited - .insert("restored".into(), idle_since); - }); - - state.dispatch_command( - cx, - 1, - Command::UnarchiveSession { - session_id: "restored".into(), - }, - ); - state.dispatch_command( - cx, - 2, - Command::AutoArchiveSweep { - project_id: "project".into(), - }, - ); - cx.run_until_parked(); - - assert!(cx.drain_outgoing().iter().any(|message| matches!( - message, - HostMessage::Ack { - id: 2, - result: Ok(CommandResponse::ArchivedCount(0)) - } - ))); - let restored = store - .load_index() - .unwrap() - .into_iter() - .find(|meta| meta.id == "restored") - .unwrap(); - assert_eq!(restored.archived_at, None); - assert!(restored.updated_at > idle_since); - state.read(|state| { - assert!( - !state.session_unread(&restored), - "a thread read before it was archived is still read" - ); - }); -} - #[test] fn title_session_uses_configured_model_with_low_effort() { let defaults = title_session_meta(&Settings::default(), PathBuf::from("/tmp/project")); @@ -3812,7 +3743,7 @@ fn reported_result_reaches_parent_and_fallback_covers_silent_children() { } #[test] -fn orchestrate_send_reactivates_the_child_and_its_settled_parent() { +fn orchestrate_send_revives_only_its_archived_child() { let cx = &mut TestAppContext::default(); let test_store = TestStore::new("tcode-orchestrate-send-unarchive-test"); let store = (*test_store).clone(); @@ -3846,7 +3777,7 @@ fn orchestrate_send_reactivates_the_child_and_its_settled_parent() { ); assert!(response.try_recv().unwrap().is_ok()); assert!(state.find_meta("child").unwrap().settled_at.is_none()); - assert!(state.find_meta("parent").unwrap().settled_at.is_none()); + assert_eq!(state.find_meta("parent").unwrap().settled_at, Some(1)); assert!( state.find_meta("child").unwrap().archived_at.is_none(), "send should revive an archived child" @@ -6858,9 +6789,12 @@ fn turn_running_for_is_independent_of_active_or_parked_location() { "{label}" ); let active_answer = state.turn_running_for(&id); + // Background work keeps the provider resident and shows Waiting; + // Working describes this thread's own turn or queued human input. + let expected_working = expected && label != "background"; assert_eq!( state.session_status_snapshot(&id).unwrap().activity.working, - expected, + expected_working, "{label}" ); @@ -6869,7 +6803,7 @@ fn turn_running_for_is_independent_of_active_or_parked_location() { let parked_answer = state.turn_running_for(&id); assert_eq!( state.session_status_snapshot(&id).unwrap().activity.working, - expected, + expected_working, "{label}" ); @@ -7873,6 +7807,7 @@ fn session_history_snapshot_pages_and_absolute_tail_cursors() { state.update(cx, |state, _| { let records: Vec = (0..2000) .map(|index| SessionEventRecord { + origin: None, author: None, ts: Some(index), event: AgentEvent::Warning { @@ -7968,6 +7903,7 @@ fn history_snapshot_and_pages_start_at_turn_boundaries() { for turn in 0..5u64 { turn_starts.push(records.len()); records.push(SessionEventRecord { + origin: None, author: None, ts: Some(turn * 1000), event: AgentEvent::ItemCompleted(ThreadItem { @@ -7982,6 +7918,7 @@ fn history_snapshot_and_pages_start_at_turn_boundaries() { elided: None, }); records.push(SessionEventRecord { + origin: None, author: None, ts: Some(turn * 1000 + 1), event: AgentEvent::TurnStarted { @@ -7991,6 +7928,7 @@ fn history_snapshot_and_pages_start_at_turn_boundaries() { }); for delta in 0..300u64 { records.push(SessionEventRecord { + origin: None, author: None, ts: Some(turn * 1000 + 2 + delta), event: AgentEvent::Delta { @@ -8002,6 +7940,7 @@ fn history_snapshot_and_pages_start_at_turn_boundaries() { }); } records.push(SessionEventRecord { + origin: None, author: None, ts: Some(turn * 1000 + 400), event: AgentEvent::TurnCompleted { @@ -8256,6 +8195,7 @@ fn history_byte_budget_preserves_contiguous_records_and_reports_shrinking() { state.update(cx, |state, _| { let records: Vec = (0..10) .map(|index| SessionEventRecord { + origin: None, author: None, ts: Some(index), event: AgentEvent::Warning { @@ -8405,209 +8345,6 @@ fn computer_use_registrations_survive_stop_but_are_replaced_after_provider_shutd }); } -#[test] -fn settled_commands_persist_without_closing_the_selected_conversation() { - let cx = &mut TestAppContext::default(); - let test_store = TestStore::new("tcode-settled-lifecycle"); - let store = (*test_store).clone(); - for (id, parent) in [ - ("parent", None), - ("child", Some("parent")), - ("sibling", Some("parent")), - ] { - let mut meta = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); - meta.id = id.into(); - meta.parent_session_id = parent.map(str::to_string); - store.upsert_meta(&meta).unwrap(); - } - let state = cx.new_entity(TestClientState::new(store.clone())); - state.update(cx, |state, cx| state.select_session("parent", cx)); - cx.run_until_parked(); - state.dispatch_command( - cx, - 1, - Command::SettleSession { - session_id: "parent".into(), - }, - ); - cx.run_until_parked(); - state.read(|state| { - assert_eq!(state.active_session_id(), Some("parent")); - assert!(state.resident("parent").is_some()); - assert!( - state - .sessions - .iter() - .all(|meta| meta.settled_at.is_some() && meta.archived_at.is_none()) - ); - }); - let restarted = AppState::new(store.clone()).unwrap(); - assert!( - restarted - .sessions - .iter() - .all(|meta| meta.settled_at.is_some()) - ); - state.update(cx, |state, cx| state.select_session("child", cx)); - cx.run_until_parked(); - state.read(|state| assert!(state.find_meta("child").unwrap().settled_at.is_some())); - state.dispatch_command( - cx, - 2, - Command::ArchiveSession { - session_id: "parent".into(), - }, - ); - state.dispatch_command( - cx, - 3, - Command::UnarchiveSession { - session_id: "parent".into(), - }, - ); - cx.run_until_parked(); - state.read(|state| { - assert!( - state - .sessions - .iter() - .all(|meta| meta.settled_at.is_some() && meta.archived_at.is_none()) - ) - }); - state.dispatch_command( - cx, - 4, - Command::MakeSessionActive { - session_id: "parent".into(), - }, - ); - cx.run_until_parked(); - assert!( - store - .load_index() - .unwrap() - .iter() - .all(|meta| meta.settled_at.is_none()) - ); -} - -#[test] -fn settling_rejects_busy_descendants_and_accepted_input_reactivates_ancestors() { - let cx = &mut TestAppContext::default(); - let test_store = TestStore::new("tcode-settled-input"); - let state = cx.new_entity(TestClientState::new((*test_store).clone())); - let (commands, _receiver) = smol::channel::unbounded(); - state.update(cx, |state, _| { - let mut child = live_session(ProviderKind::Codex, commands); - child.meta.id = "child".into(); - child.meta.parent_session_id = Some("parent".into()); - child.meta.settled_at = Some(1); - let mut parent = SessionMeta::new(ProviderKind::Codex, test_store.root().clone(), None); - parent.id = "parent".into(); - parent.settled_at = Some(1); - let mut sibling = parent.clone(); - sibling.id = "sibling".into(); - sibling.parent_session_id = Some("parent".into()); - state.sessions.extend([parent, sibling, child.meta.clone()]); - state.install_selected(child); - }); - state.dispatch_command( - cx, - 1, - Command::ScheduleTurn { - session_id: "child".into(), - text: "later".into(), - attachment_paths: Vec::new(), - fire_at_unix_secs: now_secs() + 3600, - }, - ); - state.read(|state| { - assert!(state.find_meta("child").unwrap().settled_at.is_none()); - assert!(state.find_meta("parent").unwrap().settled_at.is_none()); - assert!(state.find_meta("sibling").unwrap().settled_at.is_some()); - }); - // Each state would hide reachable child work if the parent were allowed to settle. - for busy in ["queued", "turn", "background", "input", "approval"] { - state.update(cx, |state, _| { - let child = state.resident_mut("child").unwrap(); - child.queue.clear(); - child.turn_in_flight = busy == "turn"; - child.background_task_count = usize::from(busy == "background"); - child.timeline.pending_user_input = - (busy == "input").then(|| tcode_core::session::PendingUserInput { - request_id: "input".into(), - questions: Vec::new(), - delivery: agent::UserInputDelivery::Blocking, - }); - child.timeline.pending_approvals.clear(); - if busy == "queued" { - child.push_queued("queued".into(), Vec::new()); - } - if busy == "approval" { - child - .timeline - .pending_approvals - .push(agent::ApprovalRequest { - id: "approval".into(), - turn_id: None, - kind: agent::ApprovalKind::ExecCommand { - command: "pwd".into(), - cwd: None, - reason: None, - }, - options: Vec::new(), - }); - } - assert_eq!( - state - .validate_command_target(&Command::SettleSession { - session_id: "parent".into() - }) - .unwrap_err() - .code, - "thread_busy" - ); - }); - state.dispatch_command( - cx, - 2, - Command::SettleSession { - session_id: "parent".into(), - }, - ); - state.read(|state| assert!(state.find_meta("parent").unwrap().settled_at.is_none())); - } - state.update(cx, |state, _| { - let child = state.resident_mut("child").unwrap(); - child.timeline.pending_approvals.clear(); - child.timeline.pending_user_input = None; - child.turn_in_flight = false; - child.background_task_count = 0; - }); - state.dispatch_command( - cx, - 3, - Command::SettleSession { - session_id: "parent".into(), - }, - ); - state.dispatch_command( - cx, - 4, - Command::SendTurn { - session_id: "child".into(), - text: "continue".into(), - attachment_paths: Vec::new(), - }, - ); - state.read(|state| { - assert!(state.find_meta("parent").unwrap().settled_at.is_none()); - assert!(state.find_meta("child").unwrap().settled_at.is_none()); - assert!(state.find_meta("sibling").unwrap().settled_at.is_some()); - }); - cx.run_until_parked(); -} - #[test] fn interrupt_reports_stopping_until_the_turn_completes() { let store = TestStore::new("interrupt-stopping"); @@ -8927,6 +8664,7 @@ fn index_and_visit_changes_cross_the_wire_one_thread_at_a_time() { fn tool_call(id: &str, output: String) -> SessionEventRecord { SessionEventRecord { + origin: None, author: None, ts: Some(1), event: AgentEvent::ItemCompleted(ThreadItem { @@ -8970,6 +8708,7 @@ fn history_sends_output_previews_and_reads_whole_outputs_on_request() { let records = vec![ tool_call("tool", tool_output.clone()), SessionEventRecord { + origin: None, author: None, ts: Some(2), event: AgentEvent::ItemCompleted(ThreadItem { @@ -9105,6 +8844,7 @@ fn superseded_turn_changes_cross_without_diffs() { .unwrap() }; let update = |diff: &str| SessionEventRecord { + origin: None, author: None, ts: Some(1), event: AgentEvent::TurnChangesUpdated { @@ -9116,6 +8856,7 @@ fn superseded_turn_changes_cross_without_diffs() { }; let records = vec![ SessionEventRecord { + origin: None, author: None, ts: Some(0), event: AgentEvent::TurnStarted { @@ -9198,6 +8939,7 @@ fn an_appended_snapshot_drops_the_superseded_ones_diffs_in_the_same_commit() { for (ts, diff) in [(3, "-a\n+c\n"), (4, "-a\n+d\n")] { state.record_event_for_replica_test("thread", ts, &snapshot(diff), cx); conversation.push(SessionEventRecord { + origin: None, author: None, ts: Some(ts), event: snapshot(diff), @@ -9310,6 +9052,7 @@ fn history_windows_are_byte_budgeted() { let state = cx.new_entity(TestClientState::new((*store).clone())); let records: Vec = (0..200) .map(|index| SessionEventRecord { + origin: None, author: None, ts: Some(index), event: AgentEvent::Warning { @@ -10424,6 +10167,887 @@ fn a_long_thread_opens_from_its_tail_with_the_whole_logs_window() { assert_eq!(window, whole); } +#[test] +fn message_origins_survive_queue_acknowledgement_and_callback_steering() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("message-origin"); + let state = cx.new_entity(TestClientState::new((*store).clone())); + let (commands, received) = smol::channel::unbounded(); + let mut session = live_session(ProviderKind::Codex, commands); + session.meta.id = "thread".into(); + state.update(cx, |state, _| state.install_selected(session)); + state.dispatch_command( + cx, + 1, + Command::SendTurn { + session_id: "thread".into(), + text: "human work".into(), + attachment_paths: vec![], + }, + ); + let SessionCommand::SendTurn { delivery_id, .. } = received.try_recv().unwrap() else { + panic!("send") + }; + state.update(cx, |state, cx| { + state.on_event("thread", AgentEvent::TurnAccepted { delivery_id }, cx); + state.on_event( + "thread", + AgentEvent::TurnStarted { + turn_id: "run".into(), + }, + cx, + ); + state.deliver_orchestrate_callback_to_parent("thread", "agent completion".into(), cx); + // A message the provider reports on its own, such as an answer it + // steers into the running turn. + state.on_event( + "thread", + AgentEvent::SteerRequested { + request_id: "provider-reply".into(), + text: "A".into(), + attachments: vec![], + }, + cx, + ); + }); + assert!(matches!( + received.try_recv().unwrap(), + SessionCommand::Steer { .. } + )); + cx.run_until_parked(); + let records = store.read_events("thread").unwrap(); + let messages: Vec<_> = records + .iter() + .filter(|record| { + matches!( + record.event, + AgentEvent::ItemCompleted(ThreadItem { + content: ItemContent::UserMessage { .. }, + .. + }) | AgentEvent::SteerRequested { .. } + ) + }) + .collect(); + assert_eq!( + messages + .iter() + .map(|record| record.origin) + .collect::>(), + [Some(MessageOrigin::Human), Some(MessageOrigin::Agent), None] + ); + let fold = |record: &SessionEventRecord| { + tcode_core::settlement::ThreadActivity::fold_stored([record], false) + }; + assert_eq!(fold(messages[1]).last_human_message_at, None); + assert_eq!( + fold(messages[2]).last_human_message_at, + messages[2].ts, + "a top-level thread's own provider message is the human's" + ); +} + +fn next_host_event(link: &tcode_client::HostLink) -> EventEnvelope { + smol::block_on(smol::future::race( + async { link.events().recv().await.unwrap() }, + async { + smol::Timer::after(Duration::from_secs(5)).await; + panic!("host did not publish the expected lifecycle change") + }, + )) +} + +#[test] +fn cold_lifecycle_migrates_legacy_settlement_and_ages_from_events_after_rename() { + use crate::pipe::{HostServices, spawn_host}; + let store = TestStore::new("cold-settlement"); + let stamp = now_millis() - 10 * 86_400_000; + for id in ["inherit", "never"] { + let mut project = Project::from_root(store.root().join(id)); + project.id = id.into(); + store.upsert_project(&project).unwrap(); + } + for (id, settled) in [ + ("legacy", Some(7)), + ("aged", None), + ("untouched", None), + ("never", None), + ] { + let meta: SessionMeta = serde_json::from_value(serde_json::json!({ + "id": id, "title": id, "provider": "codex", "cwd": store.root(), + "created_at": stamp / 1000, "updated_at": stamp / 1000, "settled_at": settled, + "project_id": if id == "never" { "never" } else { "inherit" } + })) + .unwrap(); + store.upsert_meta(&meta).unwrap(); + } + for id in ["aged", "never"] { + store + .append_event( + id, + stamp, + &AgentEvent::ItemCompleted(ThreadItem { + id: "old-human".into(), + parent_item_id: None, + content: ItemContent::UserMessage { + text: "An old request".into(), + context_len: None, + attachments: vec![], + }, + }), + ) + .unwrap(); + } + // Literal persisted settings distinguish an omitted project value from + // an explicit null. + std::fs::write( + store.root().join("settings.json"), + r#"{ + "auto_settle_after_days":null, + "project_settlement_overrides":{"inherit":{},"never":{"auto_settle_after_days":null}} + }"#, + ) + .unwrap(); + let host = spawn_host((*store).clone(), HostServices::default()).unwrap(); + let link = host.link(); + link.subscribe(Subscription { + topic: Topic::Index, + after: None, + }) + .unwrap(); + link.command_blocking(Command::RenameSession { + session_id: "aged".into(), + title: "Recently renamed".into(), + }) + .unwrap(); + for patch in [ + SettingsPatch::AutoSettleAfterDays(Some(0.5)), + SettingsPatch::ProjectSettlement { + project_id: "never".into(), + value: Some(tcode_core::settings::ProjectSettlementSettings { + auto_settle_after_days: Some(Some(91.0)), + ..Default::default() + }), + }, + ] { + assert_eq!( + link.command_blocking(Command::PatchSettings { patch }) + .unwrap_err() + .code, + "invalid_settings" + ); + } + link.command_blocking(Command::PatchSettings { + patch: SettingsPatch::AutoSettleAfterDays(Some(3.0)), + }) + .unwrap(); + loop { + if let ServerEvent::IndexUpsertSession(meta) = next_host_event(&link).event + && meta.id == "aged" + && meta.is_settled() + { + assert_eq!(meta.settled_at, Some(stamp / 1000)); + assert!(meta.updated_at > stamp / 1000); + break; + } + } + link.shutdown_blocking().unwrap(); + host.to_host.close(); + host.stopped.recv_blocking().unwrap(); + let restarted = spawn_host( + SessionStore::open_at(store.root().clone()).unwrap(), + HostServices::default(), + ) + .unwrap(); + let link = restarted.link(); + link.subscribe(Subscription { + topic: Topic::Index, + after: None, + }) + .unwrap(); + loop { + if let ServerEvent::IndexSnapshot(snapshot) = next_host_event(&link).event { + let find = |id: &str| snapshot.sessions.iter().find(|meta| meta.id == id).unwrap(); + assert_eq!( + find("legacy").settled_override, + Some(tcode_core::project::SettledOverride::Settled) + ); + assert_eq!(find("legacy").settled_at, Some(7)); + assert!(find("aged").is_settled()); + assert!( + !find("never").is_settled(), + "explicit project null survives settings save and restart" + ); + assert!( + !find("untouched").is_settled(), + "no event activity never ages" + ); + break; + } + } + let inherited: Command = serde_json::from_str(r#"{"type":"patch_settings","content":{"patch":{"type":"project_settlement","content":{"project_id":"never","value":{}}}}}"#).unwrap(); + link.command_blocking(inherited).unwrap(); + loop { + if let ServerEvent::IndexUpsertSession(meta) = next_host_event(&link).event + && meta.id == "never" + && meta.is_settled() + { + assert_eq!(meta.settled_at, Some(stamp / 1000)); + break; + } + } + link.shutdown_blocking().unwrap(); + restarted.to_host.close(); + restarted.stopped.recv_blocking().unwrap(); +} + +#[test] +fn manual_settlement_is_thread_local_cancels_automatic_queue_and_blocks_human_admission() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("manual-settlement"); + let state = cx.new_entity(TestClientState::new((*store).clone())); + let (commands, received) = smol::channel::unbounded(); + let original_commands = commands.clone(); + state.update(cx, |state, _| { + let mut parent = live_session(ProviderKind::Codex, commands); + parent.meta.id = "parent".into(); + parent.meta.unsettled_at = Some(1); + parent.push_or_merge_orchestrate_callback("queued completion".into()); + let wake = parent.push_queued("automatic wake".into(), vec![]); + parent + .queue + .iter_mut() + .find(|message| message.id == wake) + .unwrap() + .origin = MessageOrigin::Server; + let mut child = ActiveSession::new( + SessionMeta::new(ProviderKind::Codex, store.root().clone(), None), + false, + vec![], + ); + child.meta.id = "child".into(); + child.meta.parent_session_id = Some("parent".into()); + child.turn_in_flight = true; + state + .sessions + .extend([parent.meta.clone(), child.meta.clone()]); + state.residents.parked.insert("child".into(), child); + state.install_selected(parent); + }); + through_pipe( + &state, + cx, + 1, + tcode_protocol::ClientPayload::Command(Command::ScheduleTurn { + session_id: "parent".into(), + text: "human later".into(), + attachment_paths: vec![], + fire_at_unix_secs: now_secs() + 3600, + }), + ); + let denied = through_pipe( + &state, + cx, + 2, + tcode_protocol::ClientPayload::Command(Command::SettleSession { + session_id: "parent".into(), + }), + ); + assert!(denied.iter().any(|message| matches!( + message, + HostMessage::Ack { id: 2, result: Err(error) } if error.code == "thread_busy" + ))); + let human = state.read(|state| { + state + .resident("parent") + .unwrap() + .queue + .iter() + .find(|message| message.origin == MessageOrigin::Human) + .unwrap() + .id + }); + through_pipe( + &state, + cx, + 3, + tcode_protocol::ClientPayload::Command(Command::DropQueued { + session_id: "parent".into(), + id: human, + }), + ); + for (request, resolved) in [ + ( + AgentEvent::ApprovalRequested(agent::ApprovalRequest { + id: "approval".into(), + turn_id: None, + kind: agent::ApprovalKind::ToolUse { + name: "shell".into(), + input: serde_json::json!({}), + detail: "Needs consent".into(), + }, + options: vec![], + }), + AgentEvent::ApprovalResolved { + request_id: "approval".into(), + decision: agent::ApprovalDecision::Option("reject".into()), + }, + ), + ( + AgentEvent::UserInputRequested { + request_id: "blocking".into(), + questions: vec![], + delivery: agent::UserInputDelivery::Blocking, + }, + AgentEvent::UserInputResolved { + request_id: "blocking".into(), + answers: Default::default(), + }, + ), + ] { + state.update(cx, |state, cx| state.on_event("parent", request, cx)); + let denied = through_pipe( + &state, + cx, + 20, + tcode_protocol::ClientPayload::Command(Command::SettleSession { + session_id: "parent".into(), + }), + ); + assert!(denied.iter().any(|message| matches!( + message, + HostMessage::Ack { id: 20, result: Err(error) } if error.code == "thread_busy" + ))); + state.update(cx, |state, cx| state.on_event("parent", resolved, cx)); + } + state.update(cx, |state, cx| { + state.on_event( + "parent", + AgentEvent::UserInputRequested { + request_id: "asynchronous".into(), + questions: vec![], + delivery: agent::UserInputDelivery::Async, + }, + cx, + ) + }); + let accepted = through_pipe( + &state, + cx, + 4, + tcode_protocol::ClientPayload::Command(Command::SettleSession { + session_id: "parent".into(), + }), + ); + assert!(accepted.iter().any(|message| matches!( + message, + HostMessage::Ack { + id: 4, + result: Ok(_) + } + ))); + state.read(|state| { + let parent = state.resident("parent").unwrap(); + assert!(parent.meta.is_settled()); + assert!(parent.meta.unsettled_at.is_none()); + assert!(parent.queue.is_empty()); + assert_eq!(state.active_session_id(), Some("parent")); + assert!(state.resident("child").unwrap().turn_in_flight); + assert!(!state.find_meta("child").unwrap().is_settled()); + }); + assert!(matches!( + received.try_recv().unwrap(), + SessionCommand::Shutdown + )); + through_pipe( + &state, + cx, + 21, + tcode_protocol::ClientPayload::Subscribe(Subscription { + topic: Topic::SessionEvents { + session_id: "parent".into(), + }, + after: None, + }), + ); + state.read(|state| { + assert!( + state.find_meta("parent").unwrap().is_settled(), + "reading leaves settlement intact" + ) + }); + for command in [ + Command::ArchiveSession { + session_id: "parent".into(), + }, + Command::UnarchiveSession { + session_id: "parent".into(), + }, + ] { + through_pipe( + &state, + cx, + 22, + tcode_protocol::ClientPayload::Command(command), + ); + state.read(|state| assert!(state.find_meta("parent").unwrap().is_settled())); + } + through_pipe( + &state, + cx, + 24, + tcode_protocol::ClientPayload::Subscribe(Subscription { + topic: Topic::SessionStatus { + session_id: "parent".into(), + }, + after: None, + }), + ); + cx.run_until(|state| state.resident("parent").is_some()); + state.read(|state| { + assert!( + state.find_meta("parent").unwrap().is_settled(), + "reopening status preserves settlement" + ) + }); + through_pipe( + &state, + cx, + 23, + tcode_protocol::ClientPayload::Command(Command::UnsettleSession { + session_id: "parent".into(), + }), + ); + state.read(|state| { + assert_eq!( + state.find_meta("parent").unwrap().settled_override, + Some(tcode_core::project::SettledOverride::Active) + ) + }); + let fresh = smol::channel::unbounded(); + state.update(cx, |state, _| { + state.resident_mut("parent").unwrap().runtime = Runtime::Live(fresh.0) + }); + through_pipe( + &state, + cx, + 5, + tcode_protocol::ClientPayload::Command(Command::SendTurn { + session_id: "parent".into(), + text: "new work".into(), + attachment_paths: vec![], + }), + ); + assert!( + matches!(fresh.1.try_recv().unwrap(), SessionCommand::SendTurn { text, .. } if text == "new work") + ); + state.update(cx, |state, cx| { + state.on_event_stream_ended("parent", &original_commands, cx) + }); + state.read(|state| { + let meta = state.find_meta("parent").unwrap(); + assert!(!meta.is_settled()); + assert_eq!( + meta.settled_override, None, + "an accepted message clears explicit Active" + ); + assert!(meta.unsettled_at.is_some()); + assert!( + state + .resident("parent") + .unwrap() + .delivery_in_flight + .is_some() + ); + }); + through_pipe( + &state, + cx, + 6, + tcode_protocol::ClientPayload::Command(Command::ShutdownAllAndFlush), + ); + let restarted = AppState::new((*store).clone()).unwrap(); + assert!(!restarted.find_meta("parent").unwrap().is_settled()); +} + +#[cfg(target_os = "macos")] +#[test] +fn settling_closes_only_idle_real_shells_and_spares_new_terminal_input() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("settlement-real-pty"); + for id in ["idle", "busy", "input"] { + let mut meta = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); + meta.id = id.into(); + store.upsert_meta(&meta).unwrap(); + } + let state = cx.new_entity(TestClientState::new((*store).clone())); + let mut request = 0; + let mut terminals = HashMap::new(); + for id in ["idle", "busy", "input"] { + request += 1; + through_pipe( + &state, + cx, + request, + tcode_protocol::ClientPayload::Subscribe(Subscription { + topic: Topic::SessionStatus { + session_id: id.into(), + }, + after: None, + }), + ); + request += 1; + through_pipe( + &state, + cx, + request, + tcode_protocol::ClientPayload::Command(Command::NewTerminal { + session_id: id.into(), + }), + ); + cx.run_until(|state| { + state + .resident(id) + .is_some_and(|session| session.terminal_workspace.active_id.is_some()) + }); + let (terminal_id, terminal) = state.read(|state| { + let workspace = &state.resident(id).unwrap().terminal_workspace; + ( + workspace.active_id.unwrap(), + workspace.active().unwrap().terminal.clone(), + ) + }); + cx.run_until(|_| terminal.idle_shell() == Some(true)); + let (command, marker, idle) = match id { + "busy" => ( + "printf '__BUSY_CHILD__\\n'; sleep 60\r", + "__BUSY_CHILD__", + false, + ), + _ => ( + "printf '__IDLE_OUTPUT__\\n'; true\r", + "__IDLE_OUTPUT__", + true, + ), + }; + request += 1; + through_pipe( + &state, + cx, + request, + tcode_protocol::ClientPayload::Command(Command::TerminalInput { + terminal_id, + bytes: command.as_bytes().to_vec(), + }), + ); + cx.run_until(|_| { + terminal + .peek_snapshot() + .text() + .lines() + .any(|line| line.trim() == marker) + && terminal.idle_shell() == Some(idle) + }); + terminals.insert(id, (terminal_id, terminal)); + } + for id in ["idle", "busy"] { + request += 1; + through_pipe( + &state, + cx, + request, + tcode_protocol::ClientPayload::Command(Command::SettleSession { + session_id: id.into(), + }), + ); + } + cx.run_until(|_| terminals["idle"].1.exited()); + assert!( + terminals["idle"] + .1 + .peek_snapshot() + .text() + .contains("__IDLE_OUTPUT__") + ); + assert!(!terminals["busy"].1.exited()); + state.read(|state| { + assert!( + state.terminal_handle(terminals["idle"].0).is_some(), + "closed shell output remains in its tab" + ) + }); + // Input typed after the settle and before its inspection finishes keeps + // the shell. + request += 1; + state.dispatch_command( + cx, + request, + Command::SettleSession { + session_id: "input".into(), + }, + ); + request += 1; + state.dispatch_command( + cx, + request, + Command::TerminalInput { + terminal_id: terminals["input"].0, + bytes: b"printf '__NEW_INPUT_SURVIVED__\\n'\r".to_vec(), + }, + ); + cx.run_until(|_| { + terminals["input"] + .1 + .peek_snapshot() + .text() + .lines() + .any(|line| line.trim() == "__NEW_INPUT_SURVIVED__") + }); + cx.run_until_parked(); + assert!(!terminals["input"].1.exited()); + for (_, terminal) in terminals.values() { + terminal.kill(); + } + cx.run_until(|_| terminals.values().all(|(_, terminal)| terminal.exited())); +} + +#[test] +fn child_completion_admission_reopens_only_its_parent_and_rejects_stale_targets() { + for case in [ + "valid", + "renamed", + "new_work", + "archived_child", + "archived_parent", + "deleted_parent", + ] { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("completion-admission"); + let state = cx.new_entity(TestClientState::new((*store).clone())); + let (parent_commands, parent_received) = smol::channel::unbounded(); + let (child_commands, _child_received) = smol::channel::unbounded(); + state.update(cx, |state, cx| { + let mut ancestor = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); + ancestor.id = "ancestor".into(); + ancestor.settled_at = Some(1); + let mut parent = live_session(ProviderKind::Codex, parent_commands); + parent.meta.id = "parent".into(); + parent.meta.parent_session_id = Some("ancestor".into()); + parent.meta.settled_at = Some(1); + let mut child = live_session(ProviderKind::Codex, child_commands); + child.meta.id = "child".into(); + child.meta.parent_session_id = Some("parent".into()); + child.meta.archive_on_complete = false; + state + .sessions + .extend([ancestor, parent.meta.clone(), child.meta.clone()]); + state.residents.parked.insert("parent".into(), parent); + state.install_selected(child); + state.on_event( + "child", + AgentEvent::TurnStarted { + turn_id: "finished-run".into(), + }, + cx, + ); + state.on_event( + "child", + persisted_assistant_event("completion evidence"), + cx, + ); + state.on_event( + "child", + AgentEvent::TurnCompleted { + turn_id: "finished-run".into(), + status: TurnStatus::Completed, + usage: None, + }, + cx, + ); + }); + // Each command lands while the child's log is read for its completion. + let command = match case { + "renamed" => Some(Command::RenameSession { + session_id: "child".into(), + title: "Renamed".into(), + }), + "new_work" => Some(Command::SendTurn { + session_id: "child".into(), + text: "new human work".into(), + attachment_paths: vec![], + }), + "archived_child" => Some(Command::ArchiveSession { + session_id: "child".into(), + }), + "archived_parent" => Some(Command::ArchiveSession { + session_id: "parent".into(), + }), + "deleted_parent" => Some(Command::DeleteSession { + session_id: "parent".into(), + remove_worktree: false, + }), + _ => None, + }; + if let Some(command) = command { + state.dispatch_command(cx, 1, command); + } + if case == "valid" || case == "renamed" { + cx.run_until(|_| !parent_received.is_empty()); + assert!( + matches!(parent_received.try_recv().unwrap(), SessionCommand::SendTurn { text, .. } if text.contains("completion evidence")) + ); + state.read(|state| assert!(!state.find_meta("parent").unwrap().is_settled())); + } else { + cx.run_until_parked(); + state.read(|state| { + if let Some(parent) = state.find_meta("parent") { + assert!(parent.is_settled(), "{case}"); + } + }); + assert!( + !std::iter::from_fn(|| parent_received.try_recv().ok()).any(|command| matches!( + command, + SessionCommand::SendTurn { .. } | SessionCommand::Steer { .. } + )), + "{case}" + ); + } + state.read(|state| assert!(state.find_meta("ancestor").unwrap().is_settled(), "{case}")); + } +} + +#[test] +fn settling_detaches_only_its_own_provider_and_releases_its_parent() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("settlement-own-provider"); + let stamp = now_millis() - 10 * 86_400_000; + let mut lead = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); + lead.id = "lead".into(); + lead.created_at = stamp / 1000; + store.upsert_meta(&lead).unwrap(); + store + .append_event( + "lead", + 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())); + let (worker_commands, worker_received) = smol::channel::unbounded(); + state.update(cx, |state, cx| { + state.settings.auto_settle_after_days = None; + let mut worker = live_session(ProviderKind::Codex, worker_commands); + worker.meta.id = "worker".into(); + worker.meta.parent_session_id = Some("lead".into()); + let mut independent = ActiveSession::new( + SessionMeta::new(ProviderKind::Codex, store.root().clone(), None), + false, + vec![], + ); + independent.meta.id = "independent".into(); + independent.meta.parent_session_id = Some("worker".into()); + independent.turn_in_flight = true; + state.persist_meta(&worker.meta, cx); + state.persist_meta(&independent.meta, cx); + state + .residents + .parked + .insert("independent".into(), independent); + state.install_selected(worker); + for owner in ["worker", "independent"] { + state.on_event( + owner, + AgentEvent::ItemStarted(ThreadItem { + id: format!("spawn-{owner}"), + parent_item_id: None, + content: ItemContent::Subagent { + agent_type: "explorer".into(), + description: "Inspect sample".into(), + status: ItemStatus::InProgress, + summary: None, + model: None, + effort: None, + }, + }), + cx, + ); + } + }); + // An unsettled dispatched child holds its lead out of settlement. + state.dispatch_command( + cx, + 1, + Command::PatchSettings { + patch: SettingsPatch::AutoSettleAfterDays(Some(3.0)), + }, + ); + cx.run_until(|state| state.thread_activity.contains_key("lead")); + state.read(|state| assert!(!state.find_meta("lead").unwrap().is_settled())); + let accepted = through_pipe( + &state, + cx, + 2, + tcode_protocol::ClientPayload::Command(Command::SettleSession { + session_id: "worker".into(), + }), + ); + assert!(accepted.iter().any(|message| matches!( + message, + HostMessage::Ack { + id: 2, + result: Ok(_) + } + ))); + assert!(matches!( + worker_received.try_recv().unwrap(), + SessionCommand::Shutdown + )); + state.read(|state| { + let mirror = |spawn| { + state + .sessions + .iter() + .find(|meta| meta.native_subagent.as_deref() == Some(spawn)) + .unwrap() + }; + assert_eq!( + state + .resident(&mirror("spawn-worker").id) + .unwrap() + .timeline + .last_turn_status, + Some(TurnStatus::Interrupted) + ); + assert!( + state + .resident(&mirror("spawn-independent").id) + .unwrap() + .timeline + .turn_running + ); + assert!(state.resident("independent").unwrap().turn_in_flight); + }); + state.dispatch_command( + cx, + 3, + Command::PatchSettings { + patch: SettingsPatch::AutoSettleAfterDays(Some(2.0)), + }, + ); + cx.run_until(|state| { + state + .find_meta("lead") + .is_some_and(|meta| meta.settled_at == Some(stamp / 1000)) + }); +} + #[test] fn github_secret_command_persists_separately_and_settings_never_replicate_it() { let cx = &mut TestAppContext::default(); diff --git a/crates/runtime/src/host.rs b/crates/runtime/src/host.rs index 17f7683d8..38e2120cd 100644 --- a/crates/runtime/src/host.rs +++ b/crates/runtime/src/host.rs @@ -43,6 +43,9 @@ pub struct HostCx { events: smol::channel::Sender, pub(crate) principal: Principal, pub(crate) author: Option, + /// Who a message admitted now comes from; `None` for what a provider + /// reports on its own. + pub(crate) origin: Option, pub(crate) delivery_key: Option, pub(crate) completed: std::sync::Arc>, } @@ -59,6 +62,7 @@ impl HostCx { delivery_key: None, principal: Principal::Full, author: None, + origin: None, } } diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index 8f0611bf6..ab81d6a81 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -160,6 +160,7 @@ pub fn spawn_host(store: SessionStore, mut services: HostServices) -> std::io::R state.start_diff_pass(&mut cx); } state.sync_terminal_handles(); + state.start_settlement_sweeps(&mut cx); let _ = ready_tx.send(Ok(())); let mut state = smol::block_on(host_loop(state, cx, client_rx, mailbox_rx)); if let Err(error) = state.close_store() { @@ -238,6 +239,7 @@ pub(crate) fn handle_client_message(state: &mut AppState, cx: &mut HostCx, messa principal, } = message; cx.principal = principal.unwrap_or(Principal::Full); + cx.origin = Some(tcode_core::session::MessageOrigin::Human); cx.author = match &cx.principal { Principal::Full => None, Principal::Space { @@ -267,6 +269,7 @@ pub(crate) fn handle_client_message(state: &mut AppState, cx: &mut HostCx, messa cx.send_message(reply); cx.principal = Principal::Full; cx.author = None; + cx.origin = None; return; } match payload { @@ -286,6 +289,7 @@ pub(crate) fn handle_client_message(state: &mut AppState, cx: &mut HostCx, messa cx.send_message(HostMessage::Ack { id, result }); cx.principal = Principal::Full; cx.author = None; + cx.origin = None; return; } } @@ -345,6 +349,7 @@ pub(crate) fn handle_client_message(state: &mut AppState, cx: &mut HostCx, messa } cx.principal = Principal::Full; cx.author = None; + cx.origin = None; } fn complete_command( @@ -638,12 +643,13 @@ fn dispatch_command(app: &mut AppState, cx: &mut HostCx, command: Command) -> Co } => app.set_thread_collapsed(&session_id, collapsed, cx), Command::PatchSettings { patch } => app.patch_settings(patch, cx), Command::SettleSession { session_id } => app.settle_session(&session_id, cx), - Command::MakeSessionActive { session_id } => app.make_session_active(&session_id, cx), + Command::SetAutoSettle { + session_id, + enabled, + } => app.set_auto_settle(&session_id, enabled, cx), + Command::UnsettleSession { session_id } => app.unsettle_session(&session_id, cx), Command::ArchiveSession { session_id } => app.archive_session(&session_id, cx), Command::UnarchiveSession { session_id } => app.unarchive_session(&session_id, cx), - Command::AutoArchiveSweep { project_id } => { - response = CommandResponse::ArchivedCount(app.auto_archive_sweep(&project_id, cx)); - } Command::RenameSession { session_id, title } => app.rename_session(&session_id, &title, cx), Command::RegenerateSessionTitle { session_id } => { app.regenerate_session_title(&session_id, cx) @@ -1757,6 +1763,7 @@ mod tests { "{:?}", Timeline::fold_events(legacy_events().into_iter().map(|(ts, event)| { StoredEvent { + origin: None, author: None, ts, event, diff --git a/crates/runtime/src/pipe_spaces_tests.rs b/crates/runtime/src/pipe_spaces_tests.rs index 1d5643ae3..9fdb5ad83 100644 --- a/crates/runtime/src/pipe_spaces_tests.rs +++ b/crates/runtime/src/pipe_spaces_tests.rs @@ -64,7 +64,10 @@ impl SpaceHost { ); } store.apply(&mutations).unwrap(); - let mut settings = Settings::default(); + let mut settings = Settings { + auto_settle_after_days: None, + ..Settings::default() + }; settings.profiles.insert( "shared-profile".into(), ProviderProfile { diff --git a/crates/services/src/store/mod.rs b/crates/services/src/store/mod.rs index d8462cb6b..cb18d7693 100644 --- a/crates/services/src/store/mod.rs +++ b/crates/services/src/store/mod.rs @@ -64,6 +64,8 @@ const RELAUNCH_WAIT: Duration = Duration::from_secs(15); /// callers deal in [`StoredEvent`] (which tolerates the legacy bare form). #[derive(Serialize, Deserialize)] struct EventEnvelope { + #[serde(default)] + origin: Option, #[serde(default)] author: Option, ts: u64, @@ -74,6 +76,8 @@ struct EventEnvelope { #[derive(Serialize)] struct EventEnvelopeRef<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + origin: Option, #[serde(skip_serializing_if = "Option::is_none")] author: Option<&'a Author>, ts: u64, @@ -83,7 +87,12 @@ struct EventEnvelopeRef<'a> { } impl<'a> EventEnvelopeRef<'a> { - fn new(ts: u64, event: &'a AgentEvent, author: Option<&'a Author>) -> Self { + fn new( + ts: u64, + event: &'a AgentEvent, + author: Option<&'a Author>, + origin: Option, + ) -> Self { let file_change = match event { AgentEvent::ItemStarted(item) | AgentEvent::ItemUpdated(item) @@ -96,6 +105,7 @@ impl<'a> EventEnvelopeRef<'a> { _ => false, }; Self { + origin, author, ts, event, @@ -270,7 +280,7 @@ impl Mutation { /// Append one event, wrapped in a timestamped envelope /// (`{"ts": , "event": {…}}`). pub fn append_event(session_id: &str, ts: u64, event: &AgentEvent) -> io::Result { - Self::append_authored_event(session_id, ts, event, None) + Self::append_authored_event(session_id, ts, event, None, None) } pub fn append_authored_event( @@ -278,9 +288,10 @@ impl Mutation { ts: u64, event: &AgentEvent, author: Option<&Author>, + origin: Option, ) -> io::Result { - let mut line = - serde_json::to_vec(&EventEnvelopeRef::new(ts, event, author)).map_err(invalid_data)?; + let mut line = serde_json::to_vec(&EventEnvelopeRef::new(ts, event, author, origin)) + .map_err(invalid_data)?; line.push(b'\n'); Ok(Self(Op::AppendEvent { session_id: session_id.to_owned(), @@ -1252,9 +1263,13 @@ fn read_index(db: &Db, connection: &turso::Connection) -> io::Result )?; Ok(values) } + let mut sessions: Vec = rows(db, connection, "sessions")?; + for meta in &mut sessions { + meta.migrate_lifecycle(); + } Ok(IndexFile { projects: rows(db, connection, "projects")?, - sessions: rows(db, connection, "sessions")?, + sessions, }) } @@ -1471,6 +1486,7 @@ pub(crate) fn parse_stored_line(line: &str) -> Result(line) { Ok(envelope) => ( StoredEvent { + origin: envelope.origin, author: envelope.author, ts: Some(envelope.ts), event: envelope.event, @@ -1480,6 +1496,7 @@ pub(crate) fn parse_stored_line(line: &str) -> Result ( StoredEvent { + origin: None, author: None, ts: None, event: serde_json::from_str::(line)?, diff --git a/crates/services/src/store/superseded.rs b/crates/services/src/store/superseded.rs index 74eb63764..c3748d599 100644 --- a/crates/services/src/store/superseded.rs +++ b/crates/services/src/store/superseded.rs @@ -231,6 +231,7 @@ fn without_diffs(line: &[u8]) -> io::Result, StoredEvent)>> { ts, &stored.event, stored.author.as_ref(), + stored.origin, )), None => serde_json::to_vec(&stored.event), } diff --git a/crates/term/src/lib.rs b/crates/term/src/lib.rs index 89f07c592..39d377f96 100644 --- a/crates/term/src/lib.rs +++ b/crates/term/src/lib.rs @@ -419,6 +419,18 @@ impl Terminal { self.emulator.history_size() } + /// A counter that grows with every input and output, for telling whether + /// the terminal was used between two reads. + pub fn activity_mark(&self) -> u64 { + self.pty.activity_mark() + } + + /// Inspect the process tree now; blocking. `Some(true)` only when the + /// shell itself is in the foreground and has no child process. + pub fn idle_shell(&self) -> Option { + self.pty.idle_shell() + } + /// Read host process exit state without consuming renderer damage. pub fn exited(&self) -> bool { self.pty.exited() diff --git a/crates/term/src/pty.rs b/crates/term/src/pty.rs index a3faf283a..4134ce423 100644 --- a/crates/term/src/pty.rs +++ b/crates/term/src/pty.rs @@ -72,6 +72,9 @@ struct Shared { exited: bool, command_line: String, command_label: Option, + /// Grows with every input write and every output read, so a command + /// started while the shell is inspected still changes it. + activity: u64, } /// Host-side handle for a child process running in a pseudoterminal. @@ -139,6 +142,7 @@ impl PtyHandle { exited: false, command_line: String::new(), command_label: None, + activity: 0, })); let refresh_running = Arc::new(AtomicBool::new(false)); let (notifications, events) = async_channel::unbounded(); @@ -218,6 +222,16 @@ impl PtyHandle { .unwrap_or_else(|| self.shell_name.clone()) } + pub fn activity_mark(&self) -> u64 { + self.shared.lock_recover().activity + } + + /// Fresh inspection: the shell itself is in the foreground with no child + /// process. `None` when the platform cannot tell. + pub fn idle_shell(&self) -> Option { + self.pty_info.idle_shell(&self.shell_name) + } + pub fn exited(&self) -> bool { self.shared.lock_recover().exited } @@ -225,6 +239,9 @@ impl PtyHandle { pub(crate) fn write_input_inner(&self, bytes: Vec) -> io::Result { let label_changed = { let mut shared = self.shared.lock_recover(); + if !bytes.is_empty() { + shared.activity += 1; + } let previous_label = shared.command_label.clone(); track_command_input(&mut shared, &bytes); shared.command_label != previous_label @@ -446,6 +463,7 @@ impl RawPtyEventLoop { Ok(0) => break, Ok(read) => { read_any = true; + self.shared.lock_recover().activity += 1; let _ = self .notifications .try_send(PtyEvent::Output(buffer[..read].to_vec())); diff --git a/crates/term/src/pty_info.rs b/crates/term/src/pty_info.rs index 281334c2e..e18059328 100644 --- a/crates/term/src/pty_info.rs +++ b/crates/term/src/pty_info.rs @@ -135,3 +135,66 @@ fn load_process(pid: u32) -> Option { fn load_process(_pid: u32) -> Option { None } + +impl PtyInfo { + /// Whether the shell is in the foreground with no child process, by + /// inspecting the process tree now. `None` when it cannot be told. + #[cfg(unix)] + pub fn idle_shell(&self, shell_name: &str) -> Option { + let foreground = unsafe { libc::tcgetpgrp(self.file.as_raw_fd()) }; + if foreground <= 0 || self.fallback_pid == 0 { + return None; + } + if foreground as u32 != self.fallback_pid { + return Some(false); + } + let process = load_process(self.fallback_pid)?; + if process.name != shell_name { + return Some(false); + } + has_children(self.fallback_pid).map(|children| !children) + } + + #[cfg(not(unix))] + pub fn idle_shell(&self, _shell_name: &str) -> Option { + None + } +} + +#[cfg(target_os = "linux")] +fn has_children(pid: u32) -> Option { + let path = format!("/proc/{pid}/task/{pid}/children"); + std::fs::read_to_string(path) + .ok() + .map(|children| !children.trim().is_empty()) +} + +#[cfg(target_os = "macos")] +fn has_children(pid: u32) -> Option { + // libproc's PROC_PPID_ONLY selector is 6. A one-pid buffer suffices: any child blocks closure. + let mut child: libc::pid_t = 0; + let bytes = unsafe { + *libc::__error() = 0; + libc::proc_listpids( + 6, + pid, + (&mut child as *mut libc::pid_t).cast(), + std::mem::size_of_val(&child) as libc::c_int, + ) + }; + if bytes < 0 + || (bytes == 0 + && std::io::Error::last_os_error() + .raw_os_error() + .is_some_and(|error| error != 0)) + { + None + } else { + Some(bytes > 0 && child > 0) + } +} + +#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))] +fn has_children(_pid: u32) -> Option { + None +} diff --git a/crates/ui/src/overlay/mod.rs b/crates/ui/src/overlay/mod.rs index 722e13dba..d018e439c 100644 --- a/crates/ui/src/overlay/mod.rs +++ b/crates/ui/src/overlay/mod.rs @@ -230,6 +230,7 @@ pub trait OverlayExt { fn push_notification(&mut self, note: impl Into, cx: &mut App); fn remove_notification(&mut self, cx: &mut App); fn remove_notification1(&mut self, key: impl Into, cx: &mut App); + fn has_notification(&mut self, cx: &mut App) -> bool; fn clear_notifications(&mut self, cx: &mut App); } @@ -304,6 +305,14 @@ impl OverlayExt for Window { }); } + fn has_notification(&mut self, cx: &mut App) -> bool { + Overlays::update(self, cx, |host, _, cx| { + host.notifications + .read(cx) + .contains_type(std::any::TypeId::of::()) + }) + } + fn clear_notifications(&mut self, cx: &mut App) { Overlays::update(self, cx, |host, window, cx| { host.notifications diff --git a/crates/ui/src/overlay/notification.rs b/crates/ui/src/overlay/notification.rs index 13c1648e8..3314d2836 100644 --- a/crates/ui/src/overlay/notification.rs +++ b/crates/ui/src/overlay/notification.rs @@ -167,7 +167,6 @@ impl Notification { builder: impl Fn(&mut Self, &mut Window, &mut Context) -> Button + 'static, ) -> Self { self.action = Some(Rc::new(builder)); - self.autohide = false; self } pub(super) fn requires_dialog(&self) -> bool { @@ -527,6 +526,17 @@ impl NotificationList { } } + /// Whether a notification of this type is shown and not already leaving. + pub fn contains_type(&self, kind: TypeId) -> bool { + self.manager.iter().any(|(id, _, status)| { + status != ToastTransitionStatus::Ending + && matches!( + id, + NotificationId::Type(value) | NotificationId::Key(value, _) if *value == kind + ) + }) + } + pub fn close(&mut self, id: impl Into, _: &mut Window, cx: &mut Context) { self.dismiss(&id.into(), cx); } diff --git a/crates/ui/src/plugins_settings.rs b/crates/ui/src/plugins_settings.rs index 8afaf00ca..657357243 100644 --- a/crates/ui/src/plugins_settings.rs +++ b/crates/ui/src/plugins_settings.rs @@ -1830,10 +1830,12 @@ mod tests { /// Settings with Claude Code's plugin management switched on. fn claude_managed() -> Settings { let mut settings = Settings::default(); - settings.apply(SettingsPatch::PluginManagementProvider { - provider: agent::ProviderKind::ClaudeCode, - enabled: true, - }); + settings + .apply(SettingsPatch::PluginManagementProvider { + provider: agent::ProviderKind::ClaudeCode, + enabled: true, + }) + .unwrap(); settings } diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index f80dc4e82..8e7b35f88 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -1,5 +1,7 @@ //! Full-page settings route with section navigation and editable settings. +pub(crate) mod thread_behavior; + use std::collections::HashMap; use std::rc::Rc; @@ -32,10 +34,7 @@ use crate::window_caption; use crate::window_drag_area; use crate::window_state::WindowState; use tcode_core::project::SessionMeta; -use tcode_core::settings::{ - DEFAULT_AUTO_ARCHIVE_KEEP_COUNT, DEFAULT_AUTO_ARCHIVE_MAX_IDLE_DAYS, FallbackReviewSettings, - TitleGenerationSettings, -}; +use tcode_core::settings::{FallbackReviewSettings, TitleGenerationSettings}; /// Left inset so branding clears the native macOS 26 traffic lights near x=72. #[cfg(target_os = "macos")] @@ -64,6 +63,8 @@ const CONTENT_MAX_WIDTH: f32 = 768.; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Section { General, + ThreadBehavior, + ProjectThreadRules, Providers, SourceControl, Usage, @@ -82,9 +83,10 @@ enum Section { /// headless listener in a browser. Choosing a machine, and the invitation other /// devices pair with, live in `crate::remote`. #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] -const SECTIONS: [Section; 10] = [ +const SECTIONS: [Section; 11] = [ Section::General, Section::Remote, + Section::ThreadBehavior, Section::Providers, Section::SourceControl, Section::Usage, @@ -95,8 +97,9 @@ const SECTIONS: [Section; 10] = [ Section::Archived, ]; #[cfg(not(any(feature = "remote-hosting", target_family = "wasm")))] -const SECTIONS: [Section; 9] = [ +const SECTIONS: [Section; 10] = [ Section::General, + Section::ThreadBehavior, Section::Providers, Section::SourceControl, Section::Usage, @@ -159,7 +162,9 @@ impl Section { SectionGroup::Device } } - Self::SourceControl + Self::ThreadBehavior + | Self::ProjectThreadRules + | Self::SourceControl | Self::Providers | Self::Usage | Self::Browser @@ -184,7 +189,9 @@ impl Section { Self::Browser => cx.preview_backend, #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Self::Remote => cx.hosting, - Self::General + Self::ThreadBehavior + | Self::ProjectThreadRules + | Self::General | Self::SourceControl | Self::Providers | Self::Usage @@ -198,6 +205,8 @@ impl Section { fn id(self) -> &'static str { match self { Self::General => "settings-nav-general", + Self::ThreadBehavior => "settings-nav-thread-behavior", + Self::ProjectThreadRules => "settings-nav-project-rules", Self::SourceControl => "settings-nav-source-control", Self::Providers => "settings-nav-providers", Self::Usage => "settings-nav-usage", @@ -214,6 +223,7 @@ impl Section { fn icon(self) -> IconName { match self { Self::General => IconName::Settings, + Self::ThreadBehavior | Self::ProjectThreadRules => IconName::Inbox, Self::SourceControl => IconName::Github, Self::Providers => IconName::Bot, Self::Usage => IconName::ChartPie, @@ -230,6 +240,9 @@ impl Section { fn label(self) -> SharedString { match self { Self::General => crate::tr!("settings.general"), + Self::ThreadBehavior | Self::ProjectThreadRules => { + crate::tr!("settings.thread_behavior") + } Self::SourceControl => crate::tr!("settings.source_control"), Self::Providers => crate::tr!("settings.providers"), Self::Usage => crate::tr!("settings.usage"), @@ -344,9 +357,10 @@ pub struct SettingsPage { usage_refresh_sent: bool, /// Editable "Home URL" for the Browser page; committed on change. home_url_input: SettingsInput, - auto_archive_idle_input: SettingsInput, + auto_settle_input: SettingsInput, + project_rules_editor: Option>, + project_rules_subscription: Option, remote_attachment_limit_input: SettingsInput, - auto_archive_keep_input: SettingsInput, /// Zero-based page shown by the Archived Threads list. archived_page: usize, /// Whether the editable fields have been seeded from the host's settings. @@ -371,7 +385,8 @@ pub struct SettingsPage { impl SettingsPage { fn section_applies(&self, section: &Section, cx: &App) -> bool { - section.applies(&self.capabilities) + (section != &Section::ThreadBehavior || self.window_state.read(cx).compact) + && section.applies(&self.capabilities) && (self.store.read(cx).scope().is_full() || matches!(section, Section::General | Section::Archived)) } @@ -392,6 +407,7 @@ impl SettingsPage { #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] "remote" => Section::Remote, "archived" => Section::Archived, + "thread_behavior" => Section::ThreadBehavior, _ => Section::General, }) } @@ -544,8 +560,6 @@ impl SettingsPage { .placeholder(crate::tr!("settings.device_name.placeholder")) .default_value(device_name.clone()) }); - let auto_archive_idle_input = cx.new(|cx| InputState::new(window, cx)); - let auto_archive_keep_input = cx.new(|cx| InputState::new(window, cx)); let remote_attachment_limit = remote_attachment_limit_value(store.read(cx)); let remote_attachment_limit_input = cx.new(|cx| InputState::new(window, cx).default_value(remote_attachment_limit.clone())); @@ -554,6 +568,7 @@ impl SettingsPage { let store = store.clone(); cx.new(|cx| crate::local_permissions::LocalPermissions::new(store, window, cx)) }); + let auto_settle_input = cx.new(|cx| InputState::new(window, cx).step(1.).min(1.).max(90.)); let github_panel = cx .new(|cx| crate::github_settings::GitHubSettingsPanel::new(store.clone(), window, cx)); let mut page = Self { @@ -580,13 +595,14 @@ impl SettingsPage { advanced_expanded: None, usage_refresh_sent: false, home_url_input: SettingsInput::new(home_url_input.clone()), - auto_archive_idle_input: SettingsInput::new(auto_archive_idle_input.clone()), + auto_settle_input: SettingsInput::new(auto_settle_input.clone()), + project_rules_editor: None, + project_rules_subscription: None, remote_attachment_limit_input: SettingsInput { state: remote_attachment_limit_input.clone(), pushed: remote_attachment_limit, dirty: false, }, - auto_archive_keep_input: SettingsInput::new(auto_archive_keep_input.clone()), archived_page: 0, hydrated: false, #[cfg(all(feature = "local-permissions", target_os = "macos"))] @@ -626,25 +642,16 @@ impl SettingsPage { } }, )); - page._subscriptions.push(cx.subscribe( - &auto_archive_idle_input, - |this, input, event, cx| { - if matches!(event, InputEvent::Change) { - let value = input.read(cx).value().to_string(); - if this.auto_archive_idle_input.is_user_edit(&value) { - this.commit_auto_archive_idle_days(cx); - } - } - }, - )); - page._subscriptions.push(cx.subscribe( - &auto_archive_keep_input, - |this, input, event, cx| { + + page._subscriptions.push(cx.subscribe_in( + &auto_settle_input, + window, + |this, input, event, window, cx| { if matches!(event, InputEvent::Change) { - let value = input.read(cx).value().to_string(); - if this.auto_archive_keep_input.is_user_edit(&value) { - this.commit_auto_archive_keep_count(cx); - } + this.auto_settle_input + .is_user_edit(input.read(cx).value().as_ref()); + } else if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) { + this.commit_auto_settle_days(window, cx); } }, )); @@ -671,18 +678,17 @@ impl SettingsPage { } self.hydrated = true; let settings = self.store.read(cx).settings(); + if !self.auto_settle_input.dirty { + self.auto_settle_input.push( + settings.auto_settle_after_days.unwrap_or(3.).to_string(), + window, + cx, + ); + } if !self.home_url_input.dirty { let value = settings.browser.home_url.clone().unwrap_or_default(); self.home_url_input.push(value, window, cx); } - if !self.auto_archive_idle_input.dirty { - let value = settings.auto_archive_max_idle_days.max(1).to_string(); - self.auto_archive_idle_input.push(value, window, cx); - } - if !self.auto_archive_keep_input.dirty { - let value = settings.auto_archive_keep_count.max(1).to_string(); - self.auto_archive_keep_input.push(value, window, cx); - } } fn commit_home_url(&self, cx: &mut Context) { @@ -727,42 +733,6 @@ impl SettingsPage { ); } - fn commit_auto_archive_idle_days(&self, cx: &mut Context) { - let Some(days) = self - .auto_archive_idle_input - .state - .read(cx) - .value() - .trim() - .parse::() - .ok() - else { - return; - }; - self.dispatch_settings( - move |store| store.set_auto_archive_max_idle_days(days.max(1)), - cx, - ); - } - - fn commit_auto_archive_keep_count(&self, cx: &mut Context) { - let Some(keep) = self - .auto_archive_keep_input - .state - .read(cx) - .value() - .trim() - .parse::() - .ok() - else { - return; - }; - self.dispatch_settings( - move |store| store.set_auto_archive_keep_count(keep.max(1)), - cx, - ); - } - fn build_provider_cards(&mut self, cx: &mut Context) { let profiles = self.store.read(cx).all_provider_profiles(); self.provider_cards = profiles @@ -879,7 +849,20 @@ impl SettingsPage { /// The open section's name, which is also this page's compact nav-bar /// title and the label its child's Back control carries. - pub(crate) fn section_title(&self) -> SharedString { + pub(crate) fn section_title(&self, cx: &App) -> SharedString { + if self.section == Section::ProjectThreadRules + && self.window_state.read(cx).destination() + != crate::window_state::Destination::SettingsThreadRules + { + return Section::ThreadBehavior.label(); + } + // The phone page names just the project: the nav bar has no room for + // the dialog's sentence-long title. + if self.section == Section::ProjectThreadRules + && let Some(editor) = &self.project_rules_editor + { + return editor.read(cx).project.name.clone().into(); + } self.section.label() } @@ -1288,16 +1271,6 @@ impl SettingsPage { page.device_name_input.push(device_name, window, cx); let limit = remote_attachment_limit_value(page.store.read(cx)); page.remote_attachment_limit_input.push(limit, window, cx); - page.auto_archive_idle_input.push( - DEFAULT_AUTO_ARCHIVE_MAX_IDLE_DAYS.to_string(), - window, - cx, - ); - page.auto_archive_keep_input.push( - DEFAULT_AUTO_ARCHIVE_KEEP_COUNT.to_string(), - window, - cx, - ); }); apply_theme(ThemeMode::System, window, cx); true @@ -1334,6 +1307,8 @@ impl SettingsPage { } let column = match self.section { Section::General => self.render_general(cx), + Section::ThreadBehavior => self.render_thread_behavior(cx), + Section::ProjectThreadRules => v_flex().children(self.project_rules_editor.clone()), Section::SourceControl => v_flex().child(self.github_panel.clone()), Section::Providers => self.render_providers(window, cx), Section::Usage => self.render_usage(cx), @@ -1579,6 +1554,10 @@ impl SettingsPage { .child(self.section_label(crate::tr!("settings.conversation_section"), cx)) .child(self.grouped_plain(conversation, cx)), ) + .when( + !self.window_state.read(cx).compact && self.store.read(cx).scope().is_full(), + |column| column.child(self.render_thread_behavior(cx)), + ) .child( v_flex() .child(self.section_label(crate::tr!("settings.workspace_section"), cx)) @@ -2133,101 +2112,10 @@ impl SettingsPage { fn render_archived(&mut self, cx: &mut Context) -> gpui::Div { let full = self.store.read(cx).scope().is_full(); let groups = self.store.read(cx).archived_groups(); - let settings = self.store.read(cx).settings(); - let days = settings.auto_archive_max_idle_days.max(1); - let keep = settings.auto_archive_keep_count.max(1); - let auto_archive_reset = self.reset_action( - "reset-auto-archive", - settings.auto_archive_disabled, - cx, - |this, _, cx| { - this.dispatch_settings(|store| store.set_auto_archive_disabled(false), cx) - }, - ); - let idle_days_reset = self.reset_action( - "reset-auto-archive-idle-days", - settings.auto_archive_max_idle_days != DEFAULT_AUTO_ARCHIVE_MAX_IDLE_DAYS, - cx, - |this, window, cx| { - this.dispatch_settings( - |store| { - store.set_auto_archive_max_idle_days(DEFAULT_AUTO_ARCHIVE_MAX_IDLE_DAYS) - }, - cx, - ); - this.auto_archive_idle_input.push( - DEFAULT_AUTO_ARCHIVE_MAX_IDLE_DAYS.to_string(), - window, - cx, - ); - }, - ); - let keep_count_reset = self.reset_action( - "reset-auto-archive-keep-count", - settings.auto_archive_keep_count != DEFAULT_AUTO_ARCHIVE_KEEP_COUNT, - cx, - |this, window, cx| { - this.dispatch_settings( - |store| store.set_auto_archive_keep_count(DEFAULT_AUTO_ARCHIVE_KEEP_COUNT), - cx, - ); - this.auto_archive_keep_input.push( - DEFAULT_AUTO_ARCHIVE_KEEP_COUNT.to_string(), - window, - cx, - ); - }, - ); - let rows = vec![ - self.toggle_row( - "auto-archive", - crate::tr!("settings.auto_archive.title"), - crate::tr!( - "settings.auto_archive.description", - days = days, - keep = keep - ), - !settings.auto_archive_disabled, - auto_archive_reset, - cx, - |store, checked| store.set_auto_archive_disabled(!checked), - ), - self.row_frame(cx) - .child(self.row_labels( - crate::tr!("settings.auto_archive.idle_days"), - crate::tr!("settings.auto_archive.idle_days_description"), - idle_days_reset, - cx, - )) - .child( - Input::new(&self.auto_archive_idle_input.state) - .w(px(72.)) - .rounded(crate::material::radius_input(cx)), - ) - .into_any_element(), - self.row_frame(cx) - .child(self.row_labels( - crate::tr!("settings.auto_archive.keep_count"), - crate::tr!("settings.auto_archive.keep_count_description"), - keep_count_reset, - cx, - )) - .child( - Input::new(&self.auto_archive_keep_input.state) - .w(px(72.)) - .rounded(crate::material::radius_input(cx)), - ) - .into_any_element(), - ]; - let controls = v_flex() - .child(self.section_label(crate::tr!("settings.auto_archive.section"), cx)) - .child(self.grouped_plain(rows, cx)); - if groups.is_empty() { let loading = self.store.read(cx).archived_loading(); return v_flex() .gap(px(20.)) - .when(full, |column| column.child(controls)) .child(self.section_label(crate::tr!("settings.archived_section"), cx)) .child( v_flex() @@ -2277,7 +2165,6 @@ impl SettingsPage { let now = now_secs(); let mut col = v_flex() .gap(px(20.)) - .when(full, |column| column.child(controls)) .child( gpui_base::h_flex() .items_center() @@ -3128,6 +3015,13 @@ impl SettingsPage { window: &mut Window, cx: &mut Context, ) -> AnyElement { + if self.section == Section::ProjectThreadRules + && self.window_state.read(cx).destination() + != crate::window_state::Destination::SettingsThreadRules + { + self.section = Section::ThreadBehavior; + self.project_rules_editor = None; + } self.render_content(window, cx) } } @@ -3550,7 +3444,6 @@ mod tests { .expect("spawn settings test host"); smol::block_on(host.update_state_for_test(|state, _| { state.settings.browser.home_url = Some("https://host.example".into()); - state.settings.auto_archive_keep_count = 7; })) .expect("seed host settings"); @@ -3605,7 +3498,6 @@ mod tests { page.home_url_input.state.read(cx).value(), "https://host.example" ); - assert_eq!(page.auto_archive_keep_input.state.read(cx).value(), "7"); }); // The user edits the field: `replace_all` takes the same path typing diff --git a/crates/ui/src/settings_page/thread_behavior.rs b/crates/ui/src/settings_page/thread_behavior.rs new file mode 100644 index 000000000..b4c7d59e9 --- /dev/null +++ b/crates/ui/src/settings_page/thread_behavior.rs @@ -0,0 +1,600 @@ +use super::*; +use tcode_core::{project::Project, settings::ProjectSettlementSettings}; + +pub(crate) fn open_project_rules( + store: Entity, + project: Project, + window: &mut Window, + cx: &mut App, +) { + let editor = cx.new(|cx| ProjectRulesEditor::new(store, project, window, cx)); + let title = editor.read(cx).title(); + window.open_dialog(cx, move |dialog, _, _| { + let content = editor.clone(); + let save = editor.clone(); + let button_save = editor.clone(); + dialog + .title(title.clone()) + .content(move |body, _, _| body.child(content.clone())) + .on_ok(move |_, window, cx| save.update(cx, |editor, cx| editor.save(window, cx))) + .footer( + crate::overlay::DialogActions::new() + .child( + Button::new("project-rules-cancel") + .label(crate::tr!("settings.cancel")) + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("project-rules-save") + .primary() + .label(crate::tr!("settings.auto_settle.save")) + .on_click(move |_, window, cx| { + if button_save.update(cx, |editor, cx| editor.save(window, cx)) { + window.close_dialog(cx); + } + }), + ), + ) + }); +} + +impl SettingsPage { + pub(super) fn commit_auto_settle_days(&mut self, window: &mut Window, cx: &mut Context) { + let stored = self + .store + .read(cx) + .settings() + .auto_settle_after_days + .unwrap_or(3.); + let parsed = self + .auto_settle_input + .state + .read(cx) + .value() + .trim() + .parse::() + .ok() + .filter(|value| value.is_finite()) + .map(|value| value.clamp(1., 90.)); + let value = parsed.unwrap_or(stored); + self.auto_settle_input.dirty = false; + self.auto_settle_input.push(value.to_string(), window, cx); + if parsed.is_some() { + self.dispatch_settings( + move |store| store.set_auto_settle_after_days(Some(value)), + cx, + ); + } + } + + pub(super) fn render_thread_behavior(&mut self, cx: &mut Context) -> gpui::Div { + let settings = self.store.read(cx).settings(); + let reset = self.reset_action( + "reset-auto-settle", + settings.auto_settle_after_days != Some(3.), + cx, + |this, _, cx| { + this.dispatch_settings(|store| store.set_auto_settle_after_days(Some(3.)), cx) + }, + ); + let mut rows = vec![self.toggle_row( + "auto-settle-inactive", + crate::tr!("settings.auto_settle.title"), + crate::tr!("settings.auto_settle.description"), + settings.auto_settle_after_days.is_some(), + reset, + cx, + |store, checked| store.set_auto_settle_after_days(checked.then_some(3.)), + )]; + if settings.auto_settle_after_days.is_some() { + rows.push( + self.row_frame(cx) + .child(self.row_labels( + crate::tr!("settings.auto_settle.days"), + crate::tr!("settings.auto_settle.days_description"), + None, + cx, + )) + .child(days_input(&self.auto_settle_input.state)) + .into_any_element(), + ); + } + let projects = self.store.read(cx).projects(); + let summary = |project: &Project| -> Option { + settings + .project_settlement_overrides + .get(&project.id)? + .auto_settle_after_days + .map(days_label) + }; + if self.window_state.read(cx).compact { + // Phone: every project is a row that pushes its rules page; there + // is no dropdown or dialog to pick one from. + rows.push( + self.row_frame(cx) + .child(self.row_labels( + crate::tr!("settings.auto_settle.overrides"), + crate::tr!("settings.auto_settle.overrides_description"), + None, + cx, + )) + .into_any_element(), + ); + for project in projects { + let summary = summary(&project); + let edit = project.clone(); + rows.push( + crate::material::accessible_clickable( + gpui_base::h_flex(), + SharedString::from(format!("project-rules-{}", project.id)), + Role::Button, + SharedString::from(project.name.clone()), + cx, + ) + .w_full() + .min_h(px(44.)) + .px_3() + .gap_2() + .items_center() + .cursor_pointer() + .hover(|row| row.bg(cx.theme().list_hover)) + .child(crate::project_icon::artwork(&project, 16.)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(15.)) + .child(project.name.clone()), + ) + .children(summary.map(|summary| { + div() + .text_size(px(13.)) + .text_color(cx.theme().muted_foreground) + .child(summary) + })) + .child( + Icon::new(IconName::ChevronRight) + .xsmall() + .text_color(cx.theme().muted_foreground), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_project_rules(edit.clone(), window, cx) + })) + .into_any_element(), + ); + } + } else { + let options = projects + .iter() + .filter(|project| { + !settings + .project_settlement_overrides + .contains_key(&project.id) + }) + .map(|project| SelectRowOption { + id: project.id.clone().into(), + label: project.name.clone().into(), + value: project.clone(), + selected: false, + description: None, + }) + .collect(); + rows.push( + self.select_row( + "add-project-rules", + "project-rules-popover", + "project-rules-menu", + 240., + crate::tr!("settings.auto_settle.overrides") + .into_owned() + .into(), + crate::tr!("settings.auto_settle.overrides_description") + .into_owned() + .into(), + crate::tr!("settings.auto_settle.add_override") + .into_owned() + .into(), + options, + None, + |project, page, window, cx| { + page.update(cx, |page, cx| page.edit_project_rules(project, window, cx)) + }, + cx, + ), + ); + for project in projects.into_iter().filter(|project| { + settings + .project_settlement_overrides + .contains_key(&project.id) + }) { + let summary = summary(&project).unwrap_or_default(); + let edit = project.clone(); + let id = project.id.clone(); + rows.push( + gpui_base::h_flex() + .min_h(px(44.)) + .px_3() + .gap_2() + .child(crate::project_icon::artwork(&project, 16.)) + .child(div().flex_1().text_size(px(15.)).child(project.name)) + .child( + div() + .text_size(px(13.)) + .text_color(cx.theme().muted_foreground) + .child(summary), + ) + .child( + Button::new(SharedString::from(format!("edit-rules-{id}"))) + .ghost() + .xsmall() + .text_size(px(13.)) + .label(crate::tr!("settings.auto_settle.edit")) + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_project_rules(edit.clone(), window, cx) + })), + ) + .child( + Button::new(SharedString::from(format!("remove-rules-{id}"))) + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(crate::tr!("settings.auto_settle.remove_override")) + .on_click(cx.listener(move |this, _, _, cx| { + this.dispatch_settings( + |store| store.set_project_settlement(id.clone(), None), + cx, + ) + })), + ) + .into_any_element(), + ); + } + } + v_flex() + .child(self.section_label(crate::tr!("settings.threads_section"), cx)) + .child(self.grouped_plain(rows, cx)) + .child( + div() + .pl_3() + .pt_2() + .text_size(px(13.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("settings.auto_settle.footnote")), + ) + } + + fn edit_project_rules( + &mut self, + project: Project, + window: &mut Window, + cx: &mut Context, + ) { + if self.window_state.read(cx).compact { + self.project_rules_editor = + Some(cx.new(|cx| ProjectRulesEditor::new(self.store.clone(), project, window, cx))); + self.project_rules_subscription = self.project_rules_editor.as_ref().map(|editor| { + cx.subscribe(editor, |this, _, _: &gpui::DismissEvent, cx| { + this.window_state.update(cx, |state, cx| state.back(cx)); + this.section = Section::ThreadBehavior; + this.project_rules_editor = None; + cx.notify(); + }) + }); + self.section = Section::ProjectThreadRules; + self.window_state.update(cx, |state, cx| { + state.go(crate::window_state::Destination::SettingsThreadRules, cx) + }); + cx.notify(); + } else { + open_project_rules(self.store.clone(), project, window, cx); + } + } +} + +// gpui-base NumberInput owns stepping, but its text and button slots are +// unstyled; compose the existing input and icons for both days controls. +fn days_input(state: &Entity) -> gpui_base::NumberInput { + gpui_base::NumberInput::new(state) + .controls_right() + .w(px(88.)) + .h(px(32.)) + .input(Input::new(state).small()) + .increment_button(|button| { + button + .w_4() + .items_center() + .justify_center() + .child(Icon::new(IconName::ChevronUp).size_3()) + }) + .decrement_button(|button| { + button + .w_4() + .items_center() + .justify_center() + .child(Icon::new(IconName::ChevronDown).size_3()) + }) +} + +fn days_label(value: Option) -> SharedString { + value.map_or_else( + || crate::tr!("settings.auto_settle.never").into_owned().into(), + |days| { + if days == 1. { + crate::tr!("settings.auto_settle.days_value_one") + } else { + crate::tr!("settings.auto_settle.days_value", count = days) + } + .into_owned() + .into() + }, + ) +} + +pub(super) struct ProjectRulesEditor { + store: Entity, + pub(super) project: Project, + draft: ProjectSettlementSettings, + days: Entity, + _subscriptions: Vec, +} + +impl ProjectRulesEditor { + fn new( + store: Entity, + project: Project, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let settings = store.read(cx).settings(); + let draft = settings + .project_settlement_overrides + .get(&project.id) + .cloned() + .unwrap_or_default(); + let value = draft + .auto_settle_after_days + .flatten() + .or(settings.auto_settle_after_days) + .unwrap_or(3.); + let days = cx.new(|cx| { + InputState::new(window, cx) + .step(1.) + .min(1.) + .max(90.) + .default_value(value.to_string()) + }); + let subscriptions = vec![ + cx.subscribe_in(&days, window, |this, _, event, window, cx| { + if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) { + this.commit_days(window, cx); + } + }), + ]; + Self { + store, + project, + draft, + days, + _subscriptions: subscriptions, + } + } + + pub(super) fn title(&self) -> SharedString { + crate::tr!( + "settings.auto_settle.dialog_title", + project = self.project.name.clone() + ) + .into_owned() + .into() + } + + fn commit_days(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let stored = self.draft.auto_settle_after_days.flatten().unwrap_or(3.); + let parsed = self + .days + .read(cx) + .value() + .trim() + .parse::() + .ok() + .filter(|value| value.is_finite()) + .map(|value| value.clamp(1., 90.)); + let value = parsed.unwrap_or(stored); + self.days.update(cx, |input, cx| { + input.set_value(value.to_string(), window, cx) + }); + if matches!(self.draft.auto_settle_after_days, Some(Some(_))) { + self.draft.auto_settle_after_days = Some(Some(value)); + } + parsed.is_some() + } + + fn save(&mut self, window: &mut Window, cx: &mut Context) -> bool { + self.commit_days(window, cx); + let value = + (self.draft != ProjectSettlementSettings::default()).then(|| self.draft.clone()); + self.store.update(cx, |store, _| { + store.set_project_settlement(self.project.id.clone(), value) + }); + true + } +} + +impl Render for ProjectRulesEditor { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let default = days_label(self.store.read(cx).settings().auto_settle_after_days); + let choices = [ + ( + None, + crate::tr!( + "settings.auto_settle.use_default", + value = default.to_string() + ) + .into_owned(), + ), + ( + Some(None), + crate::tr!("settings.auto_settle.never").into_owned(), + ), + ( + Some(Some( + self.draft.auto_settle_after_days.flatten().unwrap_or(3.), + )), + crate::tr!("settings.auto_settle.after_days").into_owned(), + ), + ]; + let selected = self.draft.auto_settle_after_days; + let trigger_label = choices + .iter() + .find(|(value, _)| { + value.is_none() && selected.is_none() + || matches!( + (value, selected), + (Some(None), Some(None)) | (Some(Some(_)), Some(Some(_))) + ) + }) + .map(|(_, label)| label.clone()) + .unwrap_or_default(); + let editor = cx.entity(); + let compact = crate::window_seam::window_is_compact(window, cx); + let selector = if compact { + // A phone page lists the choices; it opens no dropdown. + let rows = choices + .into_iter() + .enumerate() + .map(|(index, (value, label))| { + let checked = trigger_label == label; + let editor = editor.clone(); + crate::material::accessible_clickable( + gpui_base::h_flex(), + ("project-rules-choice", index), + Role::Button, + SharedString::from(label.clone()), + cx, + ) + .aria_selected(checked) + .w_full() + .min_h(px(44.)) + .px_3() + .gap_2() + .items_center() + .cursor_pointer() + .hover(|row| row.bg(cx.theme().list_hover)) + .child(div().flex_1().text_size(px(15.)).child(label)) + .when(checked, |row| { + row.child( + Icon::new(IconName::Check) + .size_4() + .text_color(cx.theme().primary), + ) + }) + .on_click(move |_, _, cx| { + editor.update(cx, |editor, cx| { + editor.draft.auto_settle_after_days = value; + cx.notify(); + }) + }) + .into_any_element() + }) + .collect(); + crate::material::grouped(rows, cx).into_any_element() + } else { + crate::material::overlay_popover("project-auto-settle", cx) + // The same trigger as the settings page's dropdown rows. + .trigger( + Button::new("project-auto-settle-choice") + .ghost() + .outline() + .compact() + .child( + gpui_base::h_flex() + .w_full() + .items_center() + .justify_between() + .gap_2() + .text_size(px(13.)) + .child(trigger_label.clone()) + .child( + Icon::new(IconName::ChevronDown) + .xsmall() + .text_color(cx.theme().muted_foreground), + ), + ), + ) + .content(move |_, _, cx| { + let popover = cx.entity(); + v_flex().p_1().min_w(px(240.)).gap_0p5().children( + choices + .clone() + .into_iter() + .enumerate() + .map(|(index, (value, label))| { + let editor = editor.clone(); + let popover = popover.clone(); + let checked = trigger_label == label; + crate::material::accessible_clickable( + gpui_base::h_flex(), + ("project-rules-option", index), + Role::MenuItem, + SharedString::from(label.clone()), + cx, + ) + .aria_selected(checked) + .w_full() + .px_2() + .py_1() + .gap_2() + .items_center() + .text_size(px(13.)) + .rounded(crate::material::radius_button(cx)) + .cursor_pointer() + .hover(|item| item.bg(cx.theme().accent)) + .child(div().flex_1().child(label)) + .when(checked, |item| { + item.child(Icon::new(IconName::Check).xsmall()) + }) + .on_click(move |_, window, cx| { + editor.update(cx, |editor, cx| { + editor.draft.auto_settle_after_days = value; + cx.notify(); + }); + popover.update(cx, |popover, cx| popover.dismiss(window, cx)); + }) + }), + ) + }) + .into_any_element() + }; + let mut content = v_flex() + .gap_3() + .child( + div() + .text_size(px(15.)) + .child(crate::tr!("settings.auto_settle.title")), + ) + .child(selector); + if matches!(self.draft.auto_settle_after_days, Some(Some(_))) { + content = content + .child( + div() + .text_size(px(13.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("settings.auto_settle.days")), + ) + .child(days_input(&self.days)); + } + if compact { + content = content.child( + Button::new("save-project-rules") + .label(crate::tr!("settings.auto_settle.save")) + .on_click(cx.listener(|this, _, window, cx| { + this.save(window, cx); + cx.emit(gpui::DismissEvent); + })), + ); + } + content + } +} +impl gpui::EventEmitter for ProjectRulesEditor {} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 41836852e..049205cd1 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -151,7 +151,9 @@ impl Render for DestinationView { Destination::Thread => shell.render_thread_page(window, cx), Destination::Panel => shell.render_panel_page(window, cx), Destination::Settings => shell.render_settings_page(false, window, cx), - Destination::SettingsSection => shell.render_settings_page(true, window, cx), + Destination::SettingsSection | Destination::SettingsThreadRules => { + shell.render_settings_page(true, window, cx) + } }) }) .unwrap_or_else(|| div().into_any_element()) @@ -1088,6 +1090,18 @@ pub(crate) fn navigate_thread(action: &crate::shortcut::NavigateThread, cx: &mut }); } +pub(crate) fn undo_thread_action(action: &crate::sidebar::ThreadUndo, cx: &mut App) { + let Some((window, shell)) = current_window_shell(cx) else { + return; + }; + let action = action.clone(); + cx.defer(move |cx| { + let _ = window.update(cx, |_, window, cx| { + shell.update(cx, |shell, cx| shell.on_thread_undo(&action, window, cx)); + }); + }); +} + /// Point this window at another host. pub(crate) fn switch_current(target: AttachmentTarget, window: &mut Window, cx: &mut App) { let Some(shell) = current_shell(cx) else { @@ -1250,6 +1264,19 @@ impl AppShell { } } + fn on_thread_undo( + &mut self, + _: &crate::sidebar::ThreadUndo, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(attachment) = &self.attachment { + attachment + .sidebar + .update(cx, |sidebar, cx| sidebar.undo_lifecycle(window, cx)); + } + } + fn on_toggle_palette( &mut self, _: &TogglePalette, @@ -1506,7 +1533,7 @@ impl AppShell { return div().into_any_element(); }; let title = if detail { - settings.read(cx).section_title() + settings.read(cx).section_title(cx) } else { crate::tr!("settings.title").into_owned().into() }; @@ -1919,6 +1946,8 @@ impl AppShell { .text_size(px(16.)) .line_height(px(22.)) .on_action(cx.listener(Self::on_toggle_palette)) + .key_context("TcodeShell") + .on_action(cx.listener(Self::on_thread_undo)) // Every compact page, settings included, is one entry of the same // stack: one nav bar, one Back, one transition. .child(stack) @@ -2079,6 +2108,8 @@ impl AppShell { }) .text_color(cx.theme().foreground) .on_action(cx.listener(Self::on_toggle_palette)) + .key_context("TcodeShell") + .on_action(cx.listener(Self::on_thread_undo)) .child( div() .id("workspace") @@ -2107,6 +2138,8 @@ impl AppShell { }) .text_color(cx.theme().foreground) .on_action(cx.listener(Self::on_toggle_palette)) + .key_context("TcodeShell") + .on_action(cx.listener(Self::on_thread_undo)) .child( h_flex() .id("workspace") @@ -2355,6 +2388,8 @@ impl AppShell { }) .text_color(cx.theme().foreground) .on_action(cx.listener(Self::on_toggle_palette)) + .key_context("TcodeShell") + .on_action(cx.listener(Self::on_thread_undo)) .child( div() .id("workspace") @@ -4143,21 +4178,28 @@ mod tests { state, current_host: None, })); - let (shell, cx) = cx.add_window_view(move |window, cx| { + let mounted = Rc::new(RefCell::new(None)); + let capture = mounted.clone(); + let (_, cx) = cx.add_window_view(move |window, cx| { let window_state = cx.new(|_| WindowState::new(false)); - AppShell::new( - window_state, - ShellSetup { - local: Some(Rc::new(move || { - transport.borrow_mut().take().expect("one attachment") - })), - initial, - ..Default::default() - }, - window, - cx, - ) + let shell = cx.new(|cx| { + AppShell::new( + window_state, + ShellSetup { + local: Some(Rc::new(move || { + transport.borrow_mut().take().expect("one attachment") + })), + initial, + ..Default::default() + }, + window, + cx, + ) + }); + *capture.borrow_mut() = Some(shell.clone()); + gpui_base::Root::new(shell, window, cx) }); + let shell = mounted.borrow_mut().take().unwrap(); if let Some(store) = shell.read_with(cx, |shell, _| shell.store()) { crate::store::tests::seed_full_scope(&store, &incoming, Vec::new(), cx); } @@ -4406,7 +4448,6 @@ mod tests { smol::block_on(host.update_state_for_test(move |state, _| { state.projects = vec![project]; state.sessions = vec![meta]; - state.settings.auto_archive_disabled = true; })) .unwrap(); let (shell, _transport, cx) = mount(cx); @@ -4493,7 +4534,6 @@ mod tests { smol::block_on(host.update_state_for_test(move |state, _| { state.projects = vec![project]; state.sessions = sessions; - state.settings.auto_archive_disabled = true; })) .unwrap(); let (shell, _transport, cx) = mount(cx); @@ -4576,6 +4616,61 @@ mod tests { Some("second"), "compact navigation uses the same shortcuts" ); + cx.update(|_, cx| crate::window_seam::override_mobile_for_test(cx, false)); + resize(cx, 1200.); + draw(cx); + let row = cx + .debug_bounds("sidebar-thread-second") + .expect("second row"); + let settle = gpui::point(row.right() - px(18.), row.top() + px(14.)); + cx.simulate_mouse_move(settle, None, gpui::Modifiers::default()); + draw(cx); + cx.simulate_click(settle, gpui::Modifiers::default()); + await_restore_update(&shell, cx, |store| { + store + .sidebar_sessions() + .iter() + .any(|meta| meta.id == "second" && meta.is_settled()) + }); + draw(cx); + assert!(cx.update(|window, cx| window.has_notification::(cx))); + let undo_key = if cfg!(target_os = "macos") { + "cmd-z" + } else { + "ctrl-z" + }; + let composer = shell.read_with(cx, |shell, cx| { + shell.attachment.as_ref().unwrap().chat.read(cx).composer() + }); + let input = composer.read_with(cx, |composer, cx| composer.input_focus_handle(cx)); + cx.update(|window, cx| input.focus(window, cx)); + draw(cx); + cx.simulate_keystrokes("x"); + assert_eq!( + composer.read_with(cx, |composer, cx| composer.draft(cx)), + "x" + ); + cx.simulate_keystrokes(undo_key); + assert_eq!( + composer.read_with(cx, |composer, cx| composer.draft(cx)), + "" + ); + assert!(store.read_with(cx, |store, _| { + store + .sidebar_sessions() + .iter() + .any(|meta| meta.id == "second" && meta.is_settled()) + })); + cx.update(|window, cx| window.blur(cx)); + draw(cx); + cx.simulate_keystrokes(undo_key); + await_restore_update(&shell, cx, |store| { + store + .sidebar_sessions() + .iter() + .any(|meta| meta.id == "second" && !meta.is_settled()) + }); + assert!(!cx.update(|window, cx| window.has_notification::(cx))); host.shutdown_blocking().unwrap(); std::fs::remove_dir_all(root).unwrap(); } @@ -5118,6 +5213,7 @@ mod tests { waiting: false, waiting_for_approval: false, waiting_for_input: false, + failed: false, unread: false, fork: tcode_protocol::ForkAvailability::Available, }, diff --git a/crates/ui/src/shortcut.rs b/crates/ui/src/shortcut.rs index c402b9c71..850ddac8f 100644 --- a/crates/ui/src/shortcut.rs +++ b/crates/ui/src/shortcut.rs @@ -12,6 +12,13 @@ pub(crate) enum NavigateThread { pub(crate) fn init(cx: &mut App) { cx.on_action(crate::shell::navigate_thread); + // With nothing focused the shell's own Undo listener is off the dispatch path. + cx.on_action(crate::shell::undo_thread_action); + cx.bind_keys([KeyBinding::new( + "secondary-z", + crate::sidebar::ThreadUndo, + None, + )]); for number in 1..=9 { let key = format!("secondary-{number}"); cx.bind_keys([ @@ -28,6 +35,7 @@ pub(crate) fn init(cx: &mut App) { // window root's focus traversal and selection copy must not claim them. let terminal = Some(crate::terminal_drawer::CONTEXT); cx.bind_keys([ + KeyBinding::new("secondary-z", NoAction, terminal), KeyBinding::new("tab", NoAction, terminal), KeyBinding::new("shift-tab", NoAction, terminal), #[cfg(target_os = "macos")] diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index 310493cad..5210dec29 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -25,7 +25,7 @@ use gpui::{ }; use gpui_base::{Scrollbar, StyledExt as _, h_flex, v_flex}; use serde::Deserialize; -use tcode_protocol::ThreadExportFormat; +use tcode_protocol::{Command, ThreadExportFormat}; use tcode_core::{ project::{ProjectGroup, SessionMeta}, @@ -52,9 +52,6 @@ const TRAFFIC_LIGHT_INSET: f32 = 80.; #[cfg(not(target_os = "macos"))] const TRAFFIC_LIGHT_INSET: f32 = 8.; -/// Max threads shown per project group before the "Show more" row. -const THREADS_COLLAPSED_LIMIT: usize = 6; - /// Flat-list row geometry, including the 2px gap reserved below every row. const FLAT_ROOT_ROW_HEIGHT: f32 = 50.; const FLAT_CHILD_ROW_HEIGHT: f32 = 32.; @@ -69,19 +66,6 @@ const SETTLED_HEADER_HEIGHT: f32 = 34.; /// operating system's reduced-motion preference is enabled. const FLAT_REORDER_SPRING: SpringConfig = SpringConfig::new(420., 41., 1.); -/// Localized thread-list toggle, when the project has enough threads to need -/// one. Keeping the toggle present in both states is what lets an expanded list -/// be collapsed again. -fn thread_list_toggle_label(total: usize, expanded: bool) -> Option> { - (total > THREADS_COLLAPSED_LIMIT).then(|| { - if expanded { - crate::tr!("sidebar.show_less") - } else { - crate::tr!("sidebar.show_more") - } - }) -} - /// A sidebar label that owns the remaining row width and always truncates on /// one line. `text_ellipsis` alone still leaves GPUI's default wrapping on, /// which lets a glyph move onto a second line at resize boundaries. @@ -144,6 +128,7 @@ struct ThreadFlags { waiting_for_approval: bool, waiting_for_input: bool, working: bool, + failed: bool, /// Background tasks run, or a child thread has not finished. waiting: bool, } @@ -155,6 +140,8 @@ struct ThreadRowState { waiting_for_approval: bool, waiting_for_input: bool, waiting: bool, + failed: bool, + auto_settle_enabled: bool, is_worktree: bool, is_child: bool, show_unread: bool, @@ -207,27 +194,7 @@ fn derive_thread_render_state( } fn partition_settled(sessions: &[SessionMeta]) -> (Vec, Vec) { - let mut active: HashSet<_> = sessions - .iter() - .filter(|meta| meta.settled_at.is_none()) - .map(|meta| meta.id.as_str()) - .collect(); - for meta in sessions.iter().filter(|meta| meta.settled_at.is_none()) { - let mut parent = meta.parent_session_id.as_deref(); - while let Some(id) = parent { - if !active.insert(id) { - break; - } - parent = sessions - .iter() - .find(|meta| meta.id == id) - .and_then(|meta| meta.parent_session_id.as_deref()); - } - } - sessions - .iter() - .cloned() - .partition(|meta| active.contains(meta.id.as_str())) + tcode_core::thread_sort::partition_threads(sessions) } fn thread_visible(meta: &SessionMeta, collapsed_parents: &HashSet) -> bool { @@ -254,153 +221,24 @@ fn visible_threads<'a>( .collect() } -/// Compact lists own their ordering: form families before sorting so live workers -/// stay attached even when their parent is older or its project is folded. fn compact_visible_threads<'a>( sessions: &'a [SessionMeta], collapsed: &HashSet, project: Option<&str>, ) -> Vec<&'a SessionMeta> { - let mut ordered: Vec<_> = sessions - .iter() - .filter(|meta| meta.archived_at.is_none()) - .collect(); - ordered.sort_by(|a, b| { - b.updated_at - .cmp(&a.updated_at) - .then_with(|| a.id.cmp(&b.id)) - }); - let by_id: HashMap<_, _> = ordered - .iter() - .map(|meta| (meta.id.as_str(), *meta)) - .collect(); - let mut families: Vec<(&SessionMeta, Vec<&SessionMeta>)> = Vec::new(); - for meta in &ordered { - let mut root = *meta; - let mut visited = HashSet::from([root.id.as_str()]); - while let Some(parent) = root - .parent_session_id - .as_deref() - .and_then(|id| by_id.get(id)) - { - if !visited.insert(parent.id.as_str()) { - break; - } - root = parent; - } - if project.is_some_and(|id| root.project_id.as_deref() != Some(id)) { - continue; - } - if let Some((_, members)) = families.iter_mut().find(|(head, _)| head.id == root.id) { - members.push(meta); - } else { - families.push((root, vec![meta])); - } - } - // Families were encountered in descending maximum activity order. - fn append<'a>( - meta: &'a SessionMeta, - members: &[&'a SessionMeta], - collapsed: &HashSet, - rows: &mut Vec<&'a SessionMeta>, - ) { - if rows.iter().any(|row| row.id == meta.id) { - return; - } - rows.push(meta); - if !collapsed.contains(&meta.id) { - for child in members - .iter() - .filter(|child| child.parent_session_id.as_deref() == Some(meta.id.as_str())) - { - append(child, members, collapsed, rows); - } - } - } - let mut rows = Vec::new(); - for (root, members) in families { - append(root, &members, collapsed, &mut rows); - } - rows -} - -#[derive(Debug)] -struct FlatThreadBlock<'a> { - sessions: Vec<&'a SessionMeta>, - bucket: u8, - updated_at: u64, + visible_threads(sessions, collapsed) + .into_iter() + .filter(|meta| project.is_none_or(|id| meta.project_id.as_deref() == Some(id))) + .collect() } -/// Sort a parent-first flat session pool by block attention and recency, then -/// apply project filtering and the shared parent-collapse state. fn flat_visible_threads<'a>( sessions: &'a [SessionMeta], - collapsed_parents: &HashSet, - project_filter: Option<&str>, - flags: &HashMap, + collapsed: &HashSet, + project: Option<&str>, + _flags: &HashMap, ) -> Vec<&'a SessionMeta> { - let ids: HashSet<&str> = sessions.iter().map(|session| session.id.as_str()).collect(); - let mut blocks: Vec> = Vec::new(); - for session in sessions { - let is_root = session - .parent_session_id - .as_deref() - .is_none_or(|parent_id| !ids.contains(parent_id)); - if is_root || blocks.is_empty() { - blocks.push(vec![session]); - } else if let Some(block) = blocks.last_mut() { - block.push(session); - } - } - - let mut blocks: Vec> = blocks - .into_iter() - .filter(|block| { - project_filter - .is_none_or(|project_id| block[0].project_id.as_deref() == Some(project_id)) - }) - .map(|block| { - let waiting = block.iter().any(|session| { - flags - .get(&session.id) - .is_some_and(|flags| flags.waiting_for_approval || flags.waiting_for_input) - }); - let working = block - .iter() - .any(|session| flags.get(&session.id).is_some_and(|flags| flags.working)); - let updated_at = block - .iter() - .map(|session| session.updated_at) - .max() - .unwrap_or_default(); - FlatThreadBlock { - sessions: block, - bucket: if waiting { - 0 - } else if working { - 1 - } else { - 2 - }, - updated_at, - } - }) - .collect(); - blocks.sort_by(|a, b| { - a.bucket - .cmp(&b.bucket) - .then_with(|| b.updated_at.cmp(&a.updated_at)) - }); - blocks - .into_iter() - .flat_map(|block| block.sessions) - .filter(|meta| { - meta.parent_session_id.as_ref().is_none_or(|id| { - !sessions.iter().any(|parent| &parent.id == id) - || thread_visible(meta, collapsed_parents) - }) - }) - .collect() + compact_visible_threads(sessions, collapsed, project) } /// The target top edge for each visible flat-list row. These positions mirror @@ -498,6 +336,27 @@ struct ThreadArchive(String); struct ThreadSettle(String); #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = tcode_thread, no_json)] +struct ThreadAutoSettle(String, bool); + +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode, no_json)] +pub(crate) struct ThreadUndo; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum UndoKind { + Settle, + Archive, +} +struct LifecycleUndo { + kind: UndoKind, + entries: Vec, +} +struct UndoEntry { + command: Command, + reopen: Option, +} +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_thread, no_json)] struct ThreadMakeActive(String); #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = tcode_thread, no_json)] @@ -506,6 +365,10 @@ struct ThreadDelete(String); #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = tcode_project, no_json)] struct ProjectArchiveAll(String); +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = tcode_project, no_json)] +struct ProjectThreadRules(String); + #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = tcode_project, no_json)] struct ProjectDelete(String); @@ -564,6 +427,8 @@ struct CompactProjectRow { enum CompactListRow { Project(CompactProjectRow), Settled { key: String, count: usize }, + More { key: String, count: usize }, + Empty { key: String }, Thread(Rc), BottomInset, } @@ -573,33 +438,29 @@ impl CompactListRow { match self { Self::Project(row) => &row.row_id, Self::Settled { key, .. } => key, + Self::More { key, .. } => key, + Self::Empty { key } => key, Self::Thread(row) => &row.row_id, Self::BottomInset => "compact-bottom-inset", } } } +enum FlatListRow { + Thread(Box, f32), + Settled, + More(usize), + Empty, +} + /// 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 }, Thread(Box), - ToggleThreads { - group: usize, - expanded: bool, - label: SharedString, - }, - AutoArchived { - group: usize, - count: usize, - }, - Settled { - group: usize, - count: usize, - }, + More { group: usize, count: usize }, + Empty { group: usize }, + Settled { group: usize, count: usize }, } impl GroupedListRow { @@ -608,8 +469,8 @@ impl GroupedListRow { match self { Self::Project { group, .. } => ("project", project(group)), Self::Thread(meta) => ("thread", &meta.id), - Self::ToggleThreads { group, .. } => ("toggle", project(group)), - Self::AutoArchived { group, .. } => ("auto-archived", project(group)), + Self::More { group, .. } => ("more", project(group)), + Self::Empty { group } => ("empty", project(group)), Self::Settled { group, .. } => ("settled", project(group)), } } @@ -627,19 +488,15 @@ pub struct SessionsSidebar { store: Entity, window_state: Entity, /// Project ids whose thread list is expanded past the collapsed limit. - expanded_groups: HashSet, expanded_settled: HashSet, + settled_limits: HashMap, + lifecycle_undo: Option, last_selected: Option, /// Optional project id filter for the session-local flat list. project_filter: Option, + settled_scope: Option<(SidebarLayout, Option)>, /// The thread currently being renamed inline, if any. renaming: Option, - /// Last expansion sweep result, cleared on collapse or the next sweep. - auto_archive_notice: Option<(String, usize)>, - /// First-run explainer queued by the launch sweep (count, days, keep). - /// Opened from the first frame: the dialog needs the window's `Root`, - /// which does not exist yet while the sidebar is constructed. - startup_archive_dialog: Option<(usize, u32, usize)>, flat_list_state: ListState, grouped_list_state: ListState, /// The row keys `grouped_list_state` was last spliced for. @@ -662,6 +519,7 @@ struct ThreadRows<'a> { settled: Vec<&'a SessionMeta>, active_count: usize, settled_count: usize, + settled_hidden_count: usize, } fn session_flags(sessions: &[SessionMeta], store: &WorkspaceStore) -> HashMap { @@ -675,6 +533,7 @@ fn session_flags(sessions: &[SessionMeta], store: &WorkspaceStore) -> HashMap, ) -> ThreadRows<'a> { - let mut active = visible_threads(active, collapsed_parents); + let active = visible_threads(active, collapsed_parents); let active_count = active.len(); let settled_count = settled.len(); - if !self.expanded_groups.contains(project_id) { - active.truncate(THREADS_COLLAPSED_LIMIT); - } - let settled = if self.expanded_settled.contains(project_id) { - visible_threads(settled, collapsed_parents) - } else { - Vec::new() - }; + let mut settled = visible_threads(settled, collapsed_parents); + let eligible = settled.len(); + self.limit_settled_rows(project_id, &mut settled); + let settled_hidden_count = eligible - settled.len(); ThreadRows { active, settled, active_count, settled_count, + settled_hidden_count, } } @@ -759,26 +615,10 @@ impl SessionsSidebar { .into_iter() .map(|meta| GroupedListRow::Thread(Box::new(meta.clone()))), ); - let expanded = self.expanded_groups.contains(project_id); - if let Some(label) = thread_list_toggle_label(threads.active_count, expanded) { - rows.push(GroupedListRow::ToggleThreads { - group, - expanded, - label: label.into(), - }); - } - if expanded - && let Some((_, count)) = self - .auto_archive_notice - .as_ref() - .filter(|(notice_project, _)| notice_project == project_id) - { - rows.push(GroupedListRow::AutoArchived { - group, - count: *count, - }); - } if threads.settled_count > 0 { + if threads.active_count == 0 { + rows.push(GroupedListRow::Empty { group }); + } rows.push(GroupedListRow::Settled { group, count: threads.settled_count, @@ -789,6 +629,11 @@ impl SessionsSidebar { .into_iter() .map(|meta| GroupedListRow::Thread(Box::new(meta.clone()))), ); + if let Some(count) = + self.settled_more_count(project_id, threads.settled_hidden_count) + { + rows.push(GroupedListRow::More { group, count }); + } } } rows @@ -908,53 +753,16 @@ impl SessionsSidebar { last_destination = destination; }), ]; - // Launch sweep: the same auto-archive pass expanding a thread list - // runs, applied to every project up front so stale threads are gone - // before the first paint (and before the fold state is seeded below). - let project_ids = store.read(cx).project_ids(); - let mut sweeps = Vec::with_capacity(project_ids.len()); - for project_id in project_ids { - sweeps.push(store.update(cx, |store, cx| store.auto_archive_sweep(project_id, cx))); - } - cx.spawn(async move |sidebar, cx| { - let mut archived = 0; - for sweep in sweeps { - match sweep.await { - Ok(tcode_protocol::CommandResponse::ArchivedCount(count)) => { - archived += count; - } - Ok(other) => { - log::error!("unexpected auto-archive response: {other:?}"); - } - Err(error) => { - log::error!("startup auto-archive sweep failed: {}", error.message); - } - } - } - let _ = sidebar.update(cx, |sidebar, cx| { - let settings = sidebar.store.read(cx).settings(); - sidebar.startup_archive_dialog = - (archived > 0 && !settings.auto_archive_notice_shown).then(|| { - ( - archived, - settings.auto_archive_max_idle_days.max(1), - settings.auto_archive_keep_count.max(1), - ) - }); - cx.notify(); - }); - }) - .detach(); Self { store, window_state, - expanded_groups: HashSet::new(), expanded_settled: HashSet::new(), + settled_limits: HashMap::new(), + lifecycle_undo: None, last_selected: None, project_filter: None, + settled_scope: None, renaming: None, - auto_archive_notice: None, - startup_archive_dialog: None, flat_list_state: ListState::new(0, ListAlignment::Top, px(120.)), grouped_list_state: ListState::new(0, ListAlignment::Top, px(120.)), grouped_row_keys: Vec::new(), @@ -978,105 +786,12 @@ impl SessionsSidebar { } fn toggle_project(&mut self, project_id: &str, cx: &mut Context) { - if !self.store.read(cx).is_project_collapsed(project_id) { - self.expanded_groups.remove(project_id); - } self.store.update(cx, |store, cx| { store.toggle_project_collapsed(project_id.to_string(), cx); }); cx.notify(); } - fn toggle_group(&mut self, project_id: &str, window: &mut Window, cx: &mut Context) { - if self.expanded_groups.remove(project_id) { - if self - .auto_archive_notice - .as_ref() - .is_some_and(|(notice_project, _)| notice_project == project_id) - { - self.auto_archive_notice = None; - } - } else { - self.auto_archive_notice = None; - let (notice_shown, days, keep) = { - let settings = self.store.read(cx).settings(); - ( - settings.auto_archive_notice_shown, - settings.auto_archive_max_idle_days.max(1), - settings.auto_archive_keep_count.max(1), - ) - }; - let sweep = self.store.update(cx, |store, cx| { - store.auto_archive_sweep(project_id.to_string(), cx) - }); - self.expanded_groups.insert(project_id.to_string()); - let project_id = project_id.to_string(); - cx.spawn_in(window, async move |sidebar, cx| { - let count = match sweep.await { - Ok(tcode_protocol::CommandResponse::ArchivedCount(count)) => count, - Ok(other) => { - log::error!("unexpected auto-archive response: {other:?}"); - return; - } - Err(error) => { - log::error!("auto-archive sweep failed: {}", error.message); - return; - } - }; - let _ = sidebar.update_in(cx, |sidebar, window, cx| { - if count > 0 { - sidebar.auto_archive_notice = Some((project_id, count)); - if !notice_shown { - sidebar.show_auto_archive_dialog(count, days, keep, window, cx); - } - } - cx.notify(); - }); - }) - .detach(); - } - cx.notify(); - } - - fn show_auto_archive_dialog( - &self, - count: usize, - days: u32, - keep: usize, - window: &mut Window, - cx: &mut Context, - ) { - self.store.update(cx, |store, _cx| { - store.set_auto_archive_notice_shown(true); - }); - let window_state = self.window_state.clone(); - window.open_alert_dialog(cx, move |alert, _, cx| { - let alert = alert.bg(cx.theme().popover); - let window_state = window_state.clone(); - alert - .title(crate::tr!("sidebar.auto_archive_dialog.title")) - .description(crate::tr!( - "sidebar.auto_archive_dialog.body", - count = count, - days = days, - keep = keep - )) - .button_props( - DialogButtons::default() - .ok_text(crate::tr!("sidebar.auto_archive_dialog.open_settings")) - .cancel_text(crate::tr!("sidebar.auto_archive_dialog.got_it")) - .show_cancel(true), - ) - .on_ok(move |_, _, cx| { - window_state.update(cx, |state, cx| { - state.pending_settings_section = Some("archived".into()); - state.open_settings(cx); - }); - true - }) - }); - } - fn on_filter_project( &mut self, action: &FilterProject, @@ -1084,6 +799,9 @@ impl SessionsSidebar { cx: &mut Context, ) { self.project_filter = (!action.0.is_empty()).then(|| action.0.clone()); + self.expanded_settled.clear(); + self.settled_limits.clear(); + self.compact_model_dirty = true; self.window_state .update(cx, |state, cx| state.leave_route_for_chat(cx)); cx.notify(); @@ -1285,9 +1003,174 @@ impl SessionsSidebar { self.prompt_export(&action.0, ThreadExportFormat::Markdown, window, cx); } - fn on_settle(&mut self, action: &ThreadSettle, _: &mut Window, cx: &mut Context) { - self.store - .update(cx, |store, _| store.settle_session(action.0.clone())); + fn on_auto_settle( + &mut self, + action: &ThreadAutoSettle, + _: &mut Window, + cx: &mut Context, + ) { + self.store.update(cx, |store, _| { + store.set_auto_settle(action.0.clone(), action.1) + }); + } + + fn on_settle(&mut self, action: &ThreadSettle, window: &mut Window, cx: &mut Context) { + self.perform_lifecycle( + Command::SettleSession { + session_id: action.0.clone(), + }, + Command::UnsettleSession { + session_id: action.0.clone(), + }, + window, + cx, + ); + } + + fn perform_lifecycle( + &mut self, + command: Command, + reverse: Command, + window: &mut Window, + cx: &mut Context, + ) { + let reopen = match &command { + 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); + }); + } + 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) + }); + } + }) + .detach(); + } + + fn push_lifecycle_undo( + &mut self, + command: Command, + reopen: Option, + 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 + .lifecycle_undo + .as_ref() + .is_none_or(|undo| undo.kind != kind) + { + self.lifecycle_undo = Some(LifecycleUndo { + kind, + entries: vec![], + }); + } + self.lifecycle_undo + .as_mut() + .unwrap() + .entries + .push(UndoEntry { command, reopen }); + 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::Archive, 1) => crate::tr!("sidebar.undo_archived_one"), + (UndoKind::Settle, _) => crate::tr!("sidebar.undo_settled", count = count), + (UndoKind::Archive, _) => crate::tr!("sidebar.undo_archived", count = count), + } + .into_owned(); + let weak = cx.entity().downgrade(); + window.push_notification( + Notification::new() + .id::() + .message(title) + .action(move |_, window, cx| { + let weak = weak.clone(); + let button = Button::new("undo-thread-lifecycle") + .small() + .label(crate::tr!("sidebar.undo")) + .on_click(move |_, window, cx| { + let _ = weak.update(cx, |this, cx| this.undo_lifecycle(window, cx)); + }); + // The phone pill has no keyboard to hint at. + if crate::window_seam::window_is_compact(window, cx) { + button.ghost() + } else { + button.outline().when_some( + crate::widgets::kbd::Kbd::binding_for_action( + &ThreadUndo, + Some("TcodeShell"), + window, + ), + |button, kbd| button.child(kbd), + ) + } + }) + .autohide(true), + cx, + ); + } + + pub(crate) fn undo_lifecycle(&mut self, window: &mut Window, cx: &mut Context) { + if !window.has_notification::(cx) { + self.lifecycle_undo = None; + return; + } + let Some(undo) = self.lifecycle_undo.take() else { + return; + }; + window.remove_notification::(cx); + 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; + } + if let Some(id) = entry.reopen { + store.update(cx, |store, _| store.select_session(id)); + } + } + }) + .detach(); } fn on_make_active( @@ -1320,17 +1203,13 @@ impl SessionsSidebar { else { return; }; - if meta.settled_at.is_some() { - self.expanded_settled.insert("recent".into()); - if let Some(project_id) = &meta.project_id { - self.expanded_settled.insert(project_id.clone()); - self.expanded_groups.insert(project_id.clone()); - if self.store.read(cx).is_project_collapsed(project_id) { - self.store.update(cx, |store, cx| { - store.toggle_project_collapsed(project_id.clone(), cx) - }); - } - } + if meta.is_settled() + && let Some(project_id) = &meta.project_id + && self.store.read(cx).is_project_collapsed(project_id) + { + self.store.update(cx, |store, cx| { + store.toggle_project_collapsed(project_id.clone(), cx) + }); } // The selected thread's ancestors unfold on the host, so its row is on // screen on every client and stays so after a restore. @@ -1354,23 +1233,77 @@ impl SessionsSidebar { self.compact_model_dirty = true; } - fn render_settled_header( + fn limit_settled_rows(&self, key: &str, rows: &mut Vec<&SessionMeta>) { + let limit = if self.expanded_settled.contains(key) { + self.settled_limits.get(key).copied().unwrap_or(10) + } else { + 0 + }; + let selected = self.last_selected.as_deref(); + let mut index = 0; + rows.retain(|meta| { + let keep = index < limit || selected == Some(&meta.id); + index += 1; + keep + }); + } + + fn settled_more_count(&self, key: &str, hidden: usize) -> Option { + (self.expanded_settled.contains(key) && hidden > 0).then(|| hidden.min(25)) + } + + fn render_settled_more( &self, key: &str, count: usize, cx: &mut Context, ) -> gpui::AnyElement { - let expanded = self.expanded_settled.contains(key); - let key = key.to_string(); - crate::material::accessible_clickable( - h_flex(), - SharedString::from(format!("settled-{key}")), - Role::Button, - crate::tr!("sidebar.settled"), - cx, - ) - .aria_expanded(expanded) - .debug_selector({ + let key = key.to_owned(); + Button::new(SharedString::from(format!("settled-more-{key}"))) + .debug_selector({ + let key = key.clone(); + move || format!("settled-more-{key}") + }) + .ghost() + .icon(IconName::Plus) + .label(crate::tr!("sidebar.show_more_settled", count = count)) + .w_full() + .h(px(if self.compact(cx) { 44. } else { 34. })) + .on_click(cx.listener(move |this, _, _, cx| { + *this.settled_limits.entry(key.clone()).or_insert(10) += 25; + this.compact_model_dirty = true; + cx.notify(); + })) + .into_any_element() + } + + fn render_active_empty(&self, cx: &App) -> gpui::AnyElement { + div() + .px_2() + .py_3() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("sidebar.active_empty")) + .into_any_element() + } + + fn render_settled_header( + &self, + key: &str, + count: usize, + cx: &mut Context, + ) -> gpui::AnyElement { + let expanded = self.expanded_settled.contains(key); + let key = key.to_string(); + crate::material::accessible_clickable( + h_flex(), + SharedString::from(format!("settled-{key}")), + Role::Button, + crate::tr!("sidebar.settled"), + cx, + ) + .aria_expanded(expanded) + .debug_selector({ let key = key.clone(); move || format!("settled-{key}") }) @@ -1388,6 +1321,8 @@ impl SessionsSidebar { .on_click(cx.listener(move |this, _, _, cx| { if !this.expanded_settled.remove(&key) { this.expanded_settled.insert(key.clone()); + } else { + this.settled_limits.remove(&key); } this.compact_model_dirty = true; cx.notify(); @@ -1427,6 +1362,22 @@ impl SessionsSidebar { self.delete_thread(&id, &title, window, cx); } + fn on_project_thread_rules( + &mut self, + action: &ProjectThreadRules, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(project) = self.store.read(cx).project(&action.0).cloned() { + crate::settings_page::thread_behavior::open_project_rules( + self.store.clone(), + project, + window, + cx, + ); + } + } + fn on_project_archive_all( &mut self, action: &ProjectArchiveAll, @@ -1603,20 +1554,26 @@ impl SessionsSidebar { cx: &mut Context, ) { let store = self.store.clone(); + let sidebar = cx.entity().downgrade(); if store.read(cx).turn_running_for(session_id) { return; } let session_id = session_id.to_string(); if store.read(cx).settings().skip_delete_confirmation { - store.update(cx, |store, _cx| { - store.archive_session(session_id.clone()); - }); + self.perform_lifecycle( + Command::ArchiveSession { + session_id: session_id.clone(), + }, + Command::UnarchiveSession { session_id }, + window, + cx, + ); return; } let title = title.to_string(); window.open_alert_dialog(cx, move |alert, _, cx| { let alert = alert.bg(cx.theme().popover); - let store = store.clone(); + let sidebar = sidebar.clone(); let session_id = session_id.clone(); alert .title(crate::tr!("sidebar.archive_title")) @@ -1627,9 +1584,18 @@ impl SessionsSidebar { .cancel_text(crate::tr!("settings.cancel")) .show_cancel(true), ) - .on_ok(move |_, _, cx| { - store.update(cx, |store, _cx| { - store.archive_session(session_id.clone()); + .on_ok(move |_, window, cx| { + let _ = sidebar.update(cx, |this, cx| { + this.perform_lifecycle( + Command::ArchiveSession { + session_id: session_id.clone(), + }, + Command::UnarchiveSession { + session_id: session_id.clone(), + }, + window, + cx, + ) }); true }) @@ -2174,6 +2140,12 @@ impl SessionsSidebar { Box::new(ChangeProjectIcon(id.clone())), ) }) + .when(scope.is_full(), |menu| { + menu.menu( + crate::tr!("sidebar.project_thread_rules"), + Box::new(ProjectThreadRules(id.clone())), + ) + }) .menu_with_enable( crate::tr!("sidebar.archive_all").into_owned(), Box::new(ProjectArchiveAll(id.clone())), @@ -2223,62 +2195,10 @@ impl SessionsSidebar { cx, ) .into_any_element(), - GroupedListRow::ToggleThreads { - group, - expanded, - label, - } => { - let project_id = groups[*group].project.id.clone(); - let toggle_id = project_id.clone(); - crate::material::accessible_clickable( - div(), - gpui::SharedString::from(format!("show-more-{project_id}")), - Role::Button, - label.clone(), - cx, - ) - .aria_expanded(*expanded) - .debug_selector(move || format!("show-more-{project_id}")) - .pl(px(30.)) - .py_1() - .text_size(px(12.)) - .text_color(cx.theme().muted_foreground) - .cursor_pointer() - .hover(|s| s.text_color(cx.theme().sidebar_foreground)) - .on_click(cx.listener(move |this, _, window, cx| { - this.toggle_group(&toggle_id, window, cx); - })) - .child(label.clone()) - .into_any_element() - } - GroupedListRow::AutoArchived { group, count } => { - let label = crate::tr!("sidebar.auto_archived", count = *count); - let window_state = self.window_state.clone(); - crate::material::accessible_clickable( - div(), - gpui::SharedString::from(format!( - "auto-archived-{}", - groups[*group].project.id - )), - Role::Button, - label.clone(), - cx, - ) - .pl(px(30.)) - .py_1() - .text_size(px(12.)) - .text_color(cx.theme().muted_foreground) - .cursor_pointer() - .hover(|s| s.text_color(cx.theme().sidebar_foreground)) - .on_click(move |_, _, cx| { - window_state.update(cx, |state, cx| { - state.pending_settings_section = Some("archived".into()); - state.open_settings(cx); - }); - }) - .child(label) - .into_any_element() + GroupedListRow::More { group, count } => { + self.render_settled_more(&groups[*group].project.id, *count, cx) } + GroupedListRow::Empty { .. } => self.render_active_empty(cx), GroupedListRow::Settled { group, count } => { self.render_settled_header(&groups[*group].project.id, *count, cx) } @@ -2317,6 +2237,8 @@ impl SessionsSidebar { waiting_for_approval: own_flags.waiting_for_approval, waiting_for_input: own_flags.waiting_for_input, waiting: own_flags.waiting, + failed: own_flags.failed, + auto_settle_enabled: meta.auto_settle_disabled_at.is_none(), is_worktree: meta.worktree.is_some(), is_child: render_state.is_child, show_unread: render_state.show_unread, @@ -2455,10 +2377,15 @@ impl SessionsSidebar { truncated_sidebar_label() .text_size(px(13.)) .line_height(px(18.)) - .text_color(cx.theme().sidebar_foreground) - .when(emphasize_unread && state.show_unread, |title| { - title.font_semibold() + .text_color(if meta.is_settled() { + cx.theme().muted_foreground + } else { + cx.theme().sidebar_foreground }) + .when( + emphasize_unread && state.show_unread && !meta.is_settled(), + |title| title.font_semibold(), + ) .child(meta.title.clone()) .into_any_element() }; @@ -2486,11 +2413,13 @@ impl SessionsSidebar { if state.waiting_for_approval { Some((cx.theme().warning, crate::tr!("sidebar.waiting_approval"))) } else if state.waiting_for_input { - Some((cx.theme().warning, crate::tr!("sidebar.waiting_input"))) - } else if state.waiting { - Some((cx.theme().muted_foreground, crate::tr!("sidebar.waiting"))) + Some((cx.theme().primary, crate::tr!("sidebar.waiting_input"))) + } else if state.failed { + Some((cx.theme().danger, crate::tr!("sidebar.failed"))) } else if working { Some((cx.theme().primary, crate::tr!("sidebar.working"))) + } else if state.waiting { + Some((cx.theme().muted_foreground, crate::tr!("sidebar.waiting"))) } else { None } @@ -2504,6 +2433,10 @@ impl SessionsSidebar { let (color, label) = Self::thread_status_label(state, working, cx)?; Some( h_flex() + .id(SharedString::from(format!( + "thread-status-{}", + state.session_id + ))) .flex_none() .items_center() .gap_1() @@ -2516,6 +2449,11 @@ impl SessionsSidebar { .text_color(color) .child(label), ) + .when(state.failed && !state.waiting(), |badge| { + badge.tooltip(|window, cx| { + Tooltip::new(crate::tr!("sidebar.failed_tooltip")).build(window, cx) + }) + }) .into_any_element(), ) } @@ -2534,6 +2472,8 @@ impl SessionsSidebar { let can_fork = state.menu_can_fork; let is_worktree = state.is_worktree; let title_generating = state.title_generating; + let blocked = state.waiting_for_approval || state.waiting_for_input; + let auto_settle_enabled = state.auto_settle_enabled; row.context_menu(move |menu, _window, cx| { let id = session_id.clone(); menu.menu( @@ -2566,6 +2506,32 @@ impl SessionsSidebar { Box::new(ThreadMarkUnread(id.clone())), ) .separator() + .menu_with_enable( + if settled { + crate::tr!("sidebar.ctx_unsettle").into_owned() + } else { + crate::tr!("sidebar.ctx_settle").into_owned() + }, + if settled { + Box::new(ThreadMakeActive(id.clone())) as Box + } else { + Box::new(ThreadSettle(id.clone())) + }, + settled || (!running && !blocked), + ) + .separator() + .label(crate::tr!("sidebar.ctx_auto_settle")) + .menu_with_check( + crate::tr!("sidebar.ctx_auto_settle_enabled"), + auto_settle_enabled, + Box::new(ThreadAutoSettle(id.clone(), true)), + ) + .menu_with_check( + crate::tr!("sidebar.ctx_auto_settle_disabled"), + !auto_settle_enabled, + Box::new(ThreadAutoSettle(id.clone(), false)), + ) + .separator() .menu( crate::tr!("sidebar.ctx_copy_path").into_owned(), Box::new(ThreadCopyPath(id.clone())), @@ -2584,29 +2550,17 @@ impl SessionsSidebar { Box::new(ThreadExportMarkdown(id.clone())), ) .separator() - .menu_with_enable( - if settled { - crate::tr!("sidebar.make_active").into_owned() - } else { - crate::tr!("sidebar.settle").into_owned() - }, - if settled { - Box::new(ThreadMakeActive(id.clone())) as Box - } else { - Box::new(ThreadSettle(id.clone())) - }, - settled || !running, - ) .menu_with_enable( crate::tr!("sidebar.archive").into_owned(), Box::new(ThreadArchive(id.clone())), !running, ) .when(scope.is_full(), |menu| { - menu.menu( - crate::tr!("sidebar.ctx_delete").into_owned(), - Box::new(ThreadDelete(id.clone())), - ) + menu.menu_element(Box::new(ThreadDelete(id.clone())), |_, cx| { + div() + .text_color(cx.theme().danger) + .child(crate::tr!("sidebar.ctx_delete")) + }) }) .when_some(share.as_ref(), |menu, share| { spaces::share_items(menu, share, true, cx) @@ -2722,7 +2676,7 @@ impl SessionsSidebar { row, &state, working, - meta.settled_at.is_some(), + meta.is_settled(), false, share, self.store.read(cx).scope(), @@ -2733,14 +2687,28 @@ impl SessionsSidebar { &self, meta: &SessionMeta, row_key: &str, - waiting: bool, - archive_on_hover: bool, + _waiting: bool, + action_on_hover: bool, cx: &mut Context, ) -> impl IntoElement + use<> { let session_id = meta.id.clone(); - let archive_id = session_id.clone(); - let archive_title = meta.title.clone(); - let ago = humanize_ago(now_secs().saturating_sub(meta.updated_at)); + let action_id = session_id.clone(); + let settled = meta.is_settled(); + let timestamp = if settled { + tcode_core::thread_sort::settled_timestamp(meta) + } else { + meta.updated_at + }; + let ago = humanize_ago(now_secs().saturating_sub(timestamp)); + let action_on_hover = settled + || (action_on_hover + && !self.store.read(cx).pending_approval_for(&meta.id) + && !self.store.read(cx).pending_user_input_for(&meta.id)); + let label = if settled { + crate::tr!("sidebar.unsettle_tooltip") + } else { + crate::tr!("sidebar.settle_tooltip") + }; let row_key = row_key.to_string(); div() .relative() @@ -2753,50 +2721,46 @@ impl SessionsSidebar { .items_center() .whitespace_nowrap() .text_size(px(11.)) - .text_color(if waiting { - cx.theme().warning - } else { - cx.theme().muted_foreground - }) - .when(archive_on_hover, |time| { + .text_color(cx.theme().muted_foreground) + .when(action_on_hover, |time| { time.group_hover(row_key.clone(), |time| time.invisible()) }) .child(ago), ) - .when(archive_on_hover, |slot| { + .when(action_on_hover, |slot| { slot.child( - crate::material::accessible_clickable( - h_flex(), - gpui::SharedString::from(format!("archive-flat-thread-{session_id}")), - Role::Button, - crate::tr!("sidebar.archive"), - cx, + Button::new(SharedString::from(format!( + "settle-flat-thread-{session_id}" + ))) + .ghost() + .xsmall() + .icon( + Icon::new(if settled { + IconName::Undo2 + } else { + IconName::CircleCheck + }) + .text_color(cx.theme().muted_foreground), ) + .aria_label(label.clone()) + .tooltip(label) .absolute() .right_0() .top_0() - .size_5() - .items_center() - .justify_center() - .rounded(cx.theme().tokens.radius.sm) - .cursor_pointer() + // Opacity, not visibility, keeps the button a tab stop so + // keyboard focus can reveal it; a click's focus must not + // keep it over the time once the row moves. .opacity(0.) .group_hover(row_key, |button| button.opacity(1.)) - .focus(|button| button.opacity(1.).bg(cx.theme().sidebar_accent)) - .hover(|button| button.bg(cx.theme().sidebar_accent)) - .tooltip(|window, cx| { - Tooltip::new(crate::tr!("sidebar.archive").into_owned()).build(window, cx) - }) + .focus_visible(|button| button.opacity(1.)) .on_click(cx.listener(move |this, _, window, cx| { crate::widgets::stop_click_propagation(window, cx); - this.archive_thread(&archive_id, &archive_title, window, cx); - })) - .child( - Icon::empty() - .path("icons/archive.svg") - .xsmall() - .text_color(cx.theme().muted_foreground), - ), + if settled { + this.on_make_active(&ThreadMakeActive(action_id.clone()), window, cx); + } else { + this.on_settle(&ThreadSettle(action_id.clone()), window, cx); + } + })), ) }) } @@ -2992,7 +2956,7 @@ impl SessionsSidebar { row, &state, working, - meta.settled_at.is_some(), + meta.is_settled(), false, share, self.store.read(cx).scope(), @@ -3207,11 +3171,7 @@ impl SessionsSidebar { { let (groups, collapsed_projects, collapsed_parents, sessions, flags, layout) = { let store = self.store.read(cx); - let sessions = store - .sidebar_sessions() - .into_iter() - .filter(|meta| meta.archived_at.is_none()) - .collect::>(); + let sessions = store.flat_sessions(); let flags = session_flags(&sessions, store); let groups = store.grouped_sessions(); let collapsed = groups @@ -3255,7 +3215,12 @@ impl SessionsSidebar { label: crate::tr!("sidebar.thread", title = meta.title.clone()) .into_owned() .into(), - relative_time: humanize_ago(now.saturating_sub(meta.updated_at)).into(), + relative_time: humanize_ago(now.saturating_sub(if meta.is_settled() { + tcode_core::thread_sort::settled_timestamp(meta) + } else { + meta.updated_at + })) + .into(), children_id: format!("compact-children-{}", meta.id).into(), children_label: crate::tr!( "sidebar.child_threads", @@ -3278,13 +3243,26 @@ impl SessionsSidebar { let active = compact_visible_threads(&active, &collapsed_parents, project); let settled = compact_visible_threads(&settled, &collapsed_parents, project); let mut rows = thread_rows(active, recent); + if rows.is_empty() && !settled.is_empty() { + rows.push(CompactListRow::Empty { + key: format!("{key}-empty"), + }); + } if !settled.is_empty() { rows.push(CompactListRow::Settled { key: key.into(), count: settled.len(), }); - if self.expanded_settled.contains(key) { - rows.extend(thread_rows(settled, recent)); + let count = settled.len(); + let mut visible = settled; + self.limit_settled_rows(key, &mut visible); + let hidden = count - visible.len(); + rows.extend(thread_rows(visible, recent)); + if let Some(count) = self.settled_more_count(key, hidden) { + rows.push(CompactListRow::More { + key: format!("{key}-more"), + count, + }); } } rows @@ -3363,7 +3341,12 @@ impl SessionsSidebar { if let CompactListRow::Thread(row) = row { let row = Rc::make_mut(row); row.relative_time = - humanize_ago(now.saturating_sub(row.meta.updated_at)).into(); + humanize_ago(now.saturating_sub(if row.meta.is_settled() { + tcode_core::thread_sort::settled_timestamp(&row.meta) + } else { + row.meta.updated_at + })) + .into(); } } } @@ -3487,6 +3470,10 @@ impl SessionsSidebar { CompactListRow::Settled { key, count } => { this.render_settled_header(key, *count, cx) } + CompactListRow::More { key, count } => { + this.render_settled_more(key.trim_end_matches("-more"), *count, cx) + } + CompactListRow::Empty { .. } => this.render_active_empty(cx), CompactListRow::Project(row) => { this.render_compact_group_header(row, cx).into_any_element() } @@ -3547,6 +3534,7 @@ impl SessionsSidebar { .on_action(cx.listener(Self::on_export_jsonl)) .on_action(cx.listener(Self::on_export_markdown)) .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_archive)) .on_action(cx.listener(Self::on_delete)) @@ -3763,8 +3751,17 @@ impl SessionsSidebar { truncated_sidebar_label() .text_size(px(16.)) .line_height(px(21.)) - .when(!state.is_child, |title| title.font_medium()) - .when(state.show_unread, |title| title.font_semibold()) + .text_color(if meta.is_settled() { + cx.theme().muted_foreground + } else { + cx.theme().foreground + }) + .when(!state.is_child && !meta.is_settled(), |title| { + title.font_medium() + }) + .when(state.show_unread && !meta.is_settled(), |title| { + title.font_semibold() + }) .debug_selector({ let id = session_id.clone(); move || format!("compact-title-{id}") @@ -3857,7 +3854,7 @@ impl SessionsSidebar { row, state, working, - meta.settled_at.is_some(), + meta.is_settled(), true, share, self.store.read(cx).scope(), @@ -3922,8 +3919,18 @@ fn compact_status_glyph( ) .into_any_element(); } + if state.failed { + return slot + .justify_center() + .child( + Icon::new(IconName::TriangleAlert) + .size(px(16.)) + .text_color(cx.theme().danger), + ) + .into_any_element(); + } if working || state.waiting { - let color = if state.waiting { + let color = if !working && state.waiting { cx.theme().muted_foreground } else { cx.theme().primary @@ -3957,10 +3964,12 @@ fn compact_status_line( Some((crate::tr!("mobile.approval"), cx.theme().warning)) } else if state.waiting_for_input { Some((crate::tr!("mobile.answer"), cx.theme().primary)) - } else if state.waiting { - Some((crate::tr!("sidebar.waiting"), cx.theme().muted_foreground)) + } else if state.failed { + Some((crate::tr!("sidebar.failed"), cx.theme().danger)) } else if working { Some((crate::tr!("mobile.working"), cx.theme().primary)) + } else if state.waiting { + Some((crate::tr!("sidebar.waiting"), cx.theme().muted_foreground)) } else if state.show_unread { Some((crate::tr!("mobile.unread"), cx.theme().primary)) } else { @@ -3970,19 +3979,23 @@ fn compact_status_line( impl Render for SessionsSidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let scope = ( + self.store.read(cx).sidebar_layout(), + self.project_filter.clone(), + ); + if self.settled_scope.as_ref() != Some(&scope) { + self.settled_scope = Some(scope); + self.expanded_settled.clear(); + self.settled_limits.clear(); + self.compact_model_dirty = true; + } self.reveal_selected_settled(cx); let spaces = spaces::for_store(&self.store, cx); self.spaces_observer.watch(spaces.as_ref(), cx); if self.compact(cx) { return self.render_compact(window, cx); } - if let Some((count, days, keep)) = self.startup_archive_dialog.take() { - // Deferred: opening a dialog walks the window `Root`, which is an - // ancestor of this view and still borrowed during render. - cx.defer_in(window, move |this, window, cx| { - this.show_auto_archive_dialog(count, days, keep, window, cx); - }); - } + let ( layout, active_id, @@ -4100,6 +4113,7 @@ impl Render for SessionsSidebar { active: visible, settled: settled_visible, settled_count, + settled_hidden_count, .. } = self.flat_thread_rows(&active, &settled, &flags, &collapsed_parents); if visible.is_empty() && settled_count == 0 { @@ -4152,19 +4166,25 @@ impl Render for SessionsSidebar { .into_iter() .cloned() .zip(top_offsets) - .map(Some) + .map(|(meta, offset)| FlatListRow::Thread(Box::new(meta), offset)) .collect::>(); if settled_count > 0 { - visible.push(None); + if visible.is_empty() { + visible.push(FlatListRow::Empty); + } + visible.push(FlatListRow::Settled); let offsets = flat_thread_top_offsets(&settled_visible, &flat_sessions); visible.extend( settled_visible .into_iter() .cloned() .zip(offsets.into_iter().map(|offset| offset + settled_top)) - .map(Some), + .map(|(meta, offset)| FlatListRow::Thread(Box::new(meta), offset)), ); } + if let Some(count) = self.settled_more_count("recent", settled_hidden_count) { + visible.push(FlatListRow::More(count)); + } if self.flat_list_state.item_count() != visible.len() { self.flat_list_state.reset(visible.len()); } @@ -4173,40 +4193,49 @@ impl Render for SessionsSidebar { .map(|project| (project.id, project.name)) .collect::>(); let active_id = active_id.clone(); - let thread_list = - list( - self.flat_list_state.clone(), - cx.processor(move |this, index: usize, _window, cx| { - let Some(row) = visible.get(index) else { - return div().into_any_element(); - }; - let Some((meta, target_top)) = row else { + let thread_list = list( + self.flat_list_state.clone(), + cx.processor(move |this, index: usize, _window, cx| { + let Some(row) = visible.get(index) else { + return div().into_any_element(); + }; + let (meta, target_top) = match row { + FlatListRow::Thread(meta, top) => (meta, top), + FlatListRow::Settled => { return this.render_settled_header("recent", settled_count, cx); - }; - let target_top = *target_top; - 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()); - let row = div().w_full().px_2().pb(px(2.)).child( - this.render_flat_thread( + } + FlatListRow::More(count) => { + return this.render_settled_more("recent", *count, cx); + } + FlatListRow::Empty => return this.render_active_empty(cx), + }; + let target_top = *target_top; + 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()); + let row = + div() + .w_full() + .px_2() + .pb(px(2.)) + .child(this.render_flat_thread( meta, &flat_sessions, &flags, project_name, is_active, cx, - ), - ); - animate_flat_thread_position(row, &meta.id, target_top) - .into_any_element() - }), - ) - .flex_1() - .min_h_0() - .into_any_element(); + )); + animate_flat_thread_position(row, &meta.id, target_top) + .into_any_element() + }), + ) + .flex_1() + .min_h_0() + .into_any_element(); ( self.render_flat_header(cx).into_any_element(), v_flex() @@ -4245,10 +4274,12 @@ impl Render for SessionsSidebar { .on_action(cx.listener(Self::on_export_jsonl)) .on_action(cx.listener(Self::on_export_markdown)) .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_archive)) .on_action(cx.listener(Self::on_delete)) .on_action(cx.listener(Self::on_project_archive_all)) + .on_action(cx.listener(Self::on_project_thread_rules)) .on_action(cx.listener(Self::on_project_delete)) .on_action(cx.listener(Self::on_change_project_icon)) .on_action(cx.listener(Self::on_project_reveal)) @@ -4440,11 +4471,12 @@ mod tests { activity: HashMap::from([( "background".into(), SessionActivity { - working: true, + working: !waiting, turn_running: !waiting, waiting, waiting_for_approval: false, waiting_for_input: false, + failed: false, unread: false, fork: ForkAvailability::Available, }, @@ -4518,758 +4550,74 @@ mod tests { assert_eq!(label, expected); assert_eq!( color, - if waiting { - cx.theme().muted_foreground - } else { - cx.theme().primary - } - ); - }); - } - } - - #[gpui::test] - fn thread_navigation_matches_displayed_rows_and_disclosures(cx: &mut TestAppContext) { - use tcode_protocol::{ - EventEnvelope, HostMessage, IndexSnapshot, ServerEvent, Topic, encode_line, - }; - cx.update(crate::theme::init); - // Row springs run on the wall clock, which the test executor does not - // advance; the displayed order is read once they have settled. - 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("/project")); - project.id = "project".into(); - let mut active = session("active", None); - active.project_id = Some(project.id.clone()); - let mut active_child = session("active-child", Some("active")); - active_child.project_id = Some(project.id.clone()); - let mut settled = session("settled", Some("active")); - settled.project_id = Some(project.id.clone()); - settled.settled_at = Some(1); - let send = |topic, event| { - incoming - .try_send( - encode_line(&HostMessage::Event(EventEnvelope { - request_id: None, - topic, - event, - })) - .unwrap(), - ) - .unwrap() - }; - send( - Topic::Index, - ServerEvent::IndexSnapshot(IndexSnapshot { - summary: Default::default(), - sessions: vec![active.clone(), active_child.clone(), settled.clone()], - projects: vec![project.clone()], - }), - ); - let deferred = std::iter::from_fn(|| from_host.try_recv().ok()).collect(); - let link = tcode_client::HostLink::new(to_host, from_host); - let pump_link = link.clone(); - let executor = cx.background_executor.clone(); - let _pump = cx.background_executor.spawn(async move { - pump_link - .pump_with_timer(|| executor.timer(std::time::Duration::from_millis(25))) - .await; - }); - let store = cx.new(|cx| { - WorkspaceStore::new_attached( - link, - crate::store::WorkspaceAttachment::Local, - None, - None, - false, - cx, - ) - }); - crate::store::tests::seed_full_scope(&store, &incoming, deferred, cx); - let window_state = cx.new(|_| WindowState::new(false)); - let (sidebar, cx) = cx - .add_window_view(|_, cx| SessionsSidebar::new(store.clone(), window_state.clone(), cx)); - let cx: &mut VisualTestContext = cx; - cx.simulate_resize(size(px(360.), px(1000.))); - for settled_parent in [false, true] { - for compact in [false, true] { - for layout in [SidebarLayout::Flat, SidebarLayout::Grouped] { - let mut parent = active.clone(); - parent.settled_at = settled_parent.then_some(1); - send( - Topic::Index, - ServerEvent::IndexSnapshot(IndexSnapshot { - summary: Default::default(), - sessions: vec![parent, active_child.clone(), settled.clone()], - projects: vec![project.clone()], - }), - ); - let settings = tcode_core::settings::Settings { - sidebar_layout: layout, - collapsed_threads: vec!["active".into()], - auto_archive_disabled: true, - ..Default::default() - }; - send(Topic::Settings, ServerEvent::SettingsSnapshot(settings)); - // Keep this a selected-thread fixture: applying Index to - // an empty workspace would request an unrelated draft from - // the fake host. Apply its queued metadata before drawing, - // so reveal reads this case rather than the previous one. - store.update(cx, |store, _| store.select_session("active".into())); - cx.run_until_parked(); - store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); - assert_eq!( - store.read_with(cx, |store, _| { - store - .sidebar_sessions() - .iter() - .find(|meta| meta.id == "active") - .unwrap() - .settled_at - }), - settled_parent.then_some(1), - "applied fixture: ancestor={settled_parent}, compact={compact}, layout={layout:?}" - ); - window_state.update(cx, |state, _| state.compact = compact); - sidebar.update(cx, |sidebar, cx| { - sidebar.expanded_settled.clear(); - sidebar.compact_model_dirty = true; - cx.notify(); - }); - draw(cx); - store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); - draw(cx); - assert!( - !store.read_with(cx, |store, _| store.threads_loading()), - "thread index and settings ready" - ); - assert_eq!( - store.read_with(cx, |store, _| store.sidebar_sessions().len()), - 3 - ); - let key = if layout == SidebarLayout::Flat { - "settled-recent" - } else { - "settled-project" - }; - assert!( - cx.debug_bounds(key).is_some(), - "settled header, compact={compact}, layout={layout:?}" - ); - let row = if compact { - "compact-row-settled" - } else { - "sidebar-thread-settled" - }; - if settled_parent && cx.debug_bounds(row).is_some() { - // This case proves settled-ancestor partitioning. An - // Index replacement need not trigger a new selection - // reveal, so start it collapsed via the real disclosure. - let header = cx.debug_bounds(key).unwrap(); - cx.simulate_click(header.center(), gpui::Modifiers::default()); - draw(cx); - } - assert!( - cx.debug_bounds(row).is_none(), - "settled group collapsed: ancestor={settled_parent}, compact={compact}, layout={layout:?}" - ); - assert_eq!( - sidebar.update(cx, |sidebar, cx| sidebar.navigation_threads(cx)), - ["active"] - ); - let header = cx.debug_bounds(key).unwrap(); - cx.simulate_click(header.center(), gpui::Modifiers::default()); - draw(cx); - assert!( - cx.debug_bounds(row).is_some(), - "expansion exposes settled thread" - ); - assert_eq!( - sidebar.update(cx, |sidebar, cx| sidebar.navigation_threads(cx)), - ["active", "settled"] - ); - let header = cx.debug_bounds(key).unwrap(); - cx.simulate_click(header.center(), gpui::Modifiers::default()); - draw(cx); - assert!(cx.debug_bounds(row).is_none()); - store.update(cx, |store, _| store.select_session("settled".into())); - sidebar.update(cx, |_, cx| cx.notify()); - draw(cx); - assert!( - cx.debug_bounds(row).is_some(), - "navigation expands settled group" - ); - assert!(store.read_with(cx, |store, _| { - store - .sidebar_sessions() - .iter() - .find(|meta| meta.id == "settled") - .unwrap() - .settled_at - .is_some() - })); - // A settled ancestor is kept by its active descendant. The - // separately settled sibling is visible even while its parent - // is folded; opening that parent also restores its active child. - send( - Topic::Settings, - ServerEvent::SettingsSnapshot(tcode_core::settings::Settings { - sidebar_layout: layout, - auto_archive_disabled: true, - ..Default::default() - }), - ); - draw(cx); - store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); - draw(cx); - assert_eq!( - sidebar.update(cx, |sidebar, cx| sidebar.navigation_threads(cx)), - ["active", "active-child", "settled"] - ); - let child_row = if compact { - "compact-row-active-child" - } else { - "sidebar-thread-active-child" - }; - assert!(cx.debug_bounds(child_row).is_some()); - } - } - } - - // Exercise desktop filtering and the grouped six-row disclosure against - // actual rendered positions, independently of the shortcut row helpers. - let ids = [ - "one", "two", "three", "four", "five", "six", "seven", "child", - ]; - let sessions = ids - .iter() - .enumerate() - .rev() - .map(|(index, id)| { - let parent = if *id == "child" { Some("one") } else { None }; - let mut meta = session(id, parent); - meta.project_id = Some("project".into()); - meta.updated_at = 100 - index as u64; - meta - }) - .collect(); - send( - Topic::Index, - ServerEvent::IndexSnapshot(IndexSnapshot { - summary: Default::default(), - sessions, - projects: store.read_with(cx, |store, _| store.projects()), - }), - ); - window_state.update(cx, |state, _| state.compact = false); - store.update(cx, |store, _| store.select_session("one".into())); - let cases = [ - (SidebarLayout::Grouped, false, false, true, None, &ids[..6]), - (SidebarLayout::Grouped, true, false, true, None, &ids[..7]), - (SidebarLayout::Grouped, true, true, true, None, &[]), - ( - SidebarLayout::Flat, - false, - false, - true, - Some("missing"), - &[], - ), - ( - SidebarLayout::Flat, - false, - false, - true, - Some("project"), - &ids[..7], - ), - ( - SidebarLayout::Flat, - false, - false, - false, - Some("project"), - &[ - "one", "child", "two", "three", "four", "five", "six", "seven", - ], - ), - ]; - for (layout, expanded, collapsed, children_collapsed, filter, expected) in cases { - send( - Topic::Settings, - ServerEvent::SettingsSnapshot(tcode_core::settings::Settings { - sidebar_layout: layout, - collapsed_projects: if collapsed { - vec!["project".into()] - } else { - Vec::new() - }, - collapsed_threads: if children_collapsed { - vec!["one".into()] - } else { - Vec::new() - }, - auto_archive_disabled: true, - ..Default::default() - }), - ); - sidebar.update(cx, |sidebar, cx| { - sidebar.expanded_groups.clear(); - if expanded { - sidebar.expanded_groups.insert("project".into()); - } - sidebar.project_filter = filter.map(str::to_string); - cx.notify(); - }); - draw(cx); - store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); - draw(cx); - cx.executor() - .advance_clock(std::time::Duration::from_secs(1)); - draw(cx); - let mut rendered = ids - .iter() - .zip([ - "sidebar-thread-one", - "sidebar-thread-two", - "sidebar-thread-three", - "sidebar-thread-four", - "sidebar-thread-five", - "sidebar-thread-six", - "sidebar-thread-seven", - "sidebar-thread-child", - ]) - .filter_map(|(id, selector)| { - cx.debug_bounds(selector).map(|bounds| (*id, bounds.top())) - }) - .collect::>(); - rendered.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); - assert_eq!( - rendered.iter().map(|(id, _)| *id).collect::>(), - expected, - "displayed rows for {layout:?}" - ); - assert_eq!( - sidebar.update(cx, |sidebar, cx| sidebar.navigation_threads(cx)), - expected, - "shortcut order for {layout:?}" - ); - } - - let make_sessions = |entries: &[(&str, Option<&str>, u64)]| { - entries - .iter() - .map(|(id, parent, updated)| { - let mut meta = session(id, *parent); - meta.project_id = Some("project".into()); - meta.updated_at = *updated; - meta - }) - .collect::>() - }; - let budget = make_sessions(&[ - ("parent", None, 100), - ("child-0", Some("parent"), 99), - ("child-1", Some("parent"), 98), - ("child-2", Some("parent"), 97), - ("child-3", Some("parent"), 96), - ("child-4", Some("parent"), 95), - ("child-5", Some("parent"), 94), - ("child-6", Some("parent"), 93), - ("thread-0", None, 80), - ("thread-1", None, 79), - ("thread-2", None, 78), - ("thread-3", None, 77), - ("thread-4", None, 76), - ]); - let attention = make_sessions(&[ - ("working-root", None, 40), - ("waiting-root", None, 10), - ("waiting-child", Some("waiting-root"), 11), - ("idle-old", None, 20), - ("idle-new", None, 30), - ]); - let lifted = make_sessions(&[ - ("working-root", None, 100), - ("lifted-root", None, 1), - ("lifted-child", Some("lifted-root"), 2), - ]); - for (layout, sessions, activity, folded, expected, show_more) in [ - ( - SidebarLayout::Grouped, - budget.clone(), - HashMap::new(), - true, - vec![ - "parent", "thread-0", "thread-1", "thread-2", "thread-3", "thread-4", - ], - false, - ), - ( - SidebarLayout::Grouped, - budget, - HashMap::new(), - false, - vec![ - "parent", "child-0", "child-1", "child-2", "child-3", "child-4", "child-5", - "child-6", "thread-0", "thread-1", "thread-2", "thread-3", "thread-4", - ], - true, - ), - ( - SidebarLayout::Flat, - attention, - HashMap::from([ - ( - "waiting-root".to_string(), - tcode_protocol::SessionActivity { - working: false, - turn_running: false, - waiting: false, - waiting_for_approval: true, - waiting_for_input: false, - unread: false, - fork: ForkAvailability::Available, - }, - ), - ( - "working-root".to_string(), - tcode_protocol::SessionActivity { - working: true, - turn_running: true, - waiting: false, - waiting_for_approval: false, - waiting_for_input: false, - unread: false, - fork: ForkAvailability::Available, - }, - ), - ]), - false, - vec![ - "waiting-root", - "waiting-child", - "working-root", - "idle-new", - "idle-old", - ], - false, - ), - ( - SidebarLayout::Flat, - lifted, - HashMap::from([ - ( - "lifted-child".to_string(), - tcode_protocol::SessionActivity { - working: false, - turn_running: false, - waiting: false, - waiting_for_approval: false, - waiting_for_input: true, - unread: false, - fork: ForkAvailability::Available, - }, - ), - ( - "working-root".to_string(), - tcode_protocol::SessionActivity { - working: true, - turn_running: true, - waiting: false, - waiting_for_approval: false, - waiting_for_input: false, - unread: false, - fork: ForkAvailability::Available, - }, - ), - ]), - false, - vec!["lifted-root", "lifted-child", "working-root"], - false, - ), - ] { - let selectors = sessions - .iter() - .map(|meta| { - let selector: &'static str = - Box::leak(format!("sidebar-thread-{}", meta.id).into_boxed_str()); - (meta.id.clone(), selector) - }) - .collect::>(); - send( - Topic::Index, - ServerEvent::IndexSnapshot(IndexSnapshot { - summary: tcode_protocol::IndexSummary { - activity, - ..Default::default() - }, - sessions, - projects: store.read_with(cx, |store, _| store.projects()), - }), - ); - send( - Topic::Settings, - ServerEvent::SettingsSnapshot(tcode_core::settings::Settings { - sidebar_layout: layout, - collapsed_threads: if folded { - vec!["parent".into()] - } else { - vec![] - }, - auto_archive_disabled: true, - ..Default::default() - }), - ); - sidebar.update(cx, |sidebar, cx| { - sidebar.project_filter = None; - sidebar.expanded_groups.clear(); - if !folded { - sidebar.expanded_groups.insert("project".into()); - } - cx.notify(); - }); - draw(cx); - store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); - draw(cx); - let mut rendered = selectors - .iter() - .filter_map(|(id, selector)| { - cx.debug_bounds(selector) - .map(|bounds| (id.as_str(), bounds.top())) - }) - .collect::>(); - rendered.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); - assert_eq!( - rendered.iter().map(|(id, _)| *id).collect::>(), - expected - ); - assert_eq!( - sidebar.update(cx, |sidebar, cx| sidebar.navigation_threads(cx)), - expected - ); - if layout == SidebarLayout::Grouped { - assert_eq!( - cx.debug_bounds("show-more-project").is_some(), - show_more, - "hidden children must not consume the six visible-row slots" - ); - } - } - } - - #[gpui::test] - fn working_thread_title_stays_inside_row_at_every_sidebar_width(cx: &mut TestAppContext) { - let (_, cx) = cx.add_window_view(|_, _| WorkingThreadRowProbe); - let cx: &mut VisualTestContext = cx; - - // The resizable sidebar is constrained to 220..=380px. Half-pixel - // increments cover Retina resize boundaries where glyph rounding used - // to push the final character onto a second line. - for half_pixel_width in 440..=760 { - let width = half_pixel_width as f32 / 2.; - cx.simulate_resize(size(px(width), px(60.))); - draw(cx); - - let row = cx.debug_bounds("thread-row").expect("row bounds"); - let title = cx.debug_bounds("thread-title").expect("title bounds"); - assert!( - title.top() >= row.top() && title.bottom() <= row.bottom(), - "title escaped the row vertically at {width}px: row={row:?}, title={title:?}" - ); - assert!( - title.left() >= row.left() && title.right() <= row.right(), - "title escaped the row horizontally at {width}px: row={row:?}, title={title:?}" - ); - } - } - - #[gpui::test] - fn flat_rows_start_reordering_from_their_previous_positions(cx: &mut TestAppContext) { - let (probe, cx) = cx.add_window_view(|_, _| FlatReorderAnimationProbe { - reversed: false, - list_state: ListState::new(2, ListAlignment::Top, px(0.)), - }); - let cx: &mut VisualTestContext = cx; - cx.simulate_resize(size(px(200.), px(200.))); - draw(cx); - - let first_start = cx.debug_bounds("flat-reorder-row-first").unwrap().top(); - let second_start = cx.debug_bounds("flat-reorder-row-second").unwrap().top(); - assert_eq!(second_start - first_start, px(FLAT_ROOT_ROW_HEIGHT)); - - probe.update(cx, |probe, cx| { - probe.reversed = true; - cx.notify(); - }); - draw(cx); - assert_eq!( - cx.debug_bounds("flat-reorder-row-first").unwrap().top(), - first_start, - "first row snapped to its destination instead of starting at its old position" - ); - assert_eq!( - cx.debug_bounds("flat-reorder-row-second").unwrap().top(), - second_start, - "second row snapped to its destination instead of starting at its old position" - ); - - let callbacks = cx.update(|window, cx| window.simulate_next_frame(cx)); - assert!(callbacks > 0, "spring did not request an animation frame"); - } - - #[gpui::test] - fn project_header_resets_only_its_own_thread_expansion(cx: &mut TestAppContext) { - cx.update(crate::theme::init); - let root = std::env::temp_dir().join(format!( - "tcode-project-collapse-{}", - tcode_services::store::now_millis() - )); - let host = spawn_host( - SessionStore::open_at(root.clone()).unwrap(), - HostServices::default(), - ) - .unwrap(); - let projects = ["a", "b"].map(|id| { - let mut project = Project::from_root(root.join(id)); - project.id = id.into(); - project - }); - let ids = projects.each_ref().map(|project| project.id.clone()); - smol::block_on(host.update_state_for_test(move |state, _| { - state.settings.sidebar_layout = SidebarLayout::Grouped; - state.settings.auto_archive_disabled = true; - state.settings.collapsed_threads = vec!["a-0".into(), "b-0".into()]; - for project in &projects { - for index in 0..8 { - let parent = format!("{}-0", project.id); - let mut meta = session( - &format!("{}-{index}", project.id), - (index == 7).then_some(parent.as_str()), - ); - meta.project_id = Some(project.id.clone()); - state.sessions.push(meta); - } - } - state.projects = projects.to_vec(); - })) - .unwrap(); - let store = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); - let window_state = cx.new(|_| WindowState::new(false)); - let (sidebar, cx) = cx - .add_window_view(|_, cx| SessionsSidebar::new(store.clone(), window_state.clone(), cx)); - let cx: &mut VisualTestContext = cx; - cx.simulate_resize(size(px(320.), px(1400.))); - draw(cx); - let folds = store.read_with(cx, |store, _| store.collapsed_threads()); - assert_eq!(folds.len(), 2); - for compact in [false, true] { - // Expand both lists through their production controls before testing - // either layout's project-header action. - window_state.update(cx, |state, _| state.compact = false); - sidebar.update(cx, |_, cx| cx.notify()); - draw(cx); - for id in &ids { - if !sidebar.read_with(cx, |sidebar, _| sidebar.expanded_groups.contains(id)) { - let toggle = cx - .debug_bounds(if id == "a" { - "show-more-a" - } else { - "show-more-b" - }) - .unwrap(); - cx.simulate_click(toggle.center(), gpui::Modifiers::default()); - draw(cx); - } - } - window_state.update(cx, |state, _| state.compact = compact); - sidebar.update(cx, |_, cx| cx.notify()); - draw(cx); - let selector = if compact { - "compact-group-header" - } else { - "project-header-a" - }; - let header = cx.debug_bounds(selector).unwrap(); - cx.simulate_click(header.center(), gpui::Modifiers::default()); - draw(cx); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - let collapsed_id = loop { - store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); - draw(cx); - if let Some(id) = ids - .iter() - .find(|id| store.read_with(cx, |store, _| store.is_project_collapsed(id))) - { - break id.clone(); - } - assert!( - std::time::Instant::now() < deadline, - "folder collapse reaches replica" - ); - std::thread::sleep(std::time::Duration::from_millis(5)); - }; - sidebar.read_with(cx, |sidebar, _| { - assert!( - !sidebar.expanded_groups.contains(&collapsed_id), - "collapsing a folder must reset its expanded thread list" - ); - assert!( - ids.iter() - .filter(|id| **id != collapsed_id) - .all(|id| sidebar.expanded_groups.contains(id)), - "other project expansions survive" + if waiting { + cx.theme().muted_foreground + } else { + cx.theme().primary + } ); }); - assert_eq!( - store.read_with(cx, |store, _| store.collapsed_threads()), - folds, - "child folds survive" + } + } + + #[gpui::test] + fn working_thread_title_stays_inside_row_at_every_sidebar_width(cx: &mut TestAppContext) { + let (_, cx) = cx.add_window_view(|_, _| WorkingThreadRowProbe); + let cx: &mut VisualTestContext = cx; + + // The resizable sidebar is constrained to 220..=380px. Half-pixel + // increments cover Retina resize boundaries where glyph rounding used + // to push the final character onto a second line. + for half_pixel_width in 440..=760 { + let width = half_pixel_width as f32 / 2.; + cx.simulate_resize(size(px(width), px(60.))); + draw(cx); + + let row = cx.debug_bounds("thread-row").expect("row bounds"); + let title = cx.debug_bounds("thread-title").expect("title bounds"); + assert!( + title.top() >= row.top() && title.bottom() <= row.bottom(), + "title escaped the row vertically at {width}px: row={row:?}, title={title:?}" + ); + assert!( + title.left() >= row.left() && title.right() <= row.right(), + "title escaped the row horizontally at {width}px: row={row:?}, title={title:?}" ); - let header = cx.debug_bounds(selector).unwrap(); - cx.simulate_click(header.center(), gpui::Modifiers::default()); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); - draw(cx); - if !store.read_with(cx, |store, _| store.is_project_collapsed(&collapsed_id)) { - break; - } - assert!(std::time::Instant::now() < deadline); - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(!sidebar.read_with(cx, |sidebar, _| { - sidebar.expanded_groups.contains(&collapsed_id) - })); } - window_state.update(cx, |state, _| state.compact = false); - sidebar.update(cx, |_, cx| cx.notify()); - draw(cx); - // The other project remains expanded; its direct Show less control - // still collapses the list without folding the project or its children. - let expanded_id = sidebar.read_with(cx, |sidebar, _| { - sidebar.expanded_groups.iter().next().unwrap().clone() + } + + #[gpui::test] + fn flat_rows_start_reordering_from_their_previous_positions(cx: &mut TestAppContext) { + let (probe, cx) = cx.add_window_view(|_, _| FlatReorderAnimationProbe { + reversed: false, + list_state: ListState::new(2, ListAlignment::Top, px(0.)), }); - let selector = if expanded_id == "a" { - "show-more-a" - } else { - "show-more-b" - }; - let toggle = cx.debug_bounds(selector).unwrap(); - cx.simulate_click(toggle.center(), gpui::Modifiers::default()); + let cx: &mut VisualTestContext = cx; + cx.simulate_resize(size(px(200.), px(200.))); draw(cx); - sidebar.read_with(cx, |sidebar, _| { - assert!(!sidebar.expanded_groups.contains(&expanded_id)); + + let first_start = cx.debug_bounds("flat-reorder-row-first").unwrap().top(); + let second_start = cx.debug_bounds("flat-reorder-row-second").unwrap().top(); + assert_eq!(second_start - first_start, px(FLAT_ROOT_ROW_HEIGHT)); + + probe.update(cx, |probe, cx| { + probe.reversed = true; + cx.notify(); }); + draw(cx); + assert_eq!( + cx.debug_bounds("flat-reorder-row-first").unwrap().top(), + first_start, + "first row snapped to its destination instead of starting at its old position" + ); assert_eq!( - store.read_with(cx, |store, _| store.collapsed_threads()), - folds + cx.debug_bounds("flat-reorder-row-second").unwrap().top(), + second_start, + "second row snapped to its destination instead of starting at its old position" ); - assert!(!store.read_with(cx, |store, _| store.is_project_collapsed(&expanded_id))); - host.shutdown_blocking().unwrap(); - let _ = std::fs::remove_dir_all(root); + + let callbacks = cx.update(|window, cx| window.simulate_next_frame(cx)); + assert!(callbacks > 0, "spring did not request an animation frame"); } #[gpui::test] @@ -5352,7 +4700,6 @@ mod tests { claude.provider = ProviderKind::ClaudeCode; claude.project_id = Some(project.id.clone()); smol::block_on(host.update_state_for_test(move |state, _| { - state.settings.auto_archive_disabled = true; state.projects = vec![project]; state.sessions = vec![codex, claude]; })) @@ -5479,6 +4826,7 @@ mod tests { let mut meta = session(&format!("menu-{index}"), None); meta.project_id = Some(project.id.clone()); meta.updated_at = 1000 - index; + meta.created_at = meta.updated_at; meta }) .collect(); @@ -5523,11 +4871,12 @@ mod tests { let menu = cx .debug_bounds("tcode-popup-menu") .expect("long press opens menu"); - assert!(!menu.contains(&b.center())); + let outside = gpui::point(b.left() + px(12.), b.center().y); + assert!(!menu.contains(&outside)); let selected = store.read_with(cx, |store, _| store.active_session_id()); let destination = navigation.read_with(cx, |state, _| state.destination()); - send(cx, 2, TouchPhase::Started, b.center()); - send(cx, 2, TouchPhase::Ended, b.center()); + send(cx, 2, TouchPhase::Started, outside); + send(cx, 2, TouchPhase::Ended, outside); draw(cx); assert!(cx.debug_bounds("tcode-popup-menu").is_none()); assert_eq!( @@ -5587,6 +4936,7 @@ mod tests { let mut meta = session(&format!("thread-{index}"), None); meta.project_id = Some(project.id.clone()); meta.updated_at = 100 + index as u64; + meta.created_at = meta.updated_at; meta }) .collect::>(); @@ -5631,7 +4981,7 @@ mod tests { assert!( cx.debug_bounds("compact-row-thread-1").unwrap().top() < cx.debug_bounds("compact-row-thread-0").unwrap().top(), - "last activity orders threads across projects" + "creation orders unarranged threads across projects" ); } let toggle = cx.debug_bounds("compact-layout-toggle").unwrap(); @@ -5830,6 +5180,7 @@ mod tests { let mut meta = session(&format!("virtual-{index}"), None); meta.project_id = Some(project.id.clone()); meta.updated_at = 1_000 - index; + meta.created_at = meta.updated_at; meta }) .collect(); @@ -5971,6 +5322,7 @@ mod tests { let mut meta = session(&format!("virtual-{index}"), None); meta.project_id = Some(project.id.clone()); meta.updated_at = 1_000 - index; + meta.created_at = meta.updated_at; meta }) .collect::>(); @@ -5991,7 +5343,6 @@ mod tests { Topic::Settings, ServerEvent::SettingsSnapshot(tcode_core::settings::Settings { sidebar_layout: SidebarLayout::Grouped, - auto_archive_disabled: true, ..Default::default() }), ); @@ -6023,8 +5374,7 @@ mod tests { cx.run_until_parked(); store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); cx.simulate_resize(size(px(300.), px(800.))); - sidebar.update(cx, |sidebar, cx| { - sidebar.expanded_groups.insert("project".into()); + sidebar.update(cx, |_, cx| { cx.notify(); }); draw(cx); @@ -6055,6 +5405,7 @@ mod tests { let mut newer = session("virtual-new", None); newer.project_id = Some(project.id.clone()); newer.updated_at = 2_000; + newer.created_at = 2_000; sessions.insert(0, newer); let apply = |sessions: &Vec, cx: &mut VisualTestContext| { send(Topic::Index, snapshot(sessions)); @@ -6147,6 +5498,7 @@ mod tests { let mut meta = session(&format!("{}-{index}", project.id), None); meta.project_id = Some(project.id.clone()); meta.updated_at = 10_000 - index; + meta.created_at = meta.updated_at; meta }) }) @@ -6154,7 +5506,6 @@ mod tests { let settings = tcode_core::settings::Settings { sidebar_layout: SidebarLayout::Grouped, project_sort: tcode_core::settings::ProjectSort::NameAsc, - auto_archive_disabled: true, ..Default::default() }; for (topic, event) in [ @@ -6249,7 +5600,7 @@ mod tests { cx.debug_bounds("compact-thread-list").is_none(), "the thread page covers the list" ); - // Activity in the open thread moves it to the head of its project. + // An admission that reopens a settled thread moves it to the unarranged head. let active = store.read_with(cx, |store, _| store.active_session_id().unwrap()); let mut sessions = store.read_with(cx, |store, _| store.sidebar_sessions()); let latest = sessions.iter().map(|meta| meta.updated_at).max().unwrap(); @@ -6257,7 +5608,7 @@ mod tests { .iter_mut() .find(|meta| meta.id == active) .unwrap() - .updated_at = latest + 1; + .unsettled_at = Some(latest + 1); sessions.sort_by_key(|meta| std::cmp::Reverse(meta.updated_at)); incoming .try_send( @@ -6432,7 +5783,7 @@ mod tests { } #[gpui::test] - fn compact_families_keep_activity_order_indent_and_collapse(cx: &mut TestAppContext) { + fn compact_children_keep_indent_and_shared_collapse(cx: &mut TestAppContext) { use tcode_protocol::{ EventEnvelope, HostMessage, IndexSnapshot, ServerEvent, Topic, encode_line, }; @@ -6453,6 +5804,15 @@ mod tests { let mut meta = session(id, parent); meta.project_id = Some(project.id.clone()); meta.updated_at = activity; + meta.created_at = match id { + "parent" => 100, + "running-child" => 90, + "older-child" => 80, + "other" => 70, + "orphan" => 60, + "missing-parent-child" => 50, + _ => 1, + }; if id == "archived" { meta.archived_at = Some(1); } @@ -6486,6 +5846,7 @@ mod tests { waiting: false, waiting_for_approval: false, waiting_for_input: false, + failed: false, unread: false, fork: tcode_protocol::ForkAvailability::Available, }, @@ -6547,7 +5908,7 @@ mod tests { parent.top() < running.top() && running.top() < older.top() && older.top() < other.top(), - "{layout:?}: families sort by maximum activity, children by their activity" + "{layout:?}: the static fixture exposes parent and child rows before the unrelated thread" ); assert_eq!( cx.debug_bounds("compact-title-running-child") @@ -6732,65 +6093,6 @@ mod tests { } } - #[gpui::test] - fn launch_runs_the_auto_archive_sweep_across_every_project(cx: &mut TestAppContext) { - let root = std::env::temp_dir().join(format!( - "tcode-sidebar-launch-sweep-test-{}", - tcode_services::store::now_millis() - )); - let session_store = SessionStore::open_at(root.clone()).unwrap(); - let host = spawn_host(session_store, HostServices::default()) - .expect("spawn auto-archive test host"); - - let project_a = Project::from_root(root.join("a")); - let project_b = Project::from_root(root.join("b")); - let mut sessions = Vec::new(); - for (project, prefix) in [(&project_a, "a"), (&project_b, "b")] { - for i in 0..3u64 { - let mut meta = session(&format!("{prefix}-{i}"), None); - meta.project_id = Some(project.id.clone()); - // Ancient timestamps, newest last, so each project keeps - // exactly its `keep_count = 1` most recent thread. - meta.updated_at = 1 + i; - sessions.push(meta); - } - } - smol::block_on(host.update_state_for_test(move |state, _| { - state.settings.auto_archive_keep_count = 1; - state.settings.auto_archive_max_idle_days = 1; - state.projects = vec![project_a, project_b]; - state.sessions = sessions; - })) - .expect("seed auto-archive host"); - let store = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); - - let window_state = cx.new(|_| WindowState::new(false)); - let sidebar = cx.new(|cx| SessionsSidebar::new(store, window_state.clone(), cx)); - cx.run_until_parked(); - - let archived = smol::block_on(host.update_state_for_test(|state, _| { - let mut archived: Vec<&str> = state - .sessions - .iter() - .filter(|meta| meta.archived_at.is_some()) - .map(|meta| meta.id.as_str()) - .collect(); - archived.sort_unstable(); - archived.into_iter().map(str::to_string).collect::>() - })) - .expect("read archived sessions"); - assert_eq!(archived, vec!["a-0", "a-1", "b-0", "b-1"]); - sidebar.update(cx, |sidebar, _| { - assert_eq!( - sidebar.startup_archive_dialog, - Some((4, 1, 1)), - "first launch queues the explainer dialog with the total count" - ); - }); - - let _ = std::fs::remove_dir_all(root); - } - /// While this machine hosts, a project's menu shares it into a space and, /// chosen again, takes it back out; the header carries the shared mark /// exactly while the project is in a space. @@ -6895,4 +6197,243 @@ mod tests { }); let _ = std::fs::remove_dir_all(root); } + #[gpui::test] + fn settled_shelf_pages_without_expanding_for_selection_and_navigation_matches_rows( + cx: &mut TestAppContext, + ) { + use tcode_protocol::{ + EventEnvelope, HostMessage, IndexSnapshot, ServerEvent, Topic, 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 send = |topic, event| { + incoming + .try_send( + encode_line(&HostMessage::Event(EventEnvelope { + request_id: None, + topic, + event, + })) + .unwrap(), + ) + .unwrap(); + }; + let mut project = Project::from_root(PathBuf::from("/sample")); + project.id = "sample".into(); + let sessions = (0..8) + .map(|index| { + let mut meta = session(&format!("active-{index}"), None); + meta.project_id = Some(project.id.clone()); + meta.created_at = 100 - index; + meta + }) + .chain((0..40).map(|index| { + let mut meta = session(&format!("settled-{index}"), None); + meta.project_id = Some(project.id.clone()); + meta.settled_at = Some(100 - index); + meta + })) + .collect(); + send( + Topic::Settings, + ServerEvent::SettingsSnapshot(Default::default()), + ); + send( + Topic::Index, + ServerEvent::IndexSnapshot(IndexSnapshot { + sessions, + projects: vec![project, Project::from_root(PathBuf::from("/other-sample"))], + summary: Default::default(), + }), + ); + let deferred = std::iter::from_fn(|| from_host.try_recv().ok()).collect(); + let link = tcode_client::HostLink::new(to_host, from_host); + let pump_link = link.clone(); + let executor = cx.background_executor.clone(); + let _pump = cx.background_executor.spawn(async move { + pump_link + .pump_with_timer(|| executor.timer(std::time::Duration::from_millis(25))) + .await; + }); + let store = cx.new(|cx| { + WorkspaceStore::new_attached( + link, + 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("settled-39".into())); + let window_state = cx.new(|_| WindowState::new(false)); + let (sidebar, cx) = cx + .add_window_view(|_, cx| SessionsSidebar::new(store.clone(), window_state.clone(), cx)); + cx.simulate_resize(size(px(393.), px(6000.))); + for compact in [false, true] { + window_state.update(cx, |state, cx| { + state.compact = compact; + cx.notify(); + }); + for layout in [SidebarLayout::Flat, SidebarLayout::Grouped] { + send( + Topic::Settings, + ServerEvent::SettingsSnapshot(tcode_core::settings::Settings { + sidebar_layout: layout, + ..Default::default() + }), + ); + cx.run_until_parked(); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + sidebar.update(cx, |sidebar, cx| { + sidebar.expanded_settled.clear(); + sidebar.settled_limits.clear(); + sidebar.compact_model_dirty = true; + cx.notify(); + }); + draw(cx); + let key = if layout == SidebarLayout::Flat { + "recent" + } else { + "sample" + }; + let selector = |id: &str| -> &'static str { + if compact { + format!("compact-row-{id}") + } else { + format!("sidebar-thread-{id}") + } + .leak() + }; + let expected = |limit: usize| { + (0..8) + .map(|index| format!("active-{index}")) + .chain((0..limit).map(|index| format!("settled-{index}"))) + .chain(std::iter::once("settled-39".into())) + .collect::>() + }; + let verify = |cx: &mut VisualTestContext, limit| { + let ids = sidebar.update(cx, |sidebar, cx| sidebar.navigation_threads(cx)); + assert_eq!(ids, expected(limit), "{compact:?} {layout:?}"); + let mut previous = None; + for id in ids { + let bounds = cx + .debug_bounds(selector(&id)) + .unwrap_or_else(|| panic!("missing visible row {id}")); + if let Some(previous) = previous { + assert!( + bounds.top() > previous, + "row {id}: top {:?} <= previous {:?}; compact={compact} layout={layout:?} limit={limit}", + bounds.top(), + previous + ); + } + previous = Some(bounds.top()); + } + }; + verify(cx, 0); + assert!(cx.debug_bounds(selector("settled-0")).is_none()); + let header = cx + .debug_bounds(if key == "recent" { + "settled-recent" + } else { + "settled-sample" + }) + .unwrap(); + cx.simulate_click(header.center(), gpui::Modifiers::default()); + draw(cx); + verify(cx, 10); + assert!(cx.debug_bounds(selector("settled-10")).is_none()); + let more = cx + .debug_bounds(if key == "recent" { + "settled-more-recent" + } else { + "settled-more-sample" + }) + .unwrap(); + cx.simulate_click(more.center(), gpui::Modifiers::default()); + draw(cx); + verify(cx, 35); + assert!(cx.debug_bounds(selector("settled-35")).is_none()); + let header = cx + .debug_bounds(if key == "recent" { + "settled-recent" + } else { + "settled-sample" + }) + .unwrap(); + cx.simulate_click(header.center(), gpui::Modifiers::default()); + draw(cx); + verify(cx, 0); + let header = cx + .debug_bounds(if key == "recent" { + "settled-recent" + } else { + "settled-sample" + }) + .unwrap(); + cx.simulate_click(header.center(), gpui::Modifiers::default()); + draw(cx); + verify(cx, 10); + if layout == SidebarLayout::Grouped { + send( + Topic::Settings, + ServerEvent::SettingsSnapshot(tcode_core::settings::Settings { + sidebar_layout: layout, + collapsed_projects: vec!["sample".into()], + ..Default::default() + }), + ); + cx.run_until_parked(); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + } + sidebar.update(cx, |sidebar, cx| { + if !compact && layout == SidebarLayout::Flat { + sidebar.project_filter = Some("missing-project".into()); + } + sidebar.compact_model_dirty = true; + cx.notify(); + }); + draw(cx); + // Compact has its existing project-only scope; only desktop Flat + // exposes the filter. Grouped collapse applies on both surfaces. + if !compact || layout == SidebarLayout::Grouped { + assert!( + sidebar + .update(cx, |sidebar, cx| sidebar.navigation_threads(cx)) + .is_empty() + ); + } + if layout == SidebarLayout::Grouped { + send( + Topic::Settings, + ServerEvent::SettingsSnapshot(tcode_core::settings::Settings { + sidebar_layout: layout, + ..Default::default() + }), + ); + cx.run_until_parked(); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + } + sidebar.update(cx, |sidebar, cx| { + sidebar.project_filter = None; + sidebar.compact_model_dirty = true; + cx.notify(); + }); + draw(cx); + if !compact && layout == SidebarLayout::Flat { + verify(cx, 0); + let header = cx.debug_bounds("settled-recent").unwrap(); + cx.simulate_click(header.center(), gpui::Modifiers::default()); + draw(cx); + } + verify(cx, 10); + } + } + } } diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index 788a98ae5..b895f3984 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -45,7 +45,6 @@ impl WorkspaceStore { | Command::ToggleFavoriteModel { .. } | Command::ToggleProjectCollapsed { .. } | Command::SetThreadCollapsed { .. } - | Command::AutoArchiveSweep { .. } | Command::PreviewReply { .. } | Command::ReloadProvider | Command::SetProfileSecret { .. } @@ -154,7 +153,7 @@ impl WorkspaceStore { } } - pub(super) fn command( + pub(crate) fn command( &self, command: Command, cx: &mut App, @@ -254,18 +253,6 @@ impl WorkspaceStore { pub fn set_fallback_review_advisor(&mut self, value: bool) { self.patch_settings(SettingsPatch::FallbackReviewAdvisor(value)); } - pub fn set_auto_archive_disabled(&mut self, value: bool) { - self.patch_settings(SettingsPatch::AutoArchiveDisabled(value)); - } - pub fn set_auto_archive_max_idle_days(&mut self, value: u32) { - self.patch_settings(SettingsPatch::AutoArchiveMaxIdleDays(value)); - } - pub fn set_auto_archive_keep_count(&mut self, value: usize) { - self.patch_settings(SettingsPatch::AutoArchiveKeepCount(value)); - } - pub fn set_auto_archive_notice_shown(&mut self, value: bool) { - self.patch_settings(SettingsPatch::AutoArchiveNoticeShown(value)); - } pub fn set_orchestrate_decision_models(&mut self, value: Vec) { self.patch_settings(SettingsPatch::OrchestrateDecisionModels(value)); } @@ -348,8 +335,24 @@ impl WorkspaceStore { pub fn settle_session(&mut self, session_id: String) { self.dispatch(Command::SettleSession { session_id }); } + pub fn set_auto_settle(&mut self, session_id: String, enabled: bool) { + self.dispatch(Command::SetAutoSettle { + session_id, + enabled, + }); + } + pub fn set_auto_settle_after_days(&mut self, days: Option) { + self.patch_settings(SettingsPatch::AutoSettleAfterDays(days)); + } + pub fn set_project_settlement( + &mut self, + project_id: String, + value: Option, + ) { + self.patch_settings(SettingsPatch::ProjectSettlement { project_id, value }); + } pub fn make_session_active(&mut self, session_id: String) { - self.dispatch(Command::MakeSessionActive { session_id }); + self.dispatch(Command::UnsettleSession { session_id }); } pub fn archive_session(&mut self, session_id: String) { self.dispatch(Command::ArchiveSession { session_id }); @@ -357,13 +360,7 @@ impl WorkspaceStore { pub fn unarchive_session(&mut self, session_id: String) { self.dispatch(Command::UnarchiveSession { session_id }); } - pub fn auto_archive_sweep( - &self, - project_id: String, - cx: &mut App, - ) -> Task> { - self.command(Command::AutoArchiveSweep { project_id }, cx) - } + pub fn rename_session(&mut self, session_id: String, title: String) { self.dispatch(Command::RenameSession { session_id, title }); } diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 328e568bc..fe66c4c24 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -11,7 +11,6 @@ use tcode_core::{ git::{GitFileEntry, MenuItem, QuickAction, menu_items, quick_action}, project::{ Project, ProjectGroup, SessionMeta, WorktreeInfo, descendant_session_ids, group_sessions, - order_sessions_with_children, }, provider_models::{ResolvedModel, picker_models, resolve_models}, provider_status::ProviderSnapshot, @@ -2009,11 +2008,15 @@ impl WorkspaceStore { .filter(|meta| meta.archived_at.is_none()) .cloned() .collect(); - group_sessions( + let mut groups = group_sessions( &self.index_replica.1, &visible, self.settings_replica.project_sort, - ) + ); + for group in &mut groups { + tcode_core::thread_sort::sort_threads(&mut group.sessions); + } + groups } pub fn settings(&self) -> Settings { @@ -2245,14 +2248,15 @@ impl WorkspaceStore { } pub fn flat_sessions(&self) -> Vec { - let visible = self + let mut visible: Vec<_> = self .index_replica .0 .iter() .filter(|meta| meta.archived_at.is_none()) .cloned() .collect(); - order_sessions_with_children(visible) + tcode_core::thread_sort::sort_threads(&mut visible); + visible } pub(crate) fn project(&self, id: &str) -> Option<&Project> { @@ -2297,6 +2301,13 @@ impl WorkspaceStore { .is_some_and(|activity| activity.working) } + pub fn failed_for(&self, session_id: &str) -> bool { + self.index_summary + .activity + .get(session_id) + .is_some_and(|activity| activity.failed) + } + pub fn waiting_for(&self, session_id: &str) -> bool { self.index_summary .activity @@ -4907,6 +4918,7 @@ pub(crate) mod tests { from: 10, end: 20, records: vec![StoredEvent { + origin: None, author: None, ts: Some(1), event: tool.clone(), @@ -4990,6 +5002,7 @@ pub(crate) mod tests { }); crate::store::tests::seed_full_scope(&workspace, &incoming, Vec::new(), cx); let message = |id: &str, author: Option| StoredEvent { + origin: None, author, ts: Some(1), event: agent::AgentEvent::ItemCompleted(agent::ThreadItem { @@ -5358,6 +5371,7 @@ pub(crate) mod tests { fn recorded(ts: u64, event: AgentEvent) -> SessionEventRecord { SessionEventRecord { + origin: None, author: None, ts: Some(ts), ..event.into() diff --git a/crates/ui/src/store/snapshots.rs b/crates/ui/src/store/snapshots.rs index 03347b7ab..144f11b18 100644 --- a/crates/ui/src/store/snapshots.rs +++ b/crates/ui/src/store/snapshots.rs @@ -234,6 +234,7 @@ mod tests { waiting: false, waiting_for_approval: false, waiting_for_input: false, + failed: false, unread: false, fork: tcode_protocol::ForkAvailability::Available, }, diff --git a/crates/ui/src/widgets/menu.rs b/crates/ui/src/widgets/menu.rs index 5e5a8f516..462d9d61a 100644 --- a/crates/ui/src/widgets/menu.rs +++ b/crates/ui/src/widgets/menu.rs @@ -8,6 +8,7 @@ use gpui::{ Role, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled, Subscription, Window, deferred, div, prelude::FluentBuilder, px, }; +use gpui_base::StyledExt as _; use gpui_base::actions::{Cancel, Confirm, SelectDown, SelectUp}; use serde::Deserialize; @@ -63,6 +64,7 @@ pub fn init(cx: &mut App) { enum MenuItem { Separator, + Label(SharedString), Item { label: Option, render: Option, @@ -150,6 +152,10 @@ impl PopupMenu { }); self } + pub fn label(mut self, text: impl Into) -> Self { + self.items.push(MenuItem::Label(text.into())); + self + } pub fn separator(mut self) -> Self { if !self.items.is_empty() && !matches!(self.items.last(), Some(MenuItem::Separator)) { self.items.push(MenuItem::Separator); @@ -322,6 +328,17 @@ impl Render for PopupMenu { MenuItem::Separator => { items = items.child(div().h(px(1.)).mx_1().my_1().bg(cx.theme().border)) } + MenuItem::Label(label) => { + items = items.child( + div() + .px_2() + .py_1() + .text_size(px(11.)) + .font_medium() + .text_color(cx.theme().muted_foreground) + .child(label.clone()), + ); + } MenuItem::Item { label, render, diff --git a/crates/ui/src/window_state.rs b/crates/ui/src/window_state.rs index 148f75887..a7a281fe0 100644 --- a/crates/ui/src/window_state.rs +++ b/crates/ui/src/window_state.rs @@ -26,6 +26,7 @@ pub enum Destination { /// One settings section's detail. Which section it is belongs to the page; /// *that a detail is open* is navigation and belongs here. SettingsSection, + SettingsThreadRules, } /// The shell's client-local checkpoint, independent of the host's settings. @@ -70,7 +71,10 @@ impl NavigationSnapshot { snapshot.history.retain(|destination| { !matches!( destination, - Destination::Settings | Destination::SettingsSection | Destination::Pair + Destination::Settings + | Destination::SettingsSection + | Destination::SettingsThreadRules + | Destination::Pair ) }); if snapshot.history.first() != Some(&Destination::Hosts) { @@ -109,7 +113,7 @@ impl Destination { pub fn route(self) -> Route { match self { Self::Hosts | Self::Pair => Route::Hosts, - Self::Settings | Self::SettingsSection => Route::Settings, + Self::Settings | Self::SettingsSection | Self::SettingsThreadRules => Route::Settings, Self::Threads | Self::Thread | Self::Panel => Route::Chat, } } @@ -125,7 +129,7 @@ impl Destination { Self::Threads => "mobile.threads", Self::Thread => "mobile.thread", Self::Panel => "chat.panels", - Self::Settings | Self::SettingsSection => "settings.title", + Self::Settings | Self::SettingsSection | Self::SettingsThreadRules => "settings.title", }) .into_owned() .into() diff --git a/locales/en.yml b/locales/en.yml index e4eb9860c..98a055254 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -118,6 +118,24 @@ markdown: image_copy_address: "Copy image address" mermaid_diagram: "Mermaid diagram" sidebar: + show_more_settled: "Show %{count} more" + active_empty: "Nothing active. Settled threads are below." + failed: "Failed" + failed_tooltip: "The last turn failed" + ctx_settle: "Settle thread" + ctx_unsettle: "Un-settle thread" + ctx_auto_settle: "Auto-settle behavior" + ctx_auto_settle_enabled: "Enabled" + ctx_auto_settle_disabled: "Disabled" + settle_tooltip: "Settle thread" + unsettle_tooltip: "Un-settle thread" + settle_refused: "This thread has work in progress and can't be settled yet." + undo_failed: "Could not undo: %{reason}" + undo_settled_one: "Thread settled" + undo_settled: "%{count} threads settled" + undo_archived_one: "Thread archived" + undo_archived: "%{count} threads archived" + project_thread_rules: "Thread rules…" parent_unavailable: "(parent unavailable)" collapse: "Collapse sidebar" expand: "Expand sidebar" @@ -169,14 +187,6 @@ sidebar: import_summary: "Imported %{imported}, skipped %{skipped}" import_ok: "OK" create_thread: "Create new thread" - show_more: "Show more" - show_less: "Show less" - auto_archived: "Auto-archived %{count} inactive threads — view" - auto_archive_dialog: - title: "Threads auto-archived" - body: "Tcode auto-archived %{count} threads idle for over %{days} days and beyond your %{keep} most recent. Restore them anytime in Settings → Archived Threads, or turn auto-archive off in Settings." - got_it: "Got it" - open_settings: "Open Settings" working: "Working" waiting: "Waiting" child_threads: "%{count} child threads" @@ -184,9 +194,8 @@ sidebar: waiting_approval_tooltip: "Waiting for approval" waiting_input: "Question" waiting_input_tooltip: "Waiting for your answer" + undo: "Undo" settled: "Settled" - settle: "Settle" - make_active: "Make active" archive: "Archive thread" archive_title: "Archive thread?" archive_description: 'Archive "%{title}"? Its agent stops, and the thread moves to Settings → Archived Threads, where you can restore it.' @@ -243,6 +252,26 @@ migration: quitting: "Stopping. Nothing is lost; the upgrade continues the next time Tcode starts." failed: "The upgrade could not finish: %{reason}" settings: + threads_section: "THREADS" + thread_behavior: "Thread behavior" + auto_settle: + title: "Auto-settle inactive threads" + description: "Threads with no activity for this many days move to Settled. New activity brings them back." + days: "Days before auto-settle" + days_description: "From 1 to 90 days." + footnote: "Changing these never reopens threads that are already settled." + overrides: "Project overrides" + overrides_description: "Projects listed here use their own auto-settle rules." + add_override: "Add project…" + edit: "Edit" + remove_override: "Remove override" + dialog_title: "Thread rules for \"%{project}\"" + use_default: "Use default (%{value})" + never: "Never" + after_days: "After a number of days" + days_value: "%{count} days" + days_value_one: "1 day" + save: "Save" source_control: "Source Control" title: "Settings" sections: "All settings" @@ -266,14 +295,6 @@ settings: archived_empty_desc: "Archived threads will appear here." archived_at: "Archived %{when}" archived_created: "Created %{when}" - auto_archive: - section: "AUTO-ARCHIVE" - title: "Auto-archive old threads" - description: "Automatically archive threads idle for over %{days} days and beyond the %{keep} most recent. Working, unread, and open threads are never archived." - idle_days: "Idle days" - idle_days_description: "Archive only after this many full days without activity." - keep_count: "Keep most recent" - keep_count_description: "Always keep this many newest sibling threads." unarchive: "Unarchive" delete_permanently: "Delete permanently" archived_range: "%{from}–%{to} of %{count}" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 976b119e8..fff956c54 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -118,6 +118,24 @@ markdown: image_copy_address: "复制图片地址" mermaid_diagram: "Mermaid 图表" sidebar: + failed: "失败" + show_more_settled: "再显示 %{count} 个" + active_empty: "没有进行中的对话。已收尾的对话在下方。" + failed_tooltip: "上一轮运行失败" + ctx_settle: "收尾对话" + ctx_unsettle: "取消收尾" + ctx_auto_settle: "自动收尾" + ctx_auto_settle_enabled: "启用" + ctx_auto_settle_disabled: "停用" + settle_tooltip: "收尾对话" + unsettle_tooltip: "取消收尾" + settle_refused: "该对话仍有进行中的工作,暂时无法收尾。" + undo_failed: "无法撤销:%{reason}" + undo_settled_one: "对话已收尾" + undo_settled: "已收尾 %{count} 个对话" + undo_archived_one: "对话已归档" + undo_archived: "已归档 %{count} 个对话" + project_thread_rules: "对话规则…" parent_unavailable: "(父对话不可用)" collapse: "折叠侧边栏" expand: "展开侧边栏" @@ -169,14 +187,6 @@ sidebar: import_summary: "已导入 %{imported} 条,跳过 %{skipped} 条" import_ok: "确定" create_thread: "新建对话" - show_more: "显示更多" - show_less: "收起" - auto_archived: "已自动归档 %{count} 个不活跃对话 — 查看" - auto_archive_dialog: - title: "对话已自动归档" - body: "Tcode 已自动归档 %{count} 个超过 %{days} 天未活动且排在最近 %{keep} 条之外的对话。你可以在 设置 → 已归档对话 中恢复它们,或在设置中关闭自动归档。" - got_it: "知道了" - open_settings: "打开设置" working: "工作中" waiting: "等待中" child_threads: "%{count} 个子对话" @@ -184,9 +194,8 @@ sidebar: waiting_approval_tooltip: "正在等待审批" waiting_input: "待回答" waiting_input_tooltip: "正在等待你的回答" - settled: "已完成" - settle: "标记为已完成" - make_active: "恢复为活跃" + undo: "撤销" + settled: "已收尾" archive: "归档对话" archive_title: "归档对话?" archive_description: "要归档“%{title}”吗?它的智能体会停止运行,对话会移到“设置 → 已归档对话”,可随时在那里恢复。" @@ -243,6 +252,26 @@ migration: quitting: "正在停止。数据不会丢失,下次启动 Tcode 时会继续升级。" failed: "升级未能完成:%{reason}" settings: + threads_section: "对话" + thread_behavior: "对话行为" + auto_settle: + title: "自动收尾不活跃的对话" + description: "超过设定天数没有活动的对话会移入“已收尾”。有新活动时会自动回来。" + days: "自动收尾前的天数" + days_description: "1 到 90 天。" + footnote: "修改这些设置不会重新打开已收尾的对话。" + overrides: "项目单独设置" + overrides_description: "这里列出的项目使用各自的自动收尾规则。" + add_override: "添加项目…" + edit: "编辑" + remove_override: "移除单独设置" + dialog_title: "“%{project}”的对话规则" + use_default: "使用默认值(%{value})" + never: "从不" + after_days: "一定天数后" + days_value: "%{count} 天" + days_value_one: "1 天" + save: "保存" source_control: "源代码管理" title: "设置" sections: "全部设置" @@ -266,14 +295,6 @@ settings: archived_empty_desc: "已归档的对话会显示在此处。" archived_at: "归档于 %{when}" archived_created: "创建于 %{when}" - auto_archive: - section: "自动归档" - title: "自动归档旧对话" - description: "自动归档超过 %{days} 天未活动且排在最近 %{keep} 条之外的对话。正在工作、未读或打开中的对话不会被归档。" - idle_days: "未活动天数" - idle_days_description: "仅归档超过此完整天数未活动的对话。" - keep_count: "保留最近对话" - keep_count_description: "始终保留每组中最新的这些对话。" unarchive: "取消归档" delete_permanently: "永久删除" archived_range: "第 %{from}–%{to} 条,共 %{count} 条"