diff --git a/CHANGELOG.md b/CHANGELOG.md index 74923879..0df54713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -233,6 +233,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unexpected password prompt on a host nobody had touched. It now confirms, and when the endpoint is shared the dialog says how many others go with it. +- **The Files tree follows an agent into a git worktree** — the pane's cwd row, + the tab sidebar and the source control panel all track where a coding agent + is actually working, because an agent reports its own directory over the hook + stream. The file tree was the one panel still reading the foreground + process's cwd, and an agent that moves into a worktree never `chdir`s — so + `claude` entering `.claude/worktrees/feature` left the tree rooted in the + main checkout, disagreeing with the path printed directly above it. + - **`tty7 wait` no longer calls a busy shell `idle`** — a pane with nothing reporting agent status was reported as `idle`, so `tty7 wait %3 --until idle` returned success immediately, `matched: true`, about a pane that was midway diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 8dede466..0cdfc09a 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1398,6 +1398,31 @@ impl TerminalView { self.git_status_cwd.as_deref() } + /// The directory this pane's *work* is happening in — what every panel + /// that answers "where am I?" should show. + /// + /// [`Self::cwd`] is the kernel's idea: the cwd of the foreground process. + /// That is right for a shell, and wrong for a coding agent, because an + /// agent moving into a git worktree does not `chdir` — the `claude` + /// process stays where it was launched while the session works somewhere + /// else entirely. The hook stream carries the agent's own cwd for exactly + /// this reason (`AgentSessionState::cwd`), and the git-status poll already + /// folds the two together into `git_status_cwd`; this reads that result + /// back out under a name that doesn't imply it's only about git. + /// + /// `git_status_cwd` is only ever set for a pane whose paths belong to its + /// host, so the fallback here is what decides that: this one takes any + /// cwd, [`Self::effective_host_cwd`] takes only one the host can resolve. + pub fn effective_cwd(&self) -> Option { + self.git_status_cwd.clone().or_else(|| self.cwd()) + } + + /// [`Self::effective_cwd`], restricted to paths the pane's host can act + /// on — for callers that will hand the result to a `Host` call. + pub fn effective_host_cwd(&self) -> Option { + self.git_status_cwd.clone().or_else(|| self.host_cwd()) + } + pub fn refresh_git_status_now(&mut self, cx: &mut Context) { let cwd = self.git_status_cwd.clone(); if cwd.is_some() { @@ -7718,6 +7743,86 @@ mod gpui_tests { ); } + /// An agent that moves into a git worktree does not `chdir` — the process + /// stays put and only its hook stream says where the work went. Every + /// panel that answers "where am I?" reads `effective_cwd`, so this is the + /// one place that has to prefer the agent's answer over the kernel's. + #[gpui::test] + fn a_pane_follows_its_agent_into_a_worktree(cx: &mut TestAppContext) { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + use std::io::Write as _; + use std::path::PathBuf; + + let launched_in = PathBuf::from("/repo"); + let working_in = PathBuf::from("/repo/.claude/worktrees/wt"); + + let (window, mut daemon) = harness(cx); + DaemonMsg::Cwd(launched_in.clone()) + .encode(&mut daemon) + .unwrap(); + DaemonMsg::AgentStatus(Some(AgentSessionState { + status: AgentStatus::Working, + message: None, + session_id: Some("sid-wt".into()), + launch_argv: Some(vec!["claude".into()]), + rich: true, + cwd: Some(working_in.clone()), + activity: 0, + })) + .encode(&mut daemon) + .unwrap(); + daemon.flush().unwrap(); + for _ in 0..200 { + let seen = window + .update(cx, |view, _, _| { + view.cwd().is_some() && view.agent_session().is_some() + }) + .unwrap(); + if seen { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |view, window, cx| { + assert_eq!( + view.cwd(), + Some(launched_in.clone()), + "the process really is still in the launch directory" + ); + view.poll_foreground(window, cx); + assert_eq!( + view.effective_cwd(), + Some(working_in.clone()), + "the file tree, the cwd row and the SCM panel all root here" + ); + assert_eq!(view.effective_host_cwd(), Some(working_in.clone())); + }) + .unwrap(); + + // Turn over: the agent is gone, and with it any claim about where the + // work is. Falling back to a stale worktree would be worse than the + // bug this fixes. + DaemonMsg::AgentStatus(None).encode(&mut daemon).unwrap(); + daemon.flush().unwrap(); + for _ in 0..200 { + let gone = window + .update(cx, |view, _, _| view.agent_session().is_none()) + .unwrap(); + if gone { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + window + .update(cx, |view, window, cx| { + view.poll_foreground(window, cx); + assert_eq!(view.effective_cwd(), Some(launched_in.clone())); + }) + .unwrap(); + } + /// A 1x1 red placement anchored at an absolute scrollback row, built the way /// the decode worker hands one to the store. fn placed_at(anchor_row: i64) -> crate::terminal::images::PlacedImage { diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 2b0057db..86b26d23 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -732,10 +732,15 @@ impl Tty7App { Some(tab) => tab.pane.terminals(), None => Vec::new(), }; + // `effective_cwd`, not `cwd`: a pane running an agent that moved into + // a git worktree keeps its kernel cwd back at the launch directory, so + // the raw process cwd would root the tree in the wrong checkout — and + // in the wrong one *visibly*, since the cwd row directly above this + // tree already follows the agent. let cwds: Vec = leaves .iter() .filter(|leaf| leaf.read(cx).host_id() == id) - .filter_map(|leaf| leaf.read(cx).cwd()) + .filter_map(|leaf| leaf.read(cx).effective_cwd()) .collect(); let mut roots: Vec = Vec::new(); let mut resolved = true; diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 4cd48f90..7c55158b 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -477,11 +477,7 @@ impl Tty7App { if let Some(leaf) = tab.detail_pane(window, cx) { let view = leaf.read(cx); pane_id = Some(view.pane_id); - if let Some(cwd) = view - .git_status_cwd() - .map(|p| p.to_path_buf()) - .or_else(|| view.cwd()) - { + if let Some(cwd) = view.effective_cwd() { rows.push((t(L10nKey::PanelCwd), compact_path(&cwd))); // Copy Path is right either way; Reveal only means anything // when the path is on the machine the file manager can see. diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 31cf96b9..adbcbe1d 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -1041,10 +1041,7 @@ impl Tty7App { ) -> Option<(SharedHost, PathBuf)> { let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?; let view = leaf.read(cx); - let cwd = view - .git_status_cwd() - .map(Path::to_path_buf) - .or_else(|| view.host_cwd())?; + let cwd = view.effective_host_cwd()?; Some((view.host(cx)?, cwd)) } diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 8b5dca54..4ac3f399 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -425,12 +425,7 @@ impl Tty7App { cwd_shown = tab .pane .focused_or_first(window, cx) - .and_then(|leaf| { - let view = leaf.read(cx); - view.git_status_cwd() - .map(|p| p.to_path_buf()) - .or_else(|| view.cwd()) - }) + .and_then(|leaf| leaf.read(cx).effective_cwd()) .map(|cwd| { let full = SharedString::from( abbreviate_home(&cwd.display().to_string()).into_owned(),