diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 6ba960c..0c5d493 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -608,7 +608,19 @@ impl App { /// daemon never adopts an existing worktree (docs/WORKTREES.md), same /// rule as web/src/spawnSimilar.ts. fn open_spawn_modal(&mut self) { - let seed_cwd = self.state.active_session().and_then(|sid| { + let mut draft = DraftPane::default(); + draft.seed_cwd = self.focused_seed_cwd(); + self.draft = Some(draft); + self.state.open_spawn_modal(); + } + + /// The project of the session the operator was last on — its base + /// repository for a worktree session, its cwd otherwise. Shared by the + /// plain spawn modal seed and fast-forward's project pick (#67 review): + /// both should land on "where I currently am", not just "whatever's + /// freshest". + fn focused_seed_cwd(&self) -> Option { + self.state.active_session().and_then(|sid| { self.catalog .snapshot() .sessions @@ -618,11 +630,7 @@ impl App { Some(wt) => wt.base_repo.clone(), None => s.cwd.clone(), }) - }); - let mut draft = DraftPane::default(); - draft.seed_cwd = seed_cwd; - self.draft = Some(draft); - self.state.open_spawn_modal(); + }) } /// Close the form and drop its half-filled state — reopening starts clean, @@ -767,9 +775,11 @@ impl App { info, projects, names, + focused_cwd, } = update { - self.fast_forward_spawn(info, projects, names).await; + self.fast_forward_spawn(info, projects, names, focused_cwd) + .await; return; } let want = match &update { @@ -836,6 +846,7 @@ impl App { /// `/api/projects` walks all of `$HOME`, and pressing the key must not /// freeze keys, frames and attached panes until it returns. fn spawn_fast_forward(&self) { + let focused_cwd = self.focused_seed_cwd(); let (daemon, http, base, tx) = self.loader_handles(); tokio::spawn(async move { let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); @@ -844,6 +855,7 @@ impl App { info: info.ok(), projects: projects.ok(), names, + focused_cwd, }); }); } @@ -853,6 +865,7 @@ impl App { info: Option, projects: Option>, names: Vec, + focused_cwd: Option, ) { // Keep the home dir: without it the pre-filled draft renders // unabbreviated paths and "Other directory…" seeds `/` instead of @@ -866,8 +879,13 @@ impl App { _ => Vec::new(), }; let discovered = projects.unwrap_or_default(); - let resolved = - crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now()); + let resolved = crate::fast_forward::resolve( + &discovered, + &presets, + names.first().cloned(), + Utc::now(), + focused_cwd.as_deref(), + ); if let Some(req) = resolved.build_request() { self.submit_spawn(req).await; return; @@ -1166,6 +1184,7 @@ enum DraftUpdate { info: Option, projects: Option>, names: Vec, + focused_cwd: Option, }, } diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs index b799b55..bb78711 100644 --- a/crates/maestro-tui/src/fast_forward.rs +++ b/crates/maestro-tui/src/fast_forward.rs @@ -26,16 +26,25 @@ pub struct FastForwardDefaults { /// Resolve the fast-forward defaults from the daemon's answers. `name` is /// `None` when `/api/names` yielded nothing; `presets` may be empty (the -/// caller falls back to the built-ins it already has). +/// caller falls back to the built-ins it already has). `focused_cwd` is the +/// project of the session the operator was last on ([`crate::app`]'s +/// `focused_seed_cwd`) — when it matches a known project, that project wins +/// over the global recency ranking, so `N` lands where the operator +/// currently is rather than wherever was freshest. pub fn resolve( projects: &[Project], presets: &[SpawnPreset], name: Option, now: DateTime, + focused_cwd: Option<&std::path::Path>, ) -> FastForwardDefaults { - let project = crate::projects::sort_projects(projects.to_vec(), now) - .into_iter() - .next(); + let project = focused_cwd + .and_then(|cwd| projects.iter().find(|p| p.path == cwd).cloned()) + .or_else(|| { + crate::projects::sort_projects(projects.to_vec(), now) + .into_iter() + .next() + }); let command = project .as_ref() .and_then(|p| p.last_command.clone()) @@ -155,7 +164,13 @@ mod tests { repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), ]; let presets = vec![preset("aider", "aider"), preset("custom", "")]; - let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now()); + let r = resolve( + &projects, + &presets, + Some("amber-falcon".into()), + Utc::now(), + None, + ); assert_eq!( r.project.as_ref().unwrap().path, PathBuf::from("/repos/fresh") @@ -176,6 +191,46 @@ mod tests { assert!(wt.create_branch, "fresh branch named after the session"); } + #[test] + fn the_focused_sessions_project_wins_over_the_freshest_one() { + let projects = vec![ + repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), + project("/repos/current", 5, Some("zsh")), + ]; + let presets = vec![preset("claude", "claude")]; + let r = resolve( + &projects, + &presets, + Some("n".into()), + Utc::now(), + Some(std::path::Path::new("/repos/current")), + ); + assert_eq!( + r.project.as_ref().unwrap().path, + PathBuf::from("/repos/current") + ); + assert_eq!(r.command.as_deref(), Some("zsh")); + } + + #[test] + fn an_unknown_focused_cwd_falls_back_to_the_freshest_project() { + let projects = vec![ + project("/repos/old", 30, Some("zsh")), + repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), + ]; + let r = resolve( + &projects, + &[preset("claude", "claude")], + Some("n".into()), + Utc::now(), + Some(std::path::Path::new("/repos/gone")), + ); + assert_eq!( + r.project.as_ref().unwrap().path, + PathBuf::from("/repos/fresh") + ); + } + #[test] fn worktree_of_the_most_recent_project_targets_its_base_repo() { let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)]; @@ -184,6 +239,7 @@ mod tests { &[preset("zsh", "zsh")], Some("s".into()), Utc::now(), + None, ); let wt = r.build_request().unwrap().worktree.unwrap(); assert_eq!(wt.base_repo, PathBuf::from("/repos/main")); @@ -193,7 +249,7 @@ mod tests { fn missing_last_command_falls_back_to_the_first_non_custom_preset() { let projects = vec![project("/repos/p", 0, None)]; let presets = vec![preset("custom", ""), preset("claude", "claude")]; - let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None); assert_eq!(r.command.as_deref(), Some("claude")); } @@ -201,7 +257,7 @@ mod tests { fn an_empty_last_command_is_no_command_at_all() { let projects = vec![project("/repos/p", 0, Some(""))]; let presets = vec![preset("custom", "")]; - let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None); assert_eq!(r.command, None); assert!(r.build_request().is_none()); } @@ -213,6 +269,7 @@ mod tests { &[preset("claude", "claude")], Some("n".into()), Utc::now(), + None, ); assert_eq!(r.project, None); // No project → no directory → no request, even with command + name. @@ -222,14 +279,26 @@ mod tests { #[test] fn a_missing_generated_name_downgrades_to_a_draft() { let projects = vec![project("/repos/p", 0, Some("claude"))]; - let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let r = resolve( + &projects, + &[preset("claude", "claude")], + None, + Utc::now(), + None, + ); assert!(r.build_request().is_none(), "no name → no immediate spawn"); } #[test] fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() { let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))]; - let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now()); + let r = resolve( + &projects, + &[preset("aider", "aider")], + None, + Utc::now(), + None, + ); let mut draft = DraftPane::default(); r.apply_to_draft(&mut draft); @@ -251,6 +320,7 @@ mod tests { &[preset("claude", "claude")], Some("amber-falcon".into()), Utc::now(), + None, ); let mut draft = DraftPane::default(); named.apply_to_draft(&mut draft); @@ -263,7 +333,13 @@ mod tests { #[test] fn an_unresolved_name_still_gets_generated_by_a_later_batch() { let projects = vec![project("/repos/p", 0, Some("claude"))]; - let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let r = resolve( + &projects, + &[preset("claude", "claude")], + None, + Utc::now(), + None, + ); let mut draft = DraftPane::default(); r.apply_to_draft(&mut draft); draft.seed_names(vec!["late-name".into()]); @@ -273,7 +349,13 @@ mod tests { #[test] fn a_last_command_no_preset_advertises_lands_in_custom_text() { let projects = vec![project("/repos/p", 0, Some("nvim -d"))]; - let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let r = resolve( + &projects, + &[preset("claude", "claude")], + None, + Utc::now(), + None, + ); let mut draft = DraftPane::default(); r.apply_to_draft(&mut draft); assert_eq!(draft.command(), "nvim -d"); diff --git a/web/src/fastForward.test.ts b/web/src/fastForward.test.ts index 9d1d6e5..4366c28 100644 --- a/web/src/fastForward.test.ts +++ b/web/src/fastForward.test.ts @@ -47,6 +47,30 @@ describe("resolveFastForward (#67)", () => { }); }); + it("prefers the focused session's project over the freshest one", () => { + const projects = [ + project({ path: "/repos/fresh", name: "fresh", last_command: "claude" }), + project({ + path: "/repos/current", + name: "current", + last_activity: "2020-01-01T00:00:00Z", + last_command: "zsh", + }), + ]; + const res = resolveFastForward(projects, presets, "n", "/repos/current"); + expect(res.project?.path).toBe("/repos/current"); + expect(res.command).toBe("zsh"); + }); + + it("falls back to the freshest project when the focused cwd is unknown", () => { + const projects = [ + project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }), + project({ path: "/repos/fresh", name: "fresh" }), + ]; + const res = resolveFastForward(projects, presets, "n", "/repos/gone"); + expect(res.project?.path).toBe("/repos/fresh"); + }); + it("falls back to the first non-custom preset when there is no last command", () => { const res = resolveFastForward([project({ last_command: undefined })], presets, "n"); expect(res.command).toBe("claude"); diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts index 6a47f44..5f83611 100644 --- a/web/src/fastForward.ts +++ b/web/src/fastForward.ts @@ -13,15 +13,20 @@ export interface FastForwardResolution { name?: string; } -/** The defaults: most recent project (fresh-first, same ordering the picker - * shows), its last command falling back to the first non-custom preset, and - * a freshly generated name when the daemon supplied one. */ +/** The defaults: the currently focused session's project when it's still a + * known project, else the most recent one (fresh-first, same ordering the + * picker shows) — so `N` lands where the operator currently is, matching + * what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command + * falls back to the first non-custom preset, and the name is freshly + * generated when the daemon supplied one. */ export function resolveFastForward( projects: Project[], presets: SpawnPreset[], name?: string, + focusedCwd?: string, ): FastForwardResolution { - const project = sortProjects(projects, new Date())[0]; + const project = + projects.find((p) => p.path === focusedCwd) ?? sortProjects(projects, new Date())[0]; const lastCommand = project?.last_command?.trim(); const command = lastCommand || presets.find((p) => p.command !== "")?.command || undefined; diff --git a/web/src/main.ts b/web/src/main.ts index aff03e3..d9c6f3a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -262,13 +262,28 @@ function requestOpenSpawn(): void { // beside it is the point. // The session you were last on preselects its project: `n` then Enter spawns // another session there without touching the picker. - const focused = mv?.focusedSessionId() ?? null; - const current = focused ? store.snapshot().sessions.get(focused) : undefined; + const current = focusedSession(); openSpawnModal({ seed: current ? spawnSimilarProjectSeed(current) : undefined, onSuccess: (session) => mountSpawned(session, { newTab: true }), }); } + +/** The session the operator was last on, if any. Shared by the plain spawn + * modal seed and fast-forward's project pick: both should land on "where I + * currently am". */ +function focusedSession(): Session | undefined { + const focused = mv?.focusedSessionId() ?? null; + return focused ? store.snapshot().sessions.get(focused) : undefined; +} + +/** The focused session's project — its base repo for a worktree session, its + * cwd otherwise (same rule as spawnSimilarProjectSeed). */ +function focusedProjectCwd(): string | undefined { + const s = focusedSession(); + if (!s) return undefined; + return s.worktree ? s.worktree.base_repo : s.cwd; +} document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => { const s = (ev as CustomEvent).detail; @@ -303,7 +318,7 @@ async function requestFastForward(): Promise { } catch { /* keep fallbacks */ } - const resolved = resolveFastForward(discovered, presets, name); + const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd()); const req = buildFastForwardRequest(resolved); if (req) { try {