From ab92892cc41695817d0fe38f4a4e268c6ec6160b Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 6 May 2026 03:16:44 -0500 Subject: [PATCH] fix(session): scope latest resume to workspace (#830) --- README.md | 2 +- crates/tui/src/main.rs | 54 ++++++----- crates/tui/src/session_manager.rs | 152 +++++++++++++++++++++++++----- crates/tui/src/tui/ui.rs | 4 +- docs/MODES.md | 2 +- 5 files changed, 168 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 2c33994d..c903a5ff 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ deepseek setup --status # read-only setup status deepseek setup --tools --plugins # scaffold tool/plugin dirs deepseek models # list live API models deepseek sessions # list saved sessions -deepseek resume --last # resume the most recent session +deepseek resume --last # resume the most recent session in this workspace deepseek resume # resume a specific session by UUID deepseek fork # fork a session at a chosen turn deepseek serve --http # HTTP/SSE API server diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index dcd5a475..bff1aa06 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -126,7 +126,7 @@ struct Cli { #[arg(short, long)] resume: Option, - /// Continue the most recent session + /// Continue the most recent session in this workspace #[arg(short = 'c', long = "continue")] continue_session: bool, @@ -231,7 +231,7 @@ enum Commands { /// Conversation/session id (UUID or prefix) #[arg(value_name = "SESSION_ID")] session_id: Option, - /// Continue the most recent session without a picker + /// Continue the most recent session in this workspace without a picker #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] last: bool, }, @@ -240,7 +240,7 @@ enum Commands { /// Conversation/session id (UUID or prefix) #[arg(value_name = "SESSION_ID")] session_id: Option, - /// Fork the most recent session without a picker + /// Fork the most recent session in this workspace without a picker #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] last: bool, }, @@ -727,12 +727,14 @@ async fn main() -> Result<()> { } Commands::Resume { session_id, last } => { let config = load_config_from_cli(&cli)?; - let resume_id = resolve_session_id(session_id, last)?; + let workspace = resolve_workspace(&cli); + let resume_id = resolve_session_id(session_id, last, &workspace)?; run_interactive(&cli, &config, Some(resume_id), None).await } Commands::Fork { session_id, last } => { let config = load_config_from_cli(&cli)?; - let new_session_id = fork_session(session_id, last)?; + let workspace = resolve_workspace(&cli); + let new_session_id = fork_session(session_id, last, &workspace)?; run_interactive(&cli, &config, Some(new_session_id), None).await } }; @@ -747,11 +749,8 @@ async fn main() -> Result<()> { // Handle session resume let resume_session_id = if cli.continue_session { - // Get most recent session - match session_manager::SessionManager::default_location() { - Ok(manager) => manager.get_latest_session().ok().flatten().map(|m| m.id), - Err(_) => None, - } + let workspace = resolve_workspace(&cli); + latest_session_id_for_workspace(&workspace).ok().flatten() } else if let Some(id) = cli.resume.clone() { Some(id) } else if !cli.fresh { @@ -2351,7 +2350,7 @@ fn list_sessions(limit: usize, search: Option) -> Result<()> { "".dimmed() ); println!( - "Continue latest: {}", + "Continue latest in this workspace: {}", "deepseek --continue".truecolor(blue_r, blue_g, blue_b) ); @@ -2449,9 +2448,14 @@ fn run_logout() -> Result<()> { Ok(()) } -fn resolve_session_id(session_id: Option, last: bool) -> Result { +fn resolve_session_id(session_id: Option, last: bool, workspace: &Path) -> Result { if last { - return Ok("latest".to_string()); + return latest_session_id_for_workspace(workspace)?.ok_or_else(|| { + anyhow!( + "No saved sessions found for workspace {}. Use `deepseek sessions` to list all sessions, or `deepseek resume ` to resume one explicitly.", + workspace.display() + ) + }); } if let Some(id) = session_id { return Ok(id); @@ -2459,15 +2463,25 @@ fn resolve_session_id(session_id: Option, last: bool) -> Result pick_session_id() } -fn fork_session(session_id: Option, last: bool) -> Result { +fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result> { + let manager = SessionManager::default_location()?; + Ok(manager + .get_latest_session_for_workspace(workspace)? + .map(|session| session.id)) +} + +fn fork_session(session_id: Option, last: bool, workspace: &Path) -> Result { let manager = SessionManager::default_location()?; let saved = if last { - let Some(meta) = manager.get_latest_session()? else { - bail!("No saved sessions found."); + let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else { + bail!( + "No saved sessions found for workspace {}.", + workspace.display() + ); }; manager.load_session(&meta.id)? } else { - let id = resolve_session_id(session_id, false)?; + let id = resolve_session_id(session_id, false, workspace)?; manager.load_session_by_prefix(&id)? }; @@ -2618,10 +2632,8 @@ async fn run_pr( let prompt = format_pr_prompt(number, &view, &diff); let resume_session_id = if cli.continue_session { - match session_manager::SessionManager::default_location() { - Ok(manager) => manager.get_latest_session().ok().flatten().map(|m| m.id), - Err(_) => None, - } + let workspace = resolve_workspace(cli); + latest_session_id_for_workspace(&workspace).ok().flatten() } else { cli.resume.clone() }; diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 6dc602cd..056bf962 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -436,10 +436,15 @@ impl SessionManager { Ok(pruned) } - /// Get the most recent session - pub fn get_latest_session(&self) -> std::io::Result> { + /// Get the most recent session scoped to the current workspace. + pub fn get_latest_session_for_workspace( + &self, + workspace: &Path, + ) -> std::io::Result> { let sessions = self.list_sessions()?; - Ok(sessions.into_iter().next()) + Ok(sessions + .into_iter() + .find(|session| workspace_scope_matches(&session.workspace, workspace))) } /// Search sessions by title @@ -454,6 +459,42 @@ impl SessionManager { } } +fn workspace_scope_matches(saved_workspace: &Path, current_workspace: &Path) -> bool { + if paths_equivalent(saved_workspace, current_workspace) { + return true; + } + + match ( + find_git_root(saved_workspace), + find_git_root(current_workspace), + ) { + (Some(saved_root), Some(current_root)) => paths_equivalent(&saved_root, ¤t_root), + _ => false, + } +} + +fn paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + let lhs_canonical = fs::canonicalize(lhs).ok(); + let rhs_canonical = fs::canonicalize(rhs).ok(); + match (lhs_canonical, rhs_canonical) { + (Some(lhs), Some(rhs)) => lhs == rhs, + _ => lhs == rhs, + } +} + +fn find_git_root(path: &Path) -> Option { + let mut current = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + loop { + if current.join(".git").exists() { + return Some(current); + } + match current.parent() { + Some(parent) if parent != current => current = parent.to_path_buf(), + _ => return None, + } + } +} + /// Resolve the default session directory path (`~/.deepseek/sessions`). pub fn default_sessions_dir() -> std::io::Result { let home = dirs::home_dir().ok_or_else(|| { @@ -782,6 +823,32 @@ mod tests { } } + fn write_session_record( + manager: &SessionManager, + id: &str, + workspace: &Path, + updated_at: DateTime, + ) { + let session = SavedSession { + schema_version: CURRENT_SESSION_SCHEMA_VERSION, + messages: vec![make_test_message("user", "hi")], + metadata: SessionMetadata { + id: id.to_string(), + title: format!("session-{id}"), + created_at: updated_at, + updated_at, + message_count: 1, + total_tokens: 0, + model: "deepseek-v4-flash".to_string(), + workspace: workspace.to_path_buf(), + mode: None, + }, + system_prompt: None, + context_references: Vec::new(), + }; + manager.save_session(&session).expect("save"); + } + #[test] fn test_session_manager_new() { let tmp = tempdir().expect("tempdir"); @@ -826,6 +893,66 @@ mod tests { assert_eq!(sessions.len(), 3); } + #[test] + fn latest_session_for_workspace_ignores_newer_other_directory() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); + let workspace_a = tmp.path().join("aa").join("aaa"); + let workspace_b = tmp.path().join("bb").join("bbb"); + fs::create_dir_all(&workspace_a).expect("mkdir workspace a"); + fs::create_dir_all(&workspace_b).expect("mkdir workspace b"); + + write_session_record( + &manager, + "current-workspace", + &workspace_a, + Utc::now() - chrono::Duration::minutes(10), + ); + write_session_record(&manager, "other-workspace", &workspace_b, Utc::now()); + + let global = manager + .list_sessions() + .expect("list") + .into_iter() + .next() + .expect("global latest"); + assert_eq!(global.id, "other-workspace"); + + let scoped = manager + .get_latest_session_for_workspace(&workspace_a) + .expect("latest for workspace") + .expect("scoped latest"); + assert_eq!(scoped.id, "current-workspace"); + } + + #[test] + fn latest_session_for_workspace_matches_same_git_repository() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); + let repo = tmp.path().join("repo"); + let repo_app = repo.join("apps").join("client"); + let repo_crate = repo.join("crates").join("server"); + let other_repo = tmp.path().join("other").join("project"); + fs::create_dir_all(repo.join(".git")).expect("mkdir .git"); + fs::create_dir_all(&repo_app).expect("mkdir repo app"); + fs::create_dir_all(&repo_crate).expect("mkdir repo crate"); + fs::create_dir_all(&other_repo).expect("mkdir other repo"); + + write_session_record( + &manager, + "same-repo", + &repo_app, + Utc::now() - chrono::Duration::minutes(5), + ); + write_session_record(&manager, "other-repo", &other_repo, Utc::now()); + + let scoped = manager + .get_latest_session_for_workspace(&repo_crate) + .expect("latest for workspace") + .expect("same repo latest"); + assert_eq!(scoped.id, "same-repo"); + } + #[test] fn test_load_by_prefix() { let tmp = tempdir().expect("tempdir"); @@ -1207,24 +1334,7 @@ mod tests { // to whatever the helper functions emit; we just need a // metadata block whose `updated_at` matches the requested // value. - let session = SavedSession { - schema_version: CURRENT_SESSION_SCHEMA_VERSION, - messages: vec![make_test_message("user", "hi")], - metadata: SessionMetadata { - id: id.to_string(), - title: format!("session-{id}"), - created_at: updated_at, - updated_at, - message_count: 1, - total_tokens: 0, - model: "deepseek-v4-flash".to_string(), - workspace: PathBuf::from("/tmp"), - mode: None, - }, - system_prompt: None, - context_references: Vec::new(), - }; - manager.save_session(&session).expect("save"); + write_session_record(manager, id, Path::new("/tmp"), updated_at); } #[test] diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e5d500b9..3b1b5afd 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -252,8 +252,8 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { // Try to load by prefix or full ID let load_result: std::io::Result> = if session_id == "latest" { - // Special case: resume the most recent session - match manager.get_latest_session() { + // Special case: resume the most recent session in this workspace. + match manager.get_latest_session_for_workspace(&options.workspace) { Ok(Some(meta)) => manager.load_session(&meta.id).map(Some), Ok(None) => Ok(None), Err(e) => Err(e), diff --git a/docs/MODES.md b/docs/MODES.md index 34ff218b..c7a76135 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -81,7 +81,7 @@ Run `deepseek --help` for the canonical list. Common flags: - `--workspace `: workspace root for file tools - `--yolo`: start in YOLO mode - `-r, --resume `: resume a saved session -- `-c, --continue`: resume the most recent session +- `-c, --continue`: resume the most recent session in this workspace - `--max-subagents `: clamp to `1..=20` - `--no-alt-screen`: run inline without the alternate screen buffer - `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, and right-click context actions. Mouse capture is enabled by default on non-Windows terminals so drag selection copies only user/assistant transcript text; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. On Windows it defaults off to avoid CMD/terminal mouse escape sequences being inserted into the prompt; use `--mouse-capture` to opt in.