diff --git a/.gitignore b/.gitignore index fc44a114..276f40bf 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ # Generated browser bundle. /crates/web/dist/ + +/tmp/ diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index 412cbd61..8448d709 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -77,6 +77,9 @@ 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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settled_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")] @@ -145,6 +148,7 @@ impl SessionMeta { project_id: None, model, archived_at: None, + settled_at: None, worktree: None, approval_mode: ApprovalMode::default(), resume_cursor: None, @@ -218,7 +222,8 @@ pub fn auto_archive_candidates( } let found = children.get(session_id).is_some_and(|descendants| { descendants.iter().any(|child| { - exempt.working.contains(&child.id) + 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) @@ -264,7 +269,8 @@ pub fn auto_archive_candidates( if !state.visited.insert(session.id.clone()) || state.archived.contains(&session.id) { continue; } - let directly_exempt = exempt.working.contains(&session.id) + 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)); @@ -648,6 +654,22 @@ mod tests { assert_eq!(serde_json::from_value::(json).unwrap(), meta); } + #[test] + fn auto_archive_keeps_settled_threads_and_ancestors_of_settled_children() { + let mut settled = archive_session("settled", 1, Some("parent")); + settled.settled_at = Some(2); + let sessions = [ + archive_session("newest", 1000, None), + archive_session("parent", 2, None), + settled, + archive_session("old", 1, None), + ]; + assert_eq!( + candidates(&sessions, 10000, 100, 1, &AutoArchiveExemptions::default()), + HashSet::from(["old".into()]) + ); + } + #[test] fn auto_archive_requires_idle_and_beyond_keep_window() { let day = 86_400; diff --git a/crates/protocol/src/command.rs b/crates/protocol/src/command.rs index f9907c13..1658fe98 100644 --- a/crates/protocol/src/command.rs +++ b/crates/protocol/src/command.rs @@ -220,6 +220,12 @@ pub enum Command { PatchSettings { patch: SettingsPatch, }, + SettleSession { + session_id: String, + }, + MakeSessionActive { + session_id: String, + }, ArchiveSession { session_id: String, }, @@ -405,6 +411,8 @@ impl Command { | Self::RemoveTerminalContext { session_id, .. } | Self::AddReviewComment { session_id, .. } | Self::RemoveReviewComment { session_id, .. } + | Self::SettleSession { session_id, .. } + | Self::MakeSessionActive { session_id, .. } | Self::ArchiveSession { session_id, .. } | Self::UnarchiveSession { session_id, .. } | Self::RenameSession { session_id, .. } diff --git a/crates/runtime/src/app/command_validation.rs b/crates/runtime/src/app/command_validation.rs index 73c31ec1..1284b600 100644 --- a/crates/runtime/src/app/command_validation.rs +++ b/crates/runtime/src/app/command_validation.rs @@ -92,10 +92,18 @@ impl AppState { let Some(session_id) = command.session_id() else { return Ok(()); }; + if matches!(command, Command::SettleSession { .. }) && self.settle_family_busy(session_id) { + return Err(error( + "thread_busy", + "Wait for this thread and its children to finish before settling.", + )); + } // Index mutations operate on stored sessions, without requiring a live provider. if matches!( command, - Command::ArchiveSession { .. } + Command::SettleSession { .. } + | Command::MakeSessionActive { .. } + | Command::ArchiveSession { .. } | Command::UnarchiveSession { .. } | Command::RenameSession { .. } | Command::DeleteSession { .. } diff --git a/crates/runtime/src/app/orchestrate.rs b/crates/runtime/src/app/orchestrate.rs index 59b12fa7..64dba545 100644 --- a/crates/runtime/src/app/orchestrate.rs +++ b/crates/runtime/src/app/orchestrate.rs @@ -407,6 +407,7 @@ impl AppState { child.draft = false; child.push_queued(brief, Vec::new()); self.residents.parked.insert(id.clone(), child); + self.reactivate_session(&id, cx); self.ensure_session_started(&id, cx); Ok(id) } @@ -633,6 +634,7 @@ 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 @@ -1080,7 +1082,11 @@ impl AppState { ); state.callback_last_turn.insert(child_id.clone(), turn); state.deliver_orchestrate_callback_to_parent(&parent_id, text, cx); - if auto_archive { + if auto_archive + && state + .find_meta(&child_id) + .is_some_and(|meta| meta.settled_at.is_none()) + { state.archive_session_ids(&[child_id], now_secs(), cx); } }); @@ -1172,6 +1178,7 @@ impl AppState { text: String, cx: &mut HostCx, ) { + self.reactivate_session(parent_id, cx); let can_steer = self .resident(parent_id) .is_some_and(|parent| parent.turn_in_flight && parent.can_steer()); diff --git a/crates/runtime/src/app/send.rs b/crates/runtime/src/app/send.rs index 1201ad0e..4846bc00 100644 --- a/crates/runtime/src/app/send.rs +++ b/crates/runtime/src/app/send.rs @@ -49,6 +49,7 @@ impl AppState { let should_start = matches!(active.runtime, Runtime::Idle) && !(active.draft && matches!(active.draft_workspace, WorkspaceMode::NewWorktree { .. })); + self.reactivate_session(target_id, cx); if should_start { // Starting now keeps the in-memory session parkable/resident across // navigation. Eligibility prevents the future turn from being sent @@ -235,6 +236,7 @@ impl AppState { // The first send on a draft materializes it into a real (persisted) // session so the sidebar row appears; the provider then starts below. self.commit_draft(target_id, cx); + self.reactivate_session(target_id, cx); let Some(active) = self.resident_mut(target_id) else { return; @@ -530,6 +532,7 @@ impl AppState { attachments: &[Attachment], cx: &mut HostCx, ) -> String { + self.reactivate_session(session_id, cx); let request_id = format!( "local-steer-{}", cx.delivery_key diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 3b004bf8..e1e390df 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -592,6 +592,75 @@ 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() + }) + }) + } + + /// 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) { + return; + } + 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); + } + } + } + + /// 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); + } + } + } + self.reactivate_session(session_id, 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); + } + } + } + + 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; + } + self.persist_meta(&meta, cx); + } + /// Archive a thread (reversible; it vanishes from the sidebar). Blocked while /// its turn is running (returns without changing anything so the caller's /// tooltip stands). The active thread is closed back to the empty state. diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 8322fb6c..781a8c41 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -3124,7 +3124,7 @@ fn reported_result_reaches_parent_and_fallback_covers_silent_children() { } #[test] -fn orchestrate_send_unarchives_the_child() { +fn orchestrate_send_reactivates_the_child_and_its_settled_parent() { let cx = &mut TestAppContext::default(); let test_store = TestStore::new("tcode-orchestrate-send-unarchive-test"); let store = (*test_store).clone(); @@ -3136,6 +3136,11 @@ fn orchestrate_send_unarchives_the_child() { child.meta.id = "child".into(); child.meta.parent_session_id = Some("parent".into()); child.meta.archived_at = Some(1); + 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); + state.sessions.push(parent); child.turn_in_flight = true; state.sessions.push(child.meta.clone()); state.residents.parked.insert(child.meta.id.clone(), child); @@ -3152,6 +3157,8 @@ fn orchestrate_send_unarchives_the_child() { cx, ); 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!( state.find_meta("child").unwrap().archived_at.is_none(), "send should revive an archived child" @@ -6837,3 +6844,201 @@ fn computer_use_registrations_survive_stop_but_are_replaced_after_provider_shutd assert!(state.host.mcp.computer_use_registrations.is_empty()); }); } + +#[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()); + 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() + .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(|| ("input".into(), Vec::new())); + 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(); +} diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index 60bacffc..08265775 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -439,6 +439,8 @@ fn dispatch_command(app: &mut AppState, cx: &mut HostCx, command: Command) -> Co app.toggle_project_collapsed(&project_id, 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::ArchiveSession { session_id } => app.archive_session(&session_id, cx), Command::UnarchiveSession { session_id } => app.unarchive_session(&session_id, cx), Command::AutoArchiveSweep { project_id } => { diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index c8976b2f..31ef1f76 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -50,6 +50,7 @@ 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.; +const SETTLED_HEADER_HEIGHT: f32 = 34.; /// A critically damped spring keeps reordering legible without bouncing rows /// past their destinations. GPUI also makes this snap to the target when the @@ -192,6 +193,30 @@ 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())) +} + fn thread_visible(meta: &SessionMeta, collapsed_parents: &HashSet) -> bool { meta.parent_session_id .as_ref() @@ -207,7 +232,12 @@ fn visible_threads<'a>( ) -> Vec<&'a SessionMeta> { sessions .iter() - .filter(|meta| thread_visible(meta, collapsed_parents)) + .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() } @@ -351,7 +381,12 @@ fn flat_visible_threads<'a>( blocks .into_iter() .flat_map(|block| block.sessions) - .filter(|meta| thread_visible(meta, collapsed_parents)) + .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() } @@ -477,6 +512,12 @@ struct ThreadExportMarkdown(String); struct ThreadArchive(String); #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = tcode_thread, no_json)] +struct ThreadSettle(String); +#[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)] struct ThreadDelete(String); #[derive(Action, Clone, PartialEq, Eq, Deserialize)] @@ -532,6 +573,7 @@ struct CompactProjectRow { #[derive(Clone)] enum CompactListRow { Project(CompactProjectRow), + Settled { key: String, count: usize }, Thread(Rc), BottomInset, } @@ -540,6 +582,7 @@ impl CompactListRow { fn key(&self) -> &str { match self { Self::Project(row) => &row.row_id, + Self::Settled { key, .. } => key, Self::Thread(row) => &row.row_id, Self::BottomInset => "compact-bottom-inset", } @@ -559,6 +602,8 @@ pub struct SessionsSidebar { window_state: Entity, /// Project ids whose thread list is expanded past the collapsed limit. expanded_groups: HashSet, + expanded_settled: HashSet, + last_selected: Option, /// Parent session ids whose direct child rows are folded away. collapsed_parents: HashSet, /// Optional project id filter for the session-local flat list. @@ -647,6 +692,8 @@ impl SessionsSidebar { store, window_state, expanded_groups: HashSet::new(), + expanded_settled: HashSet::new(), + last_selected: None, collapsed_parents, project_filter: None, renaming: None, @@ -957,6 +1004,110 @@ 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_make_active( + &mut self, + action: &ThreadMakeActive, + _: &mut Window, + cx: &mut Context, + ) { + self.store + .update(cx, |store, _| store.make_session_active(action.0.clone())); + } + + fn reveal_selected_settled(&mut self, cx: &mut Context) { + let selected = self.store.read(cx).active_session_id(); + if selected == self.last_selected { + return; + } + let sessions = self.store.read(cx).sidebar_sessions(); + if selected.is_some() + && !sessions + .iter() + .any(|meta| Some(&meta.id) == selected.as_ref()) + { + return; + } + self.last_selected = selected.clone(); + if let Some(meta) = sessions + .iter() + .find(|meta| Some(&meta.id) == selected.as_ref()) + && 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, _| { + store.toggle_project_collapsed(project_id.clone()) + }); + } + } + let mut parent = meta.parent_session_id.as_ref(); + let mut visited = HashSet::new(); + while let Some(id) = parent { + if !visited.insert(id) { + break; + } + self.collapsed_parents.remove(id); + parent = sessions + .iter() + .find(|meta| &meta.id == id) + .and_then(|meta| meta.parent_session_id.as_ref()); + } + self.compact_model_dirty = true; + } + } + + 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}") + }) + .w_full() + .h(px(if self.compact(cx) { + 44. + } else { + SETTLED_HEADER_HEIGHT + })) + .gap_2() + .px_3() + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + if !this.expanded_settled.remove(&key) { + this.expanded_settled.insert(key.clone()); + } + this.compact_model_dirty = true; + cx.notify(); + })) + .child(collapse_chevron(!expanded, cx)) + .child(crate::tr!("sidebar.settled")) + .child(count.to_string()) + .into_any_element() + } + fn on_archive(&mut self, action: &ThreadArchive, window: &mut Window, cx: &mut Context) { let id = action.0.clone(); let title = self @@ -1532,7 +1683,8 @@ impl SessionsSidebar { let group_key = format!("group-{project_id}"); let expanded = self.expanded_groups.contains(&project_id); - let threads = visible_threads(&group.sessions, &self.collapsed_parents); + let (active, settled) = partition_settled(&group.sessions); + let threads = visible_threads(&active, &self.collapsed_parents); let total = threads.len(); let visible = if expanded { total @@ -1735,6 +1887,21 @@ impl SessionsSidebar { .child(label), ); } + if !settled.is_empty() { + container = + container.child(self.render_settled_header(&project_id, settled.len(), cx)); + if self.expanded_settled.contains(&project_id) { + for meta in visible_threads(&settled, &self.collapsed_parents) { + container = container.child(self.render_thread( + meta, + sessions, + flags, + active_id == Some(meta.id.as_str()), + cx, + )); + } + } + } } container @@ -1790,6 +1957,10 @@ impl SessionsSidebar { cx, ) .aria_selected(is_active) + .debug_selector({ + let id = meta.id.clone(); + move || format!("sidebar-thread-{id}") + }) .when(has_direct_children, |row| { row.aria_expanded(!state.children_collapsed) }) @@ -1912,6 +2083,7 @@ impl SessionsSidebar { row: gpui::Stateful, session_id: String, running: bool, + settled: bool, can_fork: bool, is_worktree: bool, compact: bool, @@ -1957,6 +2129,19 @@ 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())), @@ -2071,6 +2256,7 @@ impl SessionsSidebar { row, session_id, working, + meta.settled_at.is_some(), state.menu_can_fork, is_worktree, false, @@ -2331,6 +2517,7 @@ impl SessionsSidebar { row, session_id, working, + meta.settled_at.is_some(), state.menu_can_fork, meta.worktree.is_some(), false, @@ -2511,11 +2698,24 @@ impl SessionsSidebar { } rows }; + let grouped_rows = |project: Option<&str>, recent: bool, key: &str| { + let (active, settled) = partition_settled(&sessions); + let active = compact_visible_threads(&active, &self.collapsed_parents, project); + let settled = compact_visible_threads(&settled, &self.collapsed_parents, project); + let mut rows = thread_rows(active, recent); + 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)); + } + } + rows + }; if layout == SidebarLayout::Flat { - rows.extend(thread_rows( - compact_visible_threads(&sessions, &self.collapsed_parents, None), - true, - )); + rows.extend(grouped_rows(None, true, "recent")); } else { // Build the same family order for each project before adding captions. for group in &groups { @@ -2529,7 +2729,11 @@ impl SessionsSidebar { groups.len() > 1 && collapsed_projects.contains(&group.project.id); let start = rows.len(); if !collapsed { - rows.extend(thread_rows(visible, false)); + rows.extend(grouped_rows( + Some(&group.project.id), + false, + &group.project.id, + )); } if groups.len() > 1 { rows.insert( @@ -2680,6 +2884,9 @@ impl SessionsSidebar { list( self.compact_list_state.clone(), cx.processor(move |this, index: usize, _, cx| match &model.rows[index] { + CompactListRow::Settled { key, count } => { + this.render_settled_header(key, *count, cx) + } CompactListRow::Project(row) => { this.render_compact_group_header(row, cx).into_any_element() } @@ -2721,6 +2928,8 @@ impl SessionsSidebar { .on_action(cx.listener(Self::on_copy_id)) .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_make_active)) .on_action(cx.listener(Self::on_archive)) .on_action(cx.listener(Self::on_delete)) .child(self.render_compact_search(cx)) @@ -3000,6 +3209,7 @@ impl SessionsSidebar { row, session_id, working, + meta.settled_at.is_some(), state.menu_can_fork, meta.worktree.is_some(), true, @@ -3075,6 +3285,7 @@ fn compact_status_line( impl Render for SessionsSidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.reveal_selected_settled(cx); if self.compact(cx) { return self.render_compact(cx); } @@ -3166,13 +3377,21 @@ impl Render for SessionsSidebar { ) } SidebarLayout::Flat => { + let (active, settled) = partition_settled(&flat_sessions); let visible = flat_visible_threads( - &flat_sessions, + &active, + &self.collapsed_parents, + self.project_filter.as_deref(), + &flags, + ); + let settled_visible = flat_visible_threads( + &settled, &self.collapsed_parents, self.project_filter.as_deref(), &flags, ); - if visible.is_empty() { + let settled_count = settled_visible.len(); + if visible.is_empty() && settled_count == 0 { // An active project filter can empty the list while threads // exist; that state gets its own hint, not the no-projects one. let hint = if flat_sessions.is_empty() { @@ -3205,11 +3424,38 @@ impl Render for SessionsSidebar { ) } else { let top_offsets = flat_thread_top_offsets(&visible, &flat_sessions); - let visible = visible + let settled_top = visible + .iter() + .map(|meta| { + if meta.parent_session_id.as_ref().is_some_and(|id| { + flat_sessions.iter().any(|parent| &parent.id == id) + }) { + FLAT_CHILD_ROW_HEIGHT + } else { + FLAT_ROOT_ROW_HEIGHT + } + }) + .sum::() + + SETTLED_HEADER_HEIGHT; + let mut visible = visible .into_iter() .cloned() .zip(top_offsets) + .map(Some) .collect::>(); + if settled_count > 0 { + visible.push(None); + if self.expanded_settled.contains("recent") { + 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), + ); + } + } if self.flat_list_state.item_count() != visible.len() { self.flat_list_state.reset(visible.len()); } @@ -3222,9 +3468,12 @@ impl Render for SessionsSidebar { list( self.flat_list_state.clone(), cx.processor(move |this, index: usize, _window, cx| { - let Some((meta, target_top)) = visible.get(index) else { + let Some(row) = visible.get(index) else { return div().into_any_element(); }; + let Some((meta, target_top)) = row else { + return this.render_settled_header("recent", settled_count, cx); + }; let target_top = *target_top; let project_name = meta .project_id @@ -3273,6 +3522,8 @@ impl Render for SessionsSidebar { .on_action(cx.listener(Self::on_copy_id)) .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_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)) @@ -3394,6 +3645,162 @@ mod tests { .collect() } + #[gpui::test] + fn settled_groups_collapse_and_navigation_reveals_them_at_both_widths(cx: &mut TestAppContext) { + use tcode_protocol::{ + EventEnvelope, HostMessage, IndexSnapshot, ServerEvent, Topic, encode_line, + }; + cx.update(crate::theme::init); + 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 settled = session("settled", None); + 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 { + sessions: vec![active, settled], + projects: vec![project], + activity: HashMap::new(), + }), + ); + 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, + false, + 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 compact in [false, true] { + for layout in [SidebarLayout::Flat, SidebarLayout::Grouped] { + let settings = tcode_core::settings::Settings { + sidebar_layout: layout, + auto_archive_disabled: true, + ..Default::default() + }; + send(Topic::Settings, ServerEvent::SettingsSnapshot(settings)); + window_state.update(cx, |state, _| state.compact = compact); + store.update(cx, |store, _| store.select_session("active".into())); + 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()), + 2 + ); + 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" + }; + assert!(cx.debug_bounds(row).is_none(), "settled starts collapsed"); + 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" + ); + 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() + })); + } + } + } + + #[test] + fn settled_partition_keeps_active_descendants_visible_and_orders_families() { + let mut parent = session("parent", None); + parent.settled_at = Some(1); + let child = session("child", Some("parent")); + let mut sibling = session("settled-child", Some("parent")); + sibling.settled_at = Some(1); + let (active, settled) = partition_settled(&[parent, child, sibling]); + assert_eq!( + active + .iter() + .map(|meta| meta.id.as_str()) + .collect::>(), + ["parent", "child"] + ); + let collapsed = HashSet::from(["parent".into()]); + assert_eq!(visible_threads(&settled, &collapsed)[0].id, "settled-child"); + assert_eq!( + flat_visible_threads(&settled, &collapsed, None, &HashMap::new())[0].id, + "settled-child" + ); + assert_eq!( + compact_visible_threads(&settled, &collapsed, None)[0].id, + "settled-child" + ); + } + #[gpui::test] fn working_thread_title_stays_inside_row_at_every_sidebar_width(cx: &mut TestAppContext) { let (_, cx) = cx.add_window_view(|_, _| WorkingThreadRowProbe); diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index d2f7b7d1..86735a90 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -185,6 +185,12 @@ impl WorkspaceStore { } impl WorkspaceStore { + pub fn settle_session(&mut self, session_id: String) { + self.dispatch(Command::SettleSession { session_id }); + } + pub fn make_session_active(&mut self, session_id: String) { + self.dispatch(Command::MakeSessionActive { session_id }); + } pub fn archive_session(&mut self, session_id: String) { self.dispatch(Command::ArchiveSession { session_id }); } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 70f22508..a5cd5ecf 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1047,6 +1047,24 @@ threads appear in the sidebar before the dialog reports the run finished. A second import of the same project while one is running is refused rather than queued. +### Settled threads + +The thread context menu offers **Settle / 标记为已完成** and, for settled threads, +**Make active / 恢复为活跃**. Settling applies to a thread and its descendants and +is refused while any affected thread has running work, pending input or approval, +or queued messages. It preserves the selected conversation, provider session, +terminals and worktree. Archive remains a separate, reversible action; automatic +archiving exempts settled threads. + +At both widths, active threads precede a collapsible **Settled / 已完成** group. +By project has one group inside each project; Recent has one group after active +threads. Existing ordering and parent/child folds apply within each group. A +settled parent cannot hide active descendants. Settled groups start collapsed, +retain expansion while the UI is open, and expand when navigation selects a +settled thread. Opening or searching does not reactivate it. Accepted messages, +scheduled messages and orchestration input reactivate the recipient and settled +ancestors. Make active restores the matching settle cascade and its ancestors. + ### Session lifetime Navigating away from a thread must not cancel its running turn, queued messages diff --git a/locales/en.yml b/locales/en.yml index 2627d8eb..e6ea2b54 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -156,6 +156,9 @@ sidebar: waiting_approval_tooltip: "Waiting for approval" waiting_input: "Question" waiting_input_tooltip: "Waiting for your answer" + settled: "Settled" + settle: "Settle" + make_active: "Make active" archive: "Archive thread" archive_title: "Archive thread?" archive_description: 'Archive "%{title}" and its saved conversation? This cannot be undone.' diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 0783bfaf..bdfde171 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -156,6 +156,9 @@ sidebar: waiting_approval_tooltip: "正在等待审批" waiting_input: "待回答" waiting_input_tooltip: "正在等待你的回答" + settled: "已完成" + settle: "标记为已完成" + make_active: "恢复为活跃" archive: "归档对话" archive_title: "归档对话?" archive_description: "要归档“%{title}”及其已保存的对话吗?此操作无法撤销。"